// Copyright 2018 The up AUTHORS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // up is the Ultimate Plumber, a tool for writing Linux pipes in a // terminal-based UI interactively, with instant live preview of command // results. package main import ( "bufio" "bytes" "context" "crypto/sha1" "errors" "fmt" "io" "io/ioutil" "log" "os" "os/exec" "sync" "unicode" "github.com/gdamore/tcell" "github.com/gdamore/tcell/terminfo" "github.com/mattn/go-isatty" "github.com/spf13/pflag" ) const version = "0.4 (2020-10-29)" // TODO: in case of error, show it in red (bg?), then below show again initial normal output (see also #4) // TODO: F1 should display help, and it should be multi-line, and scrolling licensing credits // TODO: some key shortcut to increase stdin capture buffer size (unless EOF already reached) // TODO: show status infos: // - red fg + "up: process returned with error code %d" -- when subprocess returned an error // - yellow fg -- when process is still not finished // TODO: on github: add issues, incl. up-for-grabs / help-wanted // TODO: [LATER] make it work on Windows; maybe with mattn/go-shellwords ? // TODO: [LATER] Ctrl-O shows input via `less` or $PAGER // TODO: properly show all licenses of dependencies on --version // TODO: [LATER] on ^X (?), leave TUI and run the command through buffered input, then unpause rest of input // TODO: [LATER] allow adding more elements of pipeline (initially, just writing `foo | bar` should work) // TODO: [LATER] allow invocation with partial command, like: `up grep -i` (see also #11) // TODO: [LATER][MAYBE] allow reading upN.sh scripts (see also #11) // TODO: [MUCH LATER] readline-like rich editing support? and completion? (see also #28) // TODO: [MUCH LATER] integration with fzf? and pindexis/marker? // TODO: [LATER] forking and unforking pipelines (see also #4) // TODO: [LATER] capture output of a running process (see: https://stackoverflow.com/q/19584825/98528) // TODO: [LATER] richer TUI: // - show # of read lines & kbytes // - show status (errorlevel) of process, or that it's still running (also with background colors) // - allow copying and pasting to/from command line // TODO: [LATER] allow connecting external editor (become server/engine via e.g. socket) // TODO: [LATER] become pluggable into http://luna-lang.org // TODO: [LATER][MAYBE] allow "plugins" ("combos" - commands with default options) e.g. for Lua `lua -e`+auto-quote, etc. // TODO: [LATER] make it more friendly to infrequent Linux users by providing "descriptive" commands like "search" etc. // TODO: [LATER] advertise on some reddits for data exploration / data science // TODO: [LATER] undo/redo - history of commands (see also #4) // TODO: [LATER] jump between buffers saved from earlier pipe fragments; OR: allow saving/recalling "snapshots" of (cmd, results) pairs (see also #4) // TODO: [LATER] ^-, U -- to switch to "unsafe mode"? -u to switch back? + some visual marker func init() { pflag.Usage = func() { fmt.Fprint(os.Stderr, `Usage: COMMAND | up [OPTIONS] up is the Ultimate Plumber, a tool for writing Linux pipes in a terminal-based UI interactively, with instant live preview of command results. To start using up, redirect any text-emitting command (or pipeline) into it - for example: $ lshw |& ./up Ultimate Plumber then opens a full-screen terminal app. The top line of the screen can be edited in order to interactively build a pipeline. Every time you hit [Enter], the bottom of the screen will display the results of passing the up's standard input through the pipeline (executed using your default $SHELL). If a tilde '~' is visible in top-left corner, it indicates that Ultimate Plumber did not yet fully consume its input. Some pipelines may not finish with incomplete input; use Ctrl-S to freeze reading the input and to inject fake EOF; use Ctrl-Q to unfreeze back and continue reading. If a plus '+' is visible in top-left corner, the internal buffer limit (default: 40MB) was reached and Ultimate Plumber won't read more input. KEYS - alphanumeric & symbol keys, Left, Right, Ctrl-A/E/B/F/K/Y/W - navigate and edit the pipeline command - Enter - execute the pipeline command, updating the pipeline output panel - Up, Dn, PgUp, PgDn, Ctrl-Left, Ctrl-Right - navigate (scroll) the pipeline output panel - Ctrl-X - exit and write the pipeline to up1.sh (or if it exists then to up2.sh, etc. till up1000.sh) - Ctrl-C - quit without saving and emit the pipeline on standard output - Ctrl-S - temporarily freeze a long-running input to Ultimate Plumber, injecting a fake EOF into the buffer (shows '#' indicator in top-left corner) - Ctrl-Q - unfreeze back after Ctrl-S (disables '#' indicator) OPTIONS `) pflag.PrintDefaults() fmt.Fprint(os.Stderr, ` HOMEPAGE: https://github.com/akavel/up VERSION: `+version+` `) } pflag.ErrHelp = errors.New("") // TODO: or something else? } var ( // TODO: dangerous? immediate? raw? unsafe? ... // FIXME(akavel): mark the unsafe mode vs. safe mode with some colour or status; also inform/mark what command's results are displayed... unsafeMode = pflag.Bool("unsafe-full-throttle", false, "enable mode in which pipeline is executed immediately after any change (without pressing Enter)") outputScript = pflag.StringP("output-script", "o", "", "save the command to specified `file` if Ctrl-X is pressed (default: up.sh)") debugMode = pflag.Bool("debug", false, "debug mode") noColors = pflag.Bool("no-colors", false, "disable interface colors") shellFlag = pflag.StringArrayP("exec", "e", nil, "`command` to run pipeline with; repeat multiple times to pass multi-word command; defaults to '-e=$SHELL -e=-c'") initialCmd = pflag.StringP("pipeline", "c", "", "initial `commands` to use as pipeline (default empty)") bufsize = pflag.Int("buf", 40, "input buffer size & pipeline buffer sizes in `megabytes` (MiB)") noinput = pflag.Bool("noinput", false, "start with empty buffer regardless if any input was provided") ) func main() { // Handle command-line flags pflag.Parse() log.SetOutput(ioutil.Discard) if *debugMode { debug, err := os.Create("up.debug") if err != nil { die(err.Error()) } log.SetOutput(debug) } // Find out what is the user's preferred login shell. This also allows user // to choose the "engine" used for command execution. shell := *shellFlag if len(shell) == 0 { log.Println("checking $SHELL...") sh := os.Getenv("SHELL") if sh != "" { goto shell_found } log.Println("checking bash...") sh, _ = exec.LookPath("bash") if sh != "" { goto shell_found } log.Println("checking sh...") sh, _ = exec.LookPath("sh") if sh != "" { goto shell_found } die("cannot find shell: no -e flag, $SHELL is empty, neither bash nor sh are in $PATH") shell_found: shell = []string{sh, "-c"} } log.Println("found shell:", shell) stdin := io.Reader(os.Stdin) if *noinput { stdin = bytes.NewReader(nil) } else if isatty.IsTerminal(os.Stdin.Fd()) { // TODO: Without this block, we'd hang when nothing is piped on input (see // github.com/peco/peco, mattn/gof, fzf, etc.) die("up requires some data piped on standard input, for example try: `echo hello world | up`") } // Initialize TUI infrastructure tui := initTUI() defer tui.Fini() // Initialize 3 main UI parts var ( // The top line of the TUI is an editable command, which will be used // as a pipeline for data we read from stdin commandEditor = NewEditor("| ", *initialCmd) // The rest of the screen is a view of the results of the command commandOutput = BufView{} // Sometimes, a message may be displayed at the bottom of the screen, with help or other info message = `Enter runs ^X exit (^C nosave) PgUp/PgDn/Up/Dn/^ scroll ^S pause (^Q end) [Ultimate Plumber v` + version + ` by akavel et al.]` ) // Initialize main data flow var ( // We capture data piped to 'up' on standard input into an internal buffer // When some new data shows up on stdin, we raise a custom signal, // so that main loop will refresh the buffers and the output. stdinCapture = NewBuf(*bufsize*1024*1024). StartCapturing(stdin, func() { triggerRefresh(tui) }) // Then, we pass this data as input to a subprocess. // Initially, no subprocess is running, as no command is entered yet commandSubprocess *Subprocess = nil ) // Intially, for user's convenience, show the raw input data, as if `cat` command was typed commandOutput.Buf = stdinCapture // Main loop lastCommand := "" restart := false for { // If user edited the command, immediately run it in background, and // kill the previously running command. command := commandEditor.String() if restart || (*unsafeMode && command != lastCommand) { commandSubprocess.Kill() if command != "" { commandSubprocess = StartSubprocess(shell, command, stdinCapture, func() { triggerRefresh(tui) }) commandOutput.Buf = commandSubprocess.Buf } else { // If command is empty, show original input data again (~ equivalent of typing `cat`) commandSubprocess = nil commandOutput.Buf = stdinCapture } restart = false lastCommand = command } // Draw UI w, h := tui.Size() style := whiteOnBlue if command == lastCommand { style = whiteOnDBlue } stdinCapture.DrawStatus(TuiRegion(tui, 0, 0, 1, 1), style) commandEditor.DrawTo(TuiRegion(tui, 1, 0, w-1, 1), style, func(x, y int) { tui.ShowCursor(x+1, 0) }) commandOutput.DrawTo(TuiRegion(tui, 0, 1, w, h-1)) drawText(TuiRegion(tui, 0, h-1, w, 1), whiteOnBlue, message) tui.Show() // Handle UI events switch ev := tui.PollEvent().(type) { // Key pressed case *tcell.EventKey: // Is it a command editor key? if commandEditor.HandleKey(ev) { message = "" continue } // Is it a command output view key? if commandOutput.HandleKey(ev, h-1) { message = "" continue } // Some other global key combinations switch getKey(ev) { case key(tcell.KeyEnter): restart = true case key(tcell.KeyCtrlUnderscore), ctrlKey(tcell.KeyCtrlUnderscore): // TODO: ask for another character to trigger command-line option, like in `less` case key(tcell.KeyCtrlS), ctrlKey(tcell.KeyCtrlS): stdinCapture.Pause(true) triggerRefresh(tui) case key(tcell.KeyCtrlQ), ctrlKey(tcell.KeyCtrlQ): stdinCapture.Pause(false) restart = true case key(tcell.KeyCtrlC), ctrlKey(tcell.KeyCtrlC), key(tcell.KeyCtrlD), ctrlKey(tcell.KeyCtrlD): // Quit tui.Fini() os.Stderr.WriteString("up: Ultimate Plumber v" + version + " https://github.com/akavel/up\n") os.Stderr.WriteString("up: | " + commandEditor.String() + "\n") return case key(tcell.KeyCtrlX), ctrlKey(tcell.KeyCtrlX): // Write script 'upN.sh' and quit tui.Fini() writeScript(shell, commandEditor.String(), tui) return } } } } func initTUI() tcell.Screen { // TODO: maybe try gocui or termbox? tui, err := tcell.NewScreen() if err == terminfo.ErrTermNotFound { term := os.Getenv("TERM") hash := sha1.Sum([]byte(term)) // TODO: add a flag which would attempt to perform the download automatically if explicitly requested by user die(fmt.Sprintf(`%[1]s Your terminal code: TERM=%[2]s was not found in the database provided by tcell library. Please try checking if a supplemental database is found for your terminal at one of the following URLs: https://github.com/gdamore/tcell/raw/master/terminfo/database/%.1[3]x/%.4[3]x https://github.com/gdamore/tcell/raw/master/terminfo/database/%.1[3]x/%.4[3]x.gz If yes, download it and save in the following directory: $HOME/.tcelldb/%.1[3]x/ then try running "up" again. If that does not work for you, please first consult: https://github.com/akavel/up/issues/15 and if you don't see your terminal code mentioned there, please try asking on: https://github.com/gdamore/tcell/issues Or, you might try changing TERM temporarily to some other value, for example by running "up" with: TERM=xterm up Good luck!`, err, term, hash)) } if err != nil { die(err.Error()) } err = tui.Init() if err != nil { die(err.Error()) } return tui } func triggerRefresh(tui tcell.Screen) { tui.PostEvent(tcell.NewEventInterrupt(nil)) } func die(message string) { os.Stderr.WriteString("error: " + message + "\n") os.Exit(1) } func NewEditor(prompt, value string) *Editor { v := []rune(value) return &Editor{ prompt: []rune(prompt), value: v, cursor: len(v), lastw: len(v), } } type Editor struct { // TODO: make editor multiline. Reuse gocui or something for this? prompt []rune value []rune killspace []rune cursor int // lastw is length of value on last Draw; we need it to know how much to erase after backspace lastw int } func (e *Editor) String() string { return string(e.value) } func (e *Editor) DrawTo(region Region, style tcell.Style, setcursor func(x, y int)) { // Draw prompt & the edited value - use white letters on blue background for i, ch := range e.prompt { region.SetCell(i, 0, style, ch) } for i, ch := range e.value { region.SetCell(len(e.prompt)+i, 0, style, ch) } // Clear remains of last value if needed for i := len(e.value); i < e.lastw; i++ { region.SetCell(len(e.prompt)+i, 0, tcell.StyleDefault, ' ') } e.lastw = len(e.value) // Show cursor if requested if setcursor != nil { setcursor(len(e.prompt)+e.cursor, 0) } } func (e *Editor) HandleKey(ev *tcell.EventKey) bool { // If a character is entered, with no modifiers except maybe shift, then just insert it if ev.Key() == tcell.KeyRune && ev.Modifiers()&(^tcell.ModShift) == 0 { e.insert(ev.Rune()) return true } // Handle editing & movement keys switch getKey(ev) { case key(tcell.KeyBackspace), key(tcell.KeyBackspace2): // See https://github.com/nsf/termbox-go/issues/145 e.delete(-1) case key(tcell.KeyDelete): e.delete(0) case key(tcell.KeyLeft), key(tcell.KeyCtrlB), ctrlKey(tcell.KeyCtrlB): if e.cursor > 0 { e.cursor-- } case key(tcell.KeyRight), key(tcell.KeyCtrlF), ctrlKey(tcell.KeyCtrlF): if e.cursor < len(e.value) { e.cursor++ } case key(tcell.KeyCtrlA), ctrlKey(tcell.KeyCtrlA): e.cursor = 0 case key(tcell.KeyCtrlE), ctrlKey(tcell.KeyCtrlE): e.cursor = len(e.value) case key(tcell.KeyCtrlK), ctrlKey(tcell.KeyCtrlK): e.kill() case key(tcell.KeyCtrlY), ctrlKey(tcell.KeyCtrlY): e.insert(e.killspace...) case key(tcell.KeyCtrlW), ctrlKey(tcell.KeyCtrlW): e.unixWordRubout() default: // Unknown key/combination, not handled return false } return true } func (e *Editor) insert(ch ...rune) { // Based on https://github.com/golang/go/wiki/SliceTricks#insert e.value = append(e.value, ch...) // = PREFIX + SUFFIX + (filler) copy(e.value[e.cursor+len(ch):], e.value[e.cursor:]) // = PREFIX + (filler) + SUFFIX copy(e.value[e.cursor:], ch) // = PREFIX + ch + SUFFIX e.cursor += len(ch) } func (e *Editor) delete(dx int) { pos := e.cursor + dx if pos < 0 || pos >= len(e.value) { return } e.value = append(e.value[:pos], e.value[pos+1:]...) e.cursor = pos } func (e *Editor) kill() { if e.cursor != len(e.value) { e.killspace = append(e.killspace[:0], e.value[e.cursor:]...) } e.value = e.value[:e.cursor] } // unixWordRubout removes the part of the word on the left of the cursor. A word is // delimited by whitespaces. // The term `unix-word-rubout` comes from `readline` (see `man 3 readline`) func (e *Editor) unixWordRubout() { if e.cursor <= 0 { return } pos := e.cursor - 1 for pos != 0 && (unicode.IsSpace(e.value[pos]) || !unicode.IsSpace(e.value[pos-1])) { pos-- } e.killspace = append(e.killspace[:0], e.value[pos:e.cursor]...) e.value = append(e.value[:pos], e.value[e.cursor:]...) e.cursor = pos } type BufView struct { // TODO: Wrap bool Y int // Y of the view in the Buf, for down/up scrolling X int // X of the view in the Buf, for left/right scrolling Buf *Buf } func (v *BufView) DrawTo(region Region) { r := bufio.NewReader(v.Buf.NewReader(false)) // PgDn/PgUp etc. support for y := v.Y; y > 0; y-- { line, err := r.ReadBytes('\n') switch err { case nil: // skip line continue case io.EOF: r = bufio.NewReader(bytes.NewReader(line)) y = 0 break default: panic(err) } } lclip := false drawch := func(x, y int, ch rune) { if x <= v.X && v.X != 0 { x, ch = 0, '«' lclip = true } else { x -= v.X } if x >= region.W { x, ch = region.W-1, '»' } region.SetCell(x, y, tcell.StyleDefault, ch) } endline := func(x, y int) { x -= v.X if x < 0 { x = 0 } if x == 0 && lclip { x++ } lclip = false for ; x < region.W; x++ { region.SetCell(x, y, tcell.StyleDefault, ' ') } } x, y := 0, 0 // TODO: handle runes properly, including their visual width (mattn/go-runewidth) for { ch, _, err := r.ReadRune() if y >= region.H || err == io.EOF { break } else if err != nil { panic(err) } switch ch { case '\n': endline(x, y) x, y = 0, y+1 continue case '\t': const tabwidth = 8 drawch(x, y, ' ') for x%tabwidth < (tabwidth - 1) { x++ if x >= region.W { break } drawch(x, y, ' ') } default: drawch(x, y, ch) } x++ } for ; y < region.H; y++ { endline(x, y) x = 0 } } func (v *BufView) HandleKey(ev *tcell.EventKey, scrollY int) bool { const scrollX = 8 // When user scrolls horizontally, move by this many characters switch getKey(ev) { // // Vertical scrolling // case key(tcell.KeyUp): v.Y-- v.normalizeY() case key(tcell.KeyDown): v.Y++ v.normalizeY() case key(tcell.KeyPgDn): // TODO: in top-right corner of Buf area, draw current line number & total # of lines v.Y += scrollY v.normalizeY() case key(tcell.KeyPgUp): v.Y -= scrollY v.normalizeY() // // Horizontal scrolling // case altKey(tcell.KeyLeft), ctrlKey(tcell.KeyLeft): v.X -= scrollX if v.X < 0 { v.X = 0 } case altKey(tcell.KeyRight), ctrlKey(tcell.KeyRight): v.X += scrollX case altKey(tcell.KeyHome), ctrlKey(tcell.KeyHome): v.X = 0 default: // Unknown key/combination, not handled return false } return true } func (v *BufView) normalizeY() { nlines := count(v.Buf.NewReader(false), '\n') + 1 if v.Y >= nlines { v.Y = nlines - 1 } if v.Y < 0 { v.Y = 0 } } func count(r io.Reader, b byte) (n int) { buf := [256]byte{} for { i, err := r.Read(buf[:]) n += bytes.Count(buf[:i], []byte{b}) if err != nil { return } } } func NewBuf(bufsize int) *Buf { // TODO: make buffer size dynamic (growable by pressing a key) buf := &Buf{bytes: make([]byte, bufsize)} buf.cond = sync.NewCond(&buf.mu) return buf } type Buf struct { bytes []byte mu sync.Mutex // guards the following fields cond *sync.Cond status bufStatus n int } type bufStatus int const ( bufReading bufStatus = iota bufEOF bufPaused ) func (b *Buf) StartCapturing(r io.Reader, notify func()) *Buf { go b.capture(r, notify) return b } func (b *Buf) capture(r io.Reader, notify func()) { // TODO: allow stopping - take context? for { n, err := r.Read(b.bytes[b.n:]) b.mu.Lock() for b.status == bufPaused { b.cond.Wait() } b.n += n if err == io.EOF { b.status = bufEOF } if b.n == len(b.bytes) { // TODO: remove this when we can grow the buffer err = io.EOF } b.cond.Broadcast() b.mu.Unlock() go notify() if err == io.EOF { log.Printf("capture EOF after: %q", b.bytes[:b.n]) // TODO: make sure no race here, and skipped if not debugging return } else if err != nil { // TODO: better handling of errors panic(err) } } } func (b *Buf) Pause(pause bool) { b.mu.Lock() if pause { if b.status == bufReading { b.status = bufPaused // trigger all readers to emit fake EOF b.cond.Broadcast() } } else { if b.status == bufPaused { b.status = bufReading // wake up the capture func b.cond.Broadcast() } } b.mu.Unlock() } func (b *Buf) DrawStatus(region Region, style tcell.Style) { status := '~' // default: still reading input b.mu.Lock() switch { case b.status == bufPaused: status = '#' case b.status == bufEOF: status = ' ' // all input read, nothing more to do case b.n == len(b.bytes): status = '+' // buffer full } b.mu.Unlock() region.SetCell(0, 0, style, status) } func (b *Buf) NewReader(blocking bool) io.Reader { i := 0 return funcReader(func(p []byte) (n int, err error) { b.mu.Lock() end := b.n for blocking && end == i && b.status == bufReading && end < len(b.bytes) { b.cond.Wait() end = b.n } b.mu.Unlock() n = copy(p, b.bytes[i:end]) i += n if n > 0 { return n, nil } else { if blocking { log.Printf("blocking reader emitting EOF after: %q", b.bytes[:end]) } return 0, io.EOF } }) } type funcReader func([]byte) (int, error) func (f funcReader) Read(p []byte) (int, error) { return f(p) } type Subprocess struct { Buf *Buf cancel context.CancelFunc } func StartSubprocess(shell []string, command string, stdin *Buf, notify func()) *Subprocess { ctx, cancel := context.WithCancel(context.TODO()) r, w := io.Pipe() p := &Subprocess{ Buf: NewBuf(len(stdin.bytes)).StartCapturing(r, notify), cancel: cancel, } cmd := exec.CommandContext(ctx, shell[0], append(shell[1:], command)...) cmd.Stdout = w cmd.Stderr = w cmd.Stdin = stdin.NewReader(true) err := cmd.Start() if err != nil { fmt.Fprintf(w, "up: %s", err) w.Close() return p } log.Println(cmd.Path) go func() { err = cmd.Wait() if err != nil { fmt.Fprintf(w, "up: %s", err) log.Printf("Wait returned error: %s", err) } w.Close() }() return p } func (s *Subprocess) Kill() { if s == nil { return } s.cancel() } type key int32 func getKey(ev *tcell.EventKey) key { return key(ev.Modifiers())<<16 + key(ev.Key()) } func altKey(base tcell.Key) key { return key(tcell.ModAlt)<<16 + key(base) } func ctrlKey(base tcell.Key) key { return key(tcell.ModCtrl)<<16 + key(base) } func writeScript(shell []string, command string, tui tcell.Screen) { os.Stderr.WriteString("up: Ultimate Plumber v" + version + " https://github.com/akavel/up\n") var f *os.File var err error if *outputScript != "" { os.Stderr.WriteString("up: writing " + *outputScript) f, err = os.OpenFile(*outputScript, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { goto fallback_tmp } goto try_file } os.Stderr.WriteString("up: writing: .") for i := 1; i < 1000; i++ { f, err = os.OpenFile(fmt.Sprintf("up%d.sh", i), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0755) switch { case os.IsExist(err): continue case err != nil: goto fallback_tmp default: os.Stderr.WriteString("/" + f.Name()) goto try_file } } os.Stderr.WriteString(" - error: up1.sh-up999.sh already exist\n") goto fallback_tmp try_file: // NOTE: currently not supporting multi-word shell in upNNN.sh unfortunately :( _, err = fmt.Fprintf(f, "#!%s\n%s\n", shell[0], command) if err != nil { goto fallback_tmp } err = f.Close() if err != nil { goto fallback_tmp } os.Stderr.WriteString(" - OK\n") return fallback_tmp: // TODO: test if the fallbacks etc. protections actually work os.Stderr.WriteString(" - error: " + err.Error() + "\n") f, err = ioutil.TempFile("", "up-*.sh") if err != nil { goto fallback_print } _, err = fmt.Fprintf(f, "#!%s\n%s\n", shell, command) if err != nil { goto fallback_print } err = f.Close() if err != nil { goto fallback_print } os.Stderr.WriteString("up: writing: " + f.Name() + " - OK\n") os.Chmod(f.Name(), 0755) return fallback_print: fname := "TMP" if f != nil { fname = f.Name() } os.Stderr.WriteString("up: writing: " + fname + " - error: " + err.Error() + "\n") os.Stderr.WriteString("up: | " + command + "\n") } type Region struct { W, H int SetCell func(x, y int, style tcell.Style, ch rune) } func TuiRegion(tui tcell.Screen, x, y, w, h int) Region { return Region{ W: w, H: h, SetCell: func(dx, dy int, style tcell.Style, ch rune) { if dx >= 0 && dx < w && dy >= 0 && dy < h { if *noColors { style = tcell.StyleDefault } tui.SetCell(x+dx, y+dy, style, ch) } }, } } var ( whiteOnBlue = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlue) whiteOnDBlue = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorNavy) ) func drawText(region Region, style tcell.Style, text string) { for x, ch := range text { region.SetCell(x, 0, style, ch) } }