245 lines
6.4 KiB
Go
245 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
// External
|
|
tcell "github.com/gdamore/tcell/v2"
|
|
runewidth "github.com/mattn/go-runewidth"
|
|
|
|
// Internal
|
|
"launcher/log"
|
|
|
|
// Standard
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
screen tcell.Screen
|
|
|
|
styleDefault tcell.Style
|
|
styleHeader tcell.Style
|
|
styleResultRegular tcell.Style
|
|
styleResultExecutable tcell.Style
|
|
styleResultInvalid tcell.Style
|
|
styleError tcell.Style
|
|
|
|
search string
|
|
searchRunes map[rune]struct{}
|
|
|
|
lastExecutableCommand *CommandSet
|
|
)
|
|
|
|
// Init initializes the screen with ANSI command codes.
|
|
func InitUI() {
|
|
// The terminal screen needs to be initiated by tcell.
|
|
var err error
|
|
screen, err = tcell.NewScreen()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := screen.Init(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Styles
|
|
styleDefault = tcell.StyleDefault
|
|
styleHeader = tcell.StyleDefault.Foreground(tcell.Color106)
|
|
styleError = tcell.StyleDefault.Foreground(tcell.Color124)
|
|
styleResultRegular = tcell.StyleDefault.Foreground(tcell.Color246)
|
|
styleResultExecutable = tcell.StyleDefault.Foreground(tcell.Color106)
|
|
styleResultInvalid = tcell.StyleDefault.Foreground(tcell.Color124)
|
|
|
|
// The accepted runes map is used to lookup every keystroke
|
|
// before appended to the search string.
|
|
searchRunes = make(map[rune]struct{})
|
|
for _, r := range []rune{
|
|
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
|
|
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
|
|
|
|
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
|
|
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'Y', 'V', 'W', 'X', 'Y', 'Z',
|
|
|
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
|
|
|
'.', '-', '_', ':', '+', '@', '*', '^', '$', ' ',
|
|
} {
|
|
searchRunes[r] = struct{}{}
|
|
}
|
|
|
|
// Want to be sure to start with a clean screen.
|
|
screen.Clear()
|
|
}
|
|
|
|
// addString places a new string in the screen buffer.
|
|
func addString(x, y int, style tcell.Style, str string) {
|
|
for _, c := range str {
|
|
var comb []rune
|
|
w := runewidth.RuneWidth(c)
|
|
if w == 0 {
|
|
comb = []rune{c}
|
|
c = ' '
|
|
w = 1
|
|
}
|
|
screen.SetContent(x, y, c, comb, style)
|
|
x += w
|
|
}
|
|
}
|
|
|
|
// renderScreen composes all application state into a rendered screen image.
|
|
func renderScreen(cmd *CommandSet) {
|
|
screen.Clear()
|
|
lastExecutableCommand = nil
|
|
|
|
addString(0, 0, styleHeader, "Command:")
|
|
addString(0, 1, styleHeader, " Path:")
|
|
|
|
addString(9, 0, styleDefault, search)
|
|
|
|
results, err := cmd.Search(search)
|
|
if err != nil {
|
|
addString(0, 3, styleError, err.Error())
|
|
} else {
|
|
pos := 9
|
|
last := len(results)-1
|
|
for i, result := range results {
|
|
// Search errors likely is a regex syntax error or output from dynamic script.
|
|
if result.Error != "" {
|
|
errorLines := strings.Split(result.Error, "\n")
|
|
for j, line := range errorLines {
|
|
addString(9, 3+j, styleError, line)
|
|
}
|
|
continue
|
|
}
|
|
|
|
switch len(result.Commands) {
|
|
case 0:
|
|
addString(pos, 1, styleResultInvalid, result.Search)
|
|
pos += len(result.Search) + 1
|
|
|
|
// A single result was returned.
|
|
// We add it to the path of uniquely matched commands, and displays all
|
|
// children if it is the last command, so the user will known what can
|
|
// be selected after this command.
|
|
case 1:
|
|
if i == last && result.Commands[0].Exec != "" {
|
|
addString(pos, 1, styleResultExecutable, result.Commands[0].Name)
|
|
|
|
// lastMatchedCommand is stored to be used for the enter key event,
|
|
// when the last displayed executable command is executed.
|
|
lastExecutableCommand = result.Commands[0]
|
|
} else {
|
|
addString(pos, 1, styleResultRegular, result.Commands[0].Name)
|
|
}
|
|
|
|
if i == last {
|
|
// Sorted output is more consistent for finding items
|
|
sort.SliceStable(result.Commands[0].Commands, func(i, j int) bool {
|
|
return result.Commands[0].Commands[i].Name < result.Commands[0].Commands[j].Name
|
|
})
|
|
|
|
for i, matchingCommand := range result.Commands[0].Commands {
|
|
addString( 9, 3+i, styleDefault, matchingCommand.Name)
|
|
}
|
|
}
|
|
pos += len(result.Commands[0].Name) + 1
|
|
|
|
// More than one command was returned.
|
|
// A list of them is displayed for the user to choose from.
|
|
default:
|
|
addString(pos, 1, styleResultInvalid, result.Search)
|
|
|
|
// Sorted output is more consistent for finding items
|
|
sort.SliceStable(result.Commands, func(i, j int) bool {
|
|
return result.Commands[i].Name < result.Commands[j].Name
|
|
})
|
|
|
|
for j, matchingCommand := range result.Commands {
|
|
addString( 9, 3+j, styleDefault, matchingCommand.Name)
|
|
}
|
|
pos += len(result.Search) + 1
|
|
}
|
|
}
|
|
}
|
|
|
|
screen.ShowCursor(9+len(search), 0)
|
|
screen.Show()
|
|
}
|
|
|
|
// Loop is the main input loop and never returns.
|
|
func LoopUI(cmd *CommandSet) {
|
|
for {
|
|
switch ev := screen.PollEvent().(type) {
|
|
case *tcell.EventResize:
|
|
screen.Sync()
|
|
renderScreen(cmd)
|
|
|
|
case *tcell.EventKey:
|
|
// Program exits with escape.
|
|
if ev.Key() == tcell.KeyEscape {
|
|
screen.Fini()
|
|
os.Exit(0)
|
|
}
|
|
|
|
// Backspace, because of course!
|
|
if ev.Key() == tcell.KeyBackspace2 || ev.Key() == tcell.KeyBS {
|
|
if last := len(search)-1; last >= 0 {
|
|
search = search[:last]
|
|
renderScreen(cmd)
|
|
}
|
|
}
|
|
|
|
// Ctrl+W for removing last word is a must.
|
|
if ev.Key() == tcell.KeyCtrlW {
|
|
// It feels natural to remove all trailing whitespace
|
|
// along with the previous word.
|
|
search = strings.TrimRight(search, " ")
|
|
|
|
if idx := strings.LastIndex(search, " "); idx > -1 {
|
|
search = search[:idx+1]
|
|
} else {
|
|
// No space before cursor - first word on line.
|
|
search = ""
|
|
}
|
|
|
|
renderScreen(cmd)
|
|
}
|
|
|
|
// And sometimes it is nice to start from the beginning.
|
|
if ev.Key() == tcell.KeyCtrlU {
|
|
search = ""
|
|
renderScreen(cmd)
|
|
}
|
|
|
|
// To make it simple, a small subset of keys are
|
|
// allowed appending to the search string.
|
|
if _, ok := searchRunes[ev.Rune()]; ok {
|
|
search += string(ev.Rune())
|
|
renderScreen(cmd)
|
|
}
|
|
|
|
// Commands are executed with the Enter key.
|
|
if ev.Key() == tcell.KeyEnter {
|
|
if lastExecutableCommand != nil {
|
|
log.Print("%v", lastExecutableCommand.Exec)
|
|
err := run(lastExecutableCommand)
|
|
screen.Fini()
|
|
if err != nil {
|
|
fmt.Printf("%s\n", err)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close sends ANSI sequences to close the screen and return the console to normal.
|
|
func Close() {
|
|
if screen != nil {
|
|
screen.Fini()
|
|
}
|
|
}
|