commit 6c071192e165ca7f7d2419ca41df9cfba9deb188 Author: Magnus Ă…hall Date: Sat Aug 19 09:30:59 2023 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b4dfa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +launcher diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2a91415 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/commandset.go b/commandset.go new file mode 100644 index 0000000..c2f59ff --- /dev/null +++ b/commandset.go @@ -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 +} diff --git a/dir.go b/dir.go new file mode 100644 index 0000000..ac08c8c --- /dev/null +++ b/dir.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..75d0453 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fb3f194 --- /dev/null +++ b/go.sum @@ -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= diff --git a/log/pkg.go b/log/pkg.go new file mode 100644 index 0000000..683df0b --- /dev/null +++ b/log/pkg.go @@ -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() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..403c7ae --- /dev/null +++ b/main.go @@ -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) +} diff --git a/run_linux.go b/run_linux.go new file mode 100644 index 0000000..0559a37 --- /dev/null +++ b/run_linux.go @@ -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 +} diff --git a/run_windows.go b/run_windows.go new file mode 100644 index 0000000..1a4ea39 --- /dev/null +++ b/run_windows.go @@ -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 +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..a04a315 --- /dev/null +++ b/types.go @@ -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 +} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..5fd1008 --- /dev/null +++ b/ui.go @@ -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() + } +}