Initial commit
This commit is contained in:
commit
6c071192e1
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
launcher
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 M-Ahall
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
150
commandset.go
Normal file
150
commandset.go
Normal file
@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
// External
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
// Internal
|
||||
"launcher/log"
|
||||
|
||||
// Standard
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Create reads the file and parses its YAML content.
|
||||
func CreateCommandSet(fName string) (*CommandSet, error) {
|
||||
var commandSet CommandSet
|
||||
fileContent, err := ioutil.ReadFile(fName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = yaml.UnmarshalStrict(fileContent, &commandSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &commandSet, nil
|
||||
}
|
||||
|
||||
// Dump marshals the struct and outputs it.
|
||||
func (cs *CommandSet) Dump() {
|
||||
if str, err := yaml.Marshal(cs); err != nil {
|
||||
fmt.Println(err)
|
||||
} else {
|
||||
fmt.Printf("%s\n", str)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCommand returns the commands with names matching the given regex.
|
||||
func (cs *CommandSet) GetCommands(nameRegex string) ([]*CommandSet, error) {
|
||||
var results []*CommandSet
|
||||
re, err := regexp.Compile(nameRegex)
|
||||
if err != nil {
|
||||
return []*CommandSet{}, err
|
||||
}
|
||||
|
||||
for _, cmd := range cs.Commands {
|
||||
if re.MatchString(cmd.Name) {
|
||||
// A commandset can be dynamic, which needs to run a script that generates YAML items.
|
||||
if cmd.Dynamic != "" && !cmd.completed {
|
||||
configData, err := exec.Command("/bin/bash", "-c", cmd.Dynamic).Output()
|
||||
if err != nil {
|
||||
if exErr, ok := err.(*exec.ExitError); ok {
|
||||
return nil, fmt.Errorf("Script:\n%s", exErr.Stderr)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Ignore the topmost name and add its commands instead,
|
||||
// to prevent a forced extra level as we need one toplevel commandset.
|
||||
var commandSet CommandSet
|
||||
err = yaml.UnmarshalStrict(configData, &commandSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, commands := range commandSet.Commands {
|
||||
cmd.Commands = append(cmd.Commands, commands)
|
||||
}
|
||||
log.Print("dynamic data: %s", configData)
|
||||
cmd.completed = true
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, cmd)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Search takes a search string, splits it and iteratively walks along the
|
||||
// provided commandset.
|
||||
func (cs *CommandSet) Search(search string) ([]SearchResult, error) {
|
||||
// Regex is much more flexible to search by than just string including
|
||||
// searched text. It also makes it possible to use ^ and $ when search
|
||||
// terms isn't conclusive.
|
||||
re := regexp.MustCompile("\\s+")
|
||||
words := re.Split(search, -1)
|
||||
if words[len(words)-1] == "" {
|
||||
words = words[:len(words)-1]
|
||||
}
|
||||
|
||||
var results []SearchResult
|
||||
currCommandset := cs
|
||||
|
||||
// We need to return everything when no search term is provided,
|
||||
// as the search term is more akin to a filter.
|
||||
if len(words) == 0 {
|
||||
words = []string{""}
|
||||
}
|
||||
|
||||
WordLoop:
|
||||
for _, w := range words {
|
||||
if cmds, err := currCommandset.GetCommands(w); err == nil {
|
||||
switch len(cmds) {
|
||||
// No more results, search is over.
|
||||
case 0:
|
||||
results = append(results, SearchResult{
|
||||
Search: w,
|
||||
Commands: []*CommandSet{},
|
||||
})
|
||||
break WordLoop
|
||||
|
||||
// Only one result
|
||||
case 1:
|
||||
results = append(results, SearchResult{
|
||||
Search: w,
|
||||
Commands: cmds,
|
||||
})
|
||||
currCommandset = cmds[0]
|
||||
|
||||
// Many results - return them to let user know what can be searched,
|
||||
// but the search is over.
|
||||
default:
|
||||
results = append(results, SearchResult{
|
||||
Search: w,
|
||||
Commands: cmds,
|
||||
})
|
||||
break WordLoop
|
||||
}
|
||||
|
||||
} else {
|
||||
// error in regex compilation or dynamic command execution
|
||||
results = append(results, SearchResult{
|
||||
Commands: []*CommandSet{},
|
||||
Error: err.Error(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// refresh runs the dynamic script, parses the input and puts it into place.
|
||||
func (cs *CommandSet) refresh() error {
|
||||
return nil
|
||||
}
|
77
dir.go
Normal file
77
dir.go
Normal file
@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
// Standard
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// getConfigDir figures out the path of the configuration directory.
|
||||
// Uses env variables appropriate for the OS, or uses the user provided path.
|
||||
func getConfigDir() (string, error) {
|
||||
var envName string
|
||||
var path []string
|
||||
|
||||
// User-provided directory takes priority over default directory.
|
||||
if flagConfigDir != "" {
|
||||
return flagConfigDir, nil
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux", "darwin":
|
||||
envName = "HOME"
|
||||
path = []string{".config", "launcher"}
|
||||
case "windows":
|
||||
envName = "USERPROFILE"
|
||||
path = []string{"launcher"}
|
||||
default:
|
||||
return "", fmt.Errorf("Unknown OS: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
home := os.Getenv(envName)
|
||||
if home == "" {
|
||||
return "", fmt.Errorf("%s is not set", envName)
|
||||
}
|
||||
path = append([]string{home}, path...)
|
||||
configDir := filepath.Join(path...)
|
||||
return configDir, nil
|
||||
}
|
||||
|
||||
|
||||
// getCommandsetFiles find the home directory and retrieves YAML files
|
||||
// not hidden.
|
||||
func getCommandsetFiles() ([]string, error) {
|
||||
dir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileInfos, err := ioutil.ReadDir(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// No filenamer starting with '.', and no filenames not ending in
|
||||
// ".yaml" or ".yml".
|
||||
var filenames []string
|
||||
var filename string
|
||||
for _, entry := range fileInfos {
|
||||
fName := entry.Name()
|
||||
// Shortest considered file would by something like 'a.yml',
|
||||
// 5 characters.
|
||||
if len(fName) < 5 || fName[0] == '.' {
|
||||
continue
|
||||
}
|
||||
|
||||
if m, _ := regexp.Match("(?i)\\.ya?ml", []byte(fName)); m {
|
||||
filename = filepath.Join(dir, entry.Name())
|
||||
filenames = append(filenames, filename)
|
||||
}
|
||||
}
|
||||
return filenames, nil
|
||||
}
|
19
go.mod
Normal file
19
go.mod
Normal file
@ -0,0 +1,19 @@
|
||||
module launcher
|
||||
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.2.0
|
||||
github.com/mattn/go-runewidth v0.0.10
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gdamore/encoding v1.0.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 // indirect
|
||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect
|
||||
golang.org/x/text v0.3.5 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
)
|
32
go.sum
Normal file
32
go.sum
Normal file
@ -0,0 +1,32 @@
|
||||
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell/v2 v2.2.0 h1:vSyEgKwraXPSOkvCk7IwOSyX+Pv3V2cV9CikJMXg4U4=
|
||||
github.com/gdamore/tcell/v2 v2.2.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 h1:46ULzRKLh1CwgRq2dC5SlBzEqqNCi8rreOZnNrbqcIY=
|
||||
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE=
|
||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
23
log/pkg.go
Normal file
23
log/pkg.go
Normal file
@ -0,0 +1,23 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
// Standard
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
logFile os.File
|
||||
log *bufio.Writer
|
||||
)
|
||||
|
||||
func init() {
|
||||
logFile, _ := os.OpenFile("/tmp/launcher.log", os.O_APPEND | os.O_CREATE | os.O_WRONLY, 0644)
|
||||
log = bufio.NewWriter(logFile)
|
||||
}
|
||||
|
||||
func Print(format string, params ...interface{}) {
|
||||
fmt.Fprintf(log, format+"\n", params...)
|
||||
log.Flush()
|
||||
}
|
66
main.go
Normal file
66
main.go
Normal file
@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
// Standard
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
var (
|
||||
mainCommandset CommandSet
|
||||
flagConfigDir string
|
||||
)
|
||||
|
||||
func main() {
|
||||
var defaultConfigDir string
|
||||
switch runtime.GOOS {
|
||||
case "linux", "darwin":
|
||||
defaultConfigDir = "Default is $HOME/.config/launcher/."
|
||||
case "windows":
|
||||
defaultConfigDir = "Default is %USERPROFILE\\/launcher\\."
|
||||
}
|
||||
flag.StringVar(
|
||||
&flagConfigDir,
|
||||
"config",
|
||||
"",
|
||||
"Specify the directory containing configuration.\n"+
|
||||
defaultConfigDir,
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
// Finding the files to parse commands from
|
||||
files, err := getCommandsetFiles()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Actually reading and parsing the YAML files into the
|
||||
// CommandSet structs
|
||||
errors := false
|
||||
for _, fName := range files {
|
||||
cset, err := CreateCommandSet(fName)
|
||||
if err != nil {
|
||||
fmt.Printf("\x1b[31m%s:\x1b[0m\n%s\n\n", fName, err)
|
||||
errors = true
|
||||
} else {
|
||||
mainCommandset.Commands = append(
|
||||
mainCommandset.Commands,
|
||||
cset,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if errors {
|
||||
fmt.Printf("\x1b[33mPress enter to continue\x1b[0m\n")
|
||||
bufio.NewReader(os.Stdin).ReadBytes('\n')
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Screen needs to be initialized
|
||||
InitUI()
|
||||
LoopUI(&mainCommandset)
|
||||
}
|
29
run_linux.go
Normal file
29
run_linux.go
Normal file
@ -0,0 +1,29 @@
|
||||
// +build linux
|
||||
package main
|
||||
|
||||
import (
|
||||
// Standard
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func run(cmd *CommandSet) error {
|
||||
screen.Fini()
|
||||
c := exec.Command("/bin/bash", "-c", lastExecutableCommand.Exec)
|
||||
if lastExecutableCommand.Inplace {
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
c.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
Pgid: 0,
|
||||
}
|
||||
c.Start()
|
||||
}
|
||||
return nil
|
||||
}
|
28
run_windows.go
Normal file
28
run_windows.go
Normal file
@ -0,0 +1,28 @@
|
||||
// +build windows
|
||||
package main
|
||||
|
||||
import (
|
||||
// Standard
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func run(cmd *CommandSet) error {
|
||||
screen.Fini()
|
||||
if lastExecutableCommand.Inplace {
|
||||
c := exec.Command("cmd", "/C", cmd.Exec)
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
c := exec.Command("cmd", "/C", "start /b "+cmd.Exec)
|
||||
if err := c.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
18
types.go
Normal file
18
types.go
Normal file
@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
type CommandSet struct {
|
||||
Name string
|
||||
Exec string
|
||||
Inplace bool
|
||||
Dynamic string
|
||||
Commands []*CommandSet
|
||||
|
||||
// internal state
|
||||
completed bool
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Search string
|
||||
Error string
|
||||
Commands []*CommandSet
|
||||
}
|
245
ui.go
Normal file
245
ui.go
Normal file
@ -0,0 +1,245 @@
|
||||
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()
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user