From 52fd4d6b65862c81fb83f3b25ef61c0f2c659342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Czapli=C5=84ski?= Date: Fri, 19 Oct 2018 01:32:34 +0200 Subject: refactor for better readability and style --- up.go | 595 ++++++++++++++++++++++++++++++++++++------------------------------ 1 file changed, 324 insertions(+), 271 deletions(-) diff --git a/up.go b/up.go index d3f733a..ea594bd 100644 --- a/up.go +++ b/up.go @@ -33,204 +33,254 @@ import ( "github.com/mattn/go-isatty" ) +// TODO: properly handle fully consumed buffers, to enable piping into `wc -l` or `uniq -c` etc. +// TODO: readme, asciinema +// 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] allow increasing size of input buffer with some key +// 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` +// TODO: [LATER][MAYBE] allow reading upN.sh scripts +// TODO: [LATER] auto-save and/or save on Ctrl-S or something +// TODO: [MUCH LATER] readline-like rich editing support? and completion? +// TODO: [MUCH LATER] integration with fzf? and pindexis/marker? +// TODO: [LATER] forking and unforking pipelines +// 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) +// - show if we've read whole stdin, or it's still not EOF +// - allow copying and pasting to/from command line +// - show help by default, including key to disable it (F1, Alt-h) +// 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: HN, r/programming, r/golang, r/commandline, r/linux, up-for-grabs.net; data exploration? data science? + func main() { - // TODO: Without below block, we'd hang with no piped input (see github.com/peco/peco, mattn/gof, fzf, etc.) - if isatty.IsTerminal(os.Stdin.Fd()) { - fmt.Fprintln(os.Stderr, "error: up requires some data piped on standard input, e.g.: `echo hello world | up`") - os.Exit(1) - } + // Handle command-line flags + parseFlags() - // Init TUI code - // TODO: maybe try gocui or tcell? - tui, err := tcell.NewScreen() - if err != nil { - panic(err) - } - err = tui.Init() - if err != nil { - panic(err) - } + // Initialize TUI infrastructure + tui := initTUI() defer tui.Fini() + // Initialize 2 main UI parts var ( - editor = NewEditor("| ") - lastCommand = "" - subprocess *Subprocess - inputBuf = NewBuf() - buf = inputBuf - bufView = BufView{} - bufY = 1 + // 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("| ") + // The rest of the screen is a view of the results of the command + commandOutput = BufView{ScreenY: 1} ) - const ( - xscroll = 8 - ) - - // In background, start collecting input from stdin to internal buffer of size 40 MB, then pause it - go inputBuf.Collect(os.Stdin, func() { - tui.PostEvent(tcell.NewEventInterrupt(nil)) - }) + _, screenH := tui.Size() + commandOutput.H = screenH - commandOutput.ScreenY - log.SetOutput(ioutil.Discard) - if len(os.Args) > 1 && os.Args[1] == "--debug" { - debug, err := os.Create("up.debug") - if err != nil { - panic(err) - } - log.SetOutput(debug) - } + // Initialize main data flow + var ( + stdinCapture = NewBuf().StartCapturing(os.Stdin, func() { + // When some new data shows up on stdin, we raise a custom signal, + // so further down we will know to refresh commandOutput + tui.PostEvent(tcell.NewEventInterrupt(nil)) + }) + // 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 -main_loop: + lastCommand := "" for { - // Run command automatically in background if user edited it (and kill previous command) - // TODO: allow stopping/restarting this behavior via Ctrl-Enter - // TODO: allow stopping this behavior via Ctrl-C (and killing current command), but invent some nice way to quit then - command := editor.String() + // If user edited the command, immediately run it in background, and + // kill the previously running command. + command := commandEditor.String() if command != lastCommand { - lastCommand = command - subprocess.Kill() + commandSubprocess.Kill() if command != "" { - subprocess = StartSubprocess(inputBuf, command, func() { + // TODO: wrap the PostEvent in some helper named func + commandSubprocess = StartSubprocess(command, stdinCapture, func() { tui.PostEvent(tcell.NewEventInterrupt(nil)) }) - buf = subprocess.Buf + commandOutput.Buf = commandSubprocess.Buf } else { // If command is empty, show original input data again (~ equivalent of typing `cat`) - subprocess = nil - buf = inputBuf + commandSubprocess = nil + commandOutput.Buf = stdinCapture } } + lastCommand = command - // Draw command input line - editor.Draw(tui, 0, 0, true) - buf.Draw(tui, bufY, bufView) + // Draw UI + commandEditor.Draw(tui, 0, 0, true) + commandOutput.Draw(tui) tui.Show() - // Handle events + // Handle UI events switch ev := tui.PollEvent().(type) { + // Key pressed case *tcell.EventKey: - // handle command-line editing keys - if editor.HandleKey(ev) { - continue main_loop + // Is it a command editor key? + if commandEditor.HandleKey(ev) { + continue + } + // Is it a command output view key? + if commandOutput.HandleKey(ev) { + continue } - // handle other keys - switch (keymod{ev.Key(), ev.Modifiers()}) { - case keymod{tcell.KeyCtrlC, 0}, - keymod{tcell.KeyCtrlC, tcell.ModCtrl}: - // quit + // Some other global key combinations + switch getKey(ev) { + case key(tcell.KeyCtrlC), + ctrlKey(tcell.KeyCtrlC): + // Quit + // TODO: print the command in case user did this accidentally return - case keymod{tcell.KeyCtrlX, 0}, - keymod{tcell.KeyCtrlX, tcell.ModCtrl}: - // write script and quit - writeScript(editor.String(), tui) + case key(tcell.KeyCtrlX), + ctrlKey(tcell.KeyCtrlX): + // Write script 'upN.sh' and quit + writeScript(commandEditor.String(), tui) return - // TODO: move buf scroll handlers to Buf or BufView struct - case keymod{tcell.KeyUp, 0}: - bufView.Y-- - bufView.NormalizeY(buf.Lines()) - case keymod{tcell.KeyDown, 0}: - bufView.Y++ - bufView.NormalizeY(buf.Lines()) - case keymod{tcell.KeyPgDn, 0}: - // TODO: in top-right corner of Buf area, draw current line number & total # of lines - _, h := tui.Size() - bufView.Y += h - bufY - 1 - bufView.NormalizeY(buf.Lines()) - case keymod{tcell.KeyPgUp, 0}: - _, h := tui.Size() - bufView.Y -= h - bufY - 1 - bufView.NormalizeY(buf.Lines()) - case keymod{tcell.KeyLeft, tcell.ModAlt}, - keymod{tcell.KeyLeft, tcell.ModCtrl}: - bufView.X -= xscroll - if bufView.X < 0 { - bufView.X = 0 - } - case keymod{tcell.KeyRight, tcell.ModAlt}, - keymod{tcell.KeyRight, tcell.ModCtrl}: - bufView.X += xscroll - case keymod{tcell.KeyHome, tcell.ModAlt}, - keymod{tcell.KeyHome, tcell.ModCtrl}: - bufView.X = 0 } } } +} - // TODO: properly handle fully consumed buffers, to enable piping into `wc -l` or `uniq -c` etc. - // TODO: readme, asciinema - // 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] allow increasing size of input buffer with some key - // 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` - // TODO: [LATER][MAYBE] allow reading upN.sh scripts - // TODO: [LATER] auto-save and/or save on Ctrl-S or something - // TODO: [MUCH LATER] readline-like rich editing support? and completion? - // TODO: [MUCH LATER] integration with fzf? and pindexis/marker? - // TODO: [LATER] forking and unforking pipelines - // 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: HN, r/programming, r/golang, r/commandline, r/linux, up-for-grabs.net; data exploration? data science? +func parseFlags() { + log.SetOutput(ioutil.Discard) + if len(os.Args) > 1 && os.Args[1] == "--debug" { + debug, err := os.Create("up.debug") + if err != nil { + die(err.Error()) + } + log.SetOutput(debug) + } } -type Buf struct { - bytes []byte +func initTUI() tcell.Screen { + // TODO: Without below block, we'd hang when nothing is piped on input (see + // github.com/peco/peco, mattn/gof, fzf, etc.) + if isatty.IsTerminal(os.Stdin.Fd()) { + die("up requires some data piped on standard input, for example try: `echo hello world | up`") + } - mu sync.Mutex // guards the following fields - cond *sync.Cond - // NOTE: n and eof can be written only by Collect - n int - eof bool + // Init TUI code + // TODO: maybe try gocui or termbox? + tui, err := tcell.NewScreen() + if err != nil { + die(err.Error()) + } + err = tui.Init() + if err != nil { + die(err.Error()) + } + return tui } -func NewBuf() *Buf { - const bufsize = 40 * 1024 * 1024 // 40 MB - buf := &Buf{bytes: make([]byte, bufsize)} - buf.cond = sync.NewCond(&buf.mu) - return buf +func die(message string) { + os.Stderr.WriteString("error: " + message + "\n") + os.Exit(1) } -func (b *Buf) Collect(r io.Reader, signal func()) { - // TODO: allow stopping - take context? - for { - n, err := r.Read(b.bytes[b.n:]) - b.mu.Lock() - b.n += n - if err == io.EOF { - b.eof = true - } - b.cond.Broadcast() - b.mu.Unlock() - go signal() - if err == io.EOF { - return - } else if err != nil { - // TODO: better handling of errors - panic(err) +func NewEditor(prompt string) *Editor { + return &Editor{prompt: []rune(prompt)} +} + +type Editor struct { + // TODO: make editor multiline. Reuse gocui or something for this? + prompt []rune + value []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) Draw(tui tcell.Screen, x, y int, setcursor bool) { + // Draw prompt & the edited value - use white letters on blue background + style := tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlue) + for i, ch := range e.prompt { + tui.SetCell(x+i, y, style, ch) + } + for i, ch := range e.value { + tui.SetCell(x+len(e.prompt)+i, y, style, ch) + } + + // Clear remains of last value if needed + for i := len(e.value); i < e.lastw; i++ { + tui.SetCell(x+len(e.prompt)+i, y, tcell.StyleDefault, ' ') + } + e.lastw = len(e.value) + + // Show cursor if requested + if setcursor { + tui.ShowCursor(x+len(e.prompt)+e.cursor, y) + } +} + +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): + if e.cursor > 0 { + e.cursor-- } - if b.n == len(b.bytes) { - return + case key(tcell.KeyRight): + if e.cursor < len(e.value) { + e.cursor++ } + default: + // Unknown key/combination, not handled + return false } + return true } -func (b *Buf) Draw(tui tcell.Screen, y0 int, view BufView) { - b.mu.Lock() - buf := b.bytes[:b.n] - b.mu.Unlock() +func (e *Editor) insert(ch rune) { + // Insert character into value (https://github.com/golang/go/wiki/SliceTricks#insert) + e.value = append(e.value, 0) + copy(e.value[e.cursor+1:], e.value[e.cursor:]) + e.value[e.cursor] = ch + e.cursor++ +} + +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 +} + +type BufView struct { + ScreenY int // Y position of first drawn line on screen + H int + // 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) Draw(tui tcell.Screen) { + buf := v.Buf.Snapshot() // PgDn/PgUp etc. support - for ; view.Y > 0; view.Y-- { + for ; v.Y > 0; v.Y-- { newline := bytes.IndexByte(buf, '\n') if newline != -1 { buf = buf[newline+1:] @@ -238,11 +288,11 @@ func (b *Buf) Draw(tui tcell.Screen, y0 int, view BufView) { } w, h := tui.Size() - putch := func(x, y int, ch rune) { - if x <= view.X && view.X != 0 { + drawch := func(x, y int, ch rune) { + if x <= v.X && v.X != 0 { x, ch = 0, '«' } else { - x -= view.X + x -= v.X } if x >= w { x, ch = w-1, '»' @@ -250,7 +300,7 @@ func (b *Buf) Draw(tui tcell.Screen, y0 int, view BufView) { tui.SetCell(x, y, tcell.StyleDefault, ch) } endline := func(x, y int) { - x -= view.X + x -= v.X if x < 0 { x = 0 } @@ -259,7 +309,7 @@ func (b *Buf) Draw(tui tcell.Screen, y0 int, view BufView) { } } - x, y := 0, y0 + x, y := 0, v.ScreenY // TODO: handle runes properly, including their visual width (mattn/go-runewidth) for len(buf) > 0 && y < h { ch, sz := utf8.DecodeRune(buf) @@ -271,16 +321,16 @@ func (b *Buf) Draw(tui tcell.Screen, y0 int, view BufView) { continue case '\t': const tabwidth = 8 - putch(x, y, ' ') + drawch(x, y, ' ') for x%tabwidth < (tabwidth - 1) { x++ if x >= w { break } - putch(x, y, ' ') + drawch(x, y, ' ') } default: - putch(x, y, ch) + drawch(x, y, ch) } x++ } @@ -289,12 +339,108 @@ func (b *Buf) Draw(tui tcell.Screen, y0 int, view BufView) { } } -func (b *Buf) Lines() int { +func (v *BufView) HandleKey(ev *tcell.EventKey) 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 += v.H - 1 + v.normalizeY() + case key(tcell.KeyPgUp): + v.Y -= v.H - 1 + 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 := bytes.Count(v.Buf.Snapshot(), []byte{'\n'}) + 1 + if v.Y >= nlines { + v.Y = nlines - 1 + } + if v.Y < 0 { + v.Y = 0 + } +} + +func NewBuf() *Buf { + // TODO: make buffer size dynamic (growable by pressing a key) + const bufsize = 40 * 1024 * 1024 // 40 MB + 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 + // NOTE: n and eof can be written only by capture func + n int + eof bool +} + +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() + b.n += n + if err == io.EOF { + b.eof = true + } + b.cond.Broadcast() + b.mu.Unlock() + go notify() + if err == io.EOF { + return + } else if err != nil { + // TODO: better handling of errors + panic(err) + } + if b.n == len(b.bytes) { + return + } + } +} + +func (b *Buf) Snapshot() []byte { b.mu.Lock() - n := b.n - b.mu.Unlock() - newlines := bytes.Count(b.bytes[:n], []byte{'\n'}) - return newlines + 1 + defer b.mu.Unlock() + return b.bytes[:b.n] } func (b *Buf) NewReader() io.Reader { @@ -323,110 +469,31 @@ type funcReader func([]byte) (int, error) func (f funcReader) Read(p []byte) (int, error) { return f(p) } -type Editor struct { - prompt []rune - // TODO: make editor multiline. Reuse gocui or something for this? - // TODO: rename 'command' to 'data' or 'value' or something more generic - command []rune - cursor int - // lastw is length of command on last Draw - lastw int -} - -func NewEditor(prompt string) *Editor { - return &Editor{prompt: []rune(prompt)} -} - -func (e *Editor) String() string { - return string(e.command) -} - -func (e *Editor) Draw(tui tcell.Screen, x, y int, setcursor bool) { - promptStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlue) - for i, ch := range e.prompt { - tui.SetCell(x+i, y, promptStyle, ch) - } - for i, ch := range e.command { - tui.SetCell(x+len(e.prompt)+i, y, promptStyle, ch) - } - // clear remains of last command if needed - for i := len(e.command); i < e.lastw; i++ { - tui.SetCell(x+len(e.prompt)+i, y, tcell.StyleDefault, ' ') - } - if setcursor { - tui.ShowCursor(x+len(e.prompt)+e.cursor, y) - } - e.lastw = len(e.command) -} - -func (e *Editor) HandleKey(ev *tcell.EventKey) bool { - if ev.Key() == tcell.KeyRune && ev.Modifiers()&(^tcell.ModShift) == 0 { - e.insert(ev.Rune()) - return true - } - switch (keymod{ev.Key(), ev.Modifiers()}) { - case keymod{tcell.KeyBackspace, 0}, keymod{tcell.KeyBackspace2, 0}: - // See https://github.com/nsf/termbox-go/issues/145 - e.delete(-1) - case keymod{tcell.KeyDelete, 0}: - e.delete(0) - case keymod{tcell.KeyLeft, 0}: - if e.cursor > 0 { - e.cursor-- - } - case keymod{tcell.KeyRight, 0}: - if e.cursor < len(e.command) { - e.cursor++ - } - default: - return false - } - return true -} - -func (e *Editor) insert(ch rune) { - // insert key into command (https://github.com/golang/go/wiki/SliceTricks#insert) - e.command = append(e.command, 0) - copy(e.command[e.cursor+1:], e.command[e.cursor:]) - e.command[e.cursor] = ch - e.cursor++ -} - -func (e *Editor) delete(dx int) { - pos := e.cursor + dx - if pos < 0 || pos >= len(e.command) { - return - } - e.command = append(e.command[:pos], e.command[pos+1:]...) - e.cursor = pos -} - type Subprocess struct { Buf *Buf cancel context.CancelFunc } -func StartSubprocess(inputBuf *Buf, command string, signal func()) *Subprocess { +func StartSubprocess(command string, stdin *Buf, notify func()) *Subprocess { ctx, cancel := context.WithCancel(context.TODO()) - s := &Subprocess{ - Buf: NewBuf(), + r, w := io.Pipe() + p := &Subprocess{ + Buf: NewBuf().StartCapturing(r, notify), cancel: cancel, } - r, w := io.Pipe() - go s.Buf.Collect(r, signal) cmd := exec.CommandContext(ctx, "bash", "-c", command) cmd.Stdout = w cmd.Stderr = w - cmd.Stdin = inputBuf.NewReader() + cmd.Stdin = stdin.NewReader() err := cmd.Start() if err != nil { fmt.Fprintf(w, "up: %s", err) - return s + return p } log.Println(cmd.Path) go cmd.Wait() - return s + return p } func (s *Subprocess) Kill() { @@ -436,25 +503,11 @@ func (s *Subprocess) Kill() { s.cancel() } -type BufView struct { - // TODO: Wrap bool - Y int // for pgup/pgdn scrolling) - X int // for left<->right scrolling -} +type key int32 -func (b *BufView) NormalizeY(nlines int) { - if b.Y >= nlines { - b.Y = nlines - 1 - } - if b.Y < 0 { - b.Y = 0 - } -} - -type keymod struct { - tcell.Key - tcell.ModMask -} +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(command string, tui tcell.Screen) { var f *os.File -- cgit v1.2.3