launcher/ui.go

246 lines
6.4 KiB
Go
Raw Permalink Normal View History

2023-08-19 09:30:59 +02:00
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()
}
}