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() } }