// Package tui is lw --tui: pipelines composed on a full-screen canvas drawn as // the website draws them, then run or written as a command line, environment // or file. The pipelines are a compose.Composition; this package only draws // them and turns keys into the engine's edits. package tui import ( "errors" "fmt" "slices" "strings" "time" "github.com/lixenwraith/logwisp/internal/compose" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/terminal" ui "github.com/lixenwraith/terminal/tui" ) // Exit is how the user left type Exit int const ( Quit Exit = iota // nothing to do Start // run Pipelines Print // print Output ) // Result is the user's choice on leaving type Result struct { Exit Exit Pipelines []config.PipelineConfig Output string } // modes maps lw's color setting to terminal.New's argument: auto follows the // terminal and NO_COLOR, always the terminal alone var modes = map[string][]terminal.ColorMode{ "always": {terminal.DetectColorMode()}, "never": {terminal.ColorModeNone}, } // Run composes c on the terminal until the user leaves; an empty c opens the // preset menu. color is lw's color setting; runner runs the pipelines on t, // and nothing runs once Run returns. func Run(c *compose.Composition, color string, runner Runner) (Result, error) { t := terminal.New(modes[color]...) if err := t.Init(); err != nil { return Result{}, err } defer t.Fini() if err := t.SetPasteMode(true); err != nil { return Result{}, err } a := newApp(c, looks[t.ColorMode()], fontFor(terminal.DetectColorMode())) a.live = newRun(runner, func() { t.PostEvent(terminal.Event{Type: terminal.EventKey, Key: terminal.KeyNone}) }) defer a.live.close() defer a.live.onSignals()() if runner != nil { go a.live.watch(time.Second) } w, h := t.Size() cells := make([]terminal.Cell, w*h) for !a.done { a.paint(cells, w, h) t.Flush(cells, w, h) switch ev := t.PollEvent(); ev.Type { case terminal.EventResize: w, h = ev.Width, ev.Height cells = make([]terminal.Cell, w*h) case terminal.EventClosed: return Result{}, errors.New("the terminal closed") default: a.handle(ev) } } return a.result, nil } // columns are a pipeline's parts, left to right var columns = []struct{ title, role string }{{"SOURCES", "source"}, {"FLOW", "flow"}, {"SINKS", "sink"}} const ( sourcesCol = iota flowCol sinksCol ) // app is the screen's state. draw and handle never touch the terminal, so // tests drive them directly. type app struct { comp *compose.Composition look look th ui.Theme font font pi int // the pipeline shown col int // the chosen column row [3]int // the chosen node of each column scroll ui.ViewportScroll // the canvas's rows bar bool // keys go to the pipeline bar inspect bool // keys go to the inspector insp inspector dialog dialog // takes every key while open live *run // what t runs view view // what runs shows viewing bool // keys go to the view status string // the last edit's refusal note string // what the last action did problem error // the composition's problems, joined changed bool done bool result Result } func newApp(c *compose.Composition, l look, f font) *app { a := &app{comp: c, look: l, th: l.Theme, font: f, live: newRun(nil, func() {})} a.th.Glyphs = f.Glyphs a.insp.open = map[string]bool{} if len(c.Pipelines) == 0 { a.dialog = a.presetMenu() } a.check() return a } func (a *app) pipeline() *config.PipelineConfig { return &a.comp.Pipelines[a.pi] } // nodes are a column's nodes: sources and sinks by id, the flow's stages by key func (a *app) nodes(col int) []compose.Node { if len(a.comp.Pipelines) == 0 { return nil } var out []compose.Node p := a.pipeline() switch col { case sourcesCol: for _, s := range p.PluginSources { out = append(out, compose.Node{Role: "source", ID: s.ID}) } case flowCol: for _, s := range config.FlowStages() { out = append(out, compose.Node{Role: s.Key}) } case sinksCol: for _, s := range p.PluginSinks { out = append(out, compose.Node{Role: "sink", ID: s.ID}) } } return out } // node is the chosen node, false in an empty column func (a *app) node() (compose.Node, bool) { nodes := a.nodes(a.col) if len(nodes) == 0 { a.row[a.col] = 0 return compose.Node{}, false } a.row[a.col] = max(0, min(a.row[a.col], len(nodes)-1)) return nodes[a.row[a.col]], true } // plugin is a source's or sink's catalogue row and options func (a *app) plugin(n compose.Node) (config.Plugin, bool) { p := a.pipeline() typ := "" for _, s := range p.PluginSources { if n.Role == "source" && s.ID == n.ID { typ = s.Type } } for _, s := range p.PluginSinks { if n.Role == "sink" && s.ID == n.ID { typ = s.Type } } return config.LookupPlugin(n.Role, typ) } // keys are the options a node takes: a plugin's, a stage's func (a *app) keys(n compose.Node) []config.Key { if p, ok := a.plugin(n); ok { return p.Keys() } for _, s := range config.FlowStages() { if s.Key == n.Role { return s.Keys } } return nil } // edit applies one engine edit: a refusal is the status, a change revalidates func (a *app) edit(err error) bool { if err != nil { a.status = err.Error() return false } a.status, a.note, a.changed = "", "", true a.check() return true } func (a *app) check() { a.problem = a.comp.Validate() } // handle routes an event: the tick first, which the input never sends (the // terminal library swallows unknown keys), then an open dialog takes all, // then the view, the inspector, the canvas; leaving, nothing more is taken func (a *app) handle(ev terminal.Event) { switch { case ev.Type == terminal.EventKey && ev.Key == terminal.KeyNone: a.tick() return case a.live.leaving: return case ev.Type == terminal.EventKey || ev.Type == terminal.EventPaste: a.status, a.note = "", "" } if a.dialog == nil && !a.viewing { a.view.own = false // a key on the diagram gives the point back to its cursor } switch { case a.dialog != nil: a.dialog.handle(a, ev) case a.viewing: a.viewKey(ev) case ev.Type == terminal.EventPaste && a.insp.field != nil: a.insp.field.Paste(ev.Text) case ev.Type == terminal.EventPaste: a.paste(ev.Text) case ev.Type != terminal.EventKey: case a.bar: a.barKey(ev) case !a.inspect: a.canvasKey(ev) case !a.inspectKey(ev): a.globalKey(ev) } } // is reports whether ev is a key or one of the runes func is(ev terminal.Event, key terminal.Key, runes string) bool { return ev.Key == key && key != terminal.KeyNone || ev.Key == terminal.KeyRune && strings.ContainsRune(runes, ev.Rune) } func (a *app) canvasKey(ev terminal.Event) { n, ok := a.node() switch { case is(ev, terminal.KeyLeft, "h"): a.col = max(0, a.col-1) case is(ev, terminal.KeyRight, "l"): a.col = min(2, a.col+1) case is(ev, terminal.KeyTab, ""): a.col = (a.col + 1) % 3 case is(ev, terminal.KeyUp, "k") && a.row[a.col] == 0: a.bar = true case is(ev, terminal.KeyUp, "k"): a.row[a.col]-- case is(ev, terminal.KeyDown, "j"): a.row[a.col]++ a.node() case is(ev, terminal.KeyEnter, "") && (!ok || n.Role == "filters" && len(a.pipeline().Flow.Filters) == 0): a.dialog = a.addMenu() // an empty column, or no filters, as a case is(ev, terminal.KeyEnter, ""): a.inspectFirst(n) case is(ev, terminal.KeyNone, "a"): a.dialog = a.addMenu() case is(ev, terminal.KeyNone, "d") && ok && n.Role == "filters": a.note = "choose a filter with " + a.font.enter + " to delete it" case is(ev, terminal.KeyNone, "d") && ok: a.edit(a.comp.Remove(a.pi, n)) case is(ev, terminal.KeySpace, " ") && ok: a.switchStage(n) case is(ev, terminal.KeyNone, "JK"): a.note = "J and K move a filter: " + a.font.enter + " on filters, then on one" default: a.globalKey(ev) } } // globalKey takes the keys that work outside a text field anywhere func (a *app) globalKey(ev terminal.Event) { switch n := len(a.comp.Pipelines); { case is(ev, terminal.KeyNone, "p"): a.dialog = a.presetMenu() case is(ev, terminal.KeyNone, "o"): a.dialog = a.outputMenu() case is(ev, terminal.KeyNone, "?"): a.dialog = help(a.font, a.viewing) case is(ev, terminal.KeyNone, "!"): a.showProblem() case is(ev, terminal.KeyNone, "c"): a.changeType() case is(ev, terminal.KeyNone, "r"): a.run() case is(ev, terminal.KeyNone, "t"): a.tap() case is(ev, terminal.KeyNone, "]") && n > 0: a.pi, a.row, a.inspect = (a.pi+1)%n, [3]int{}, false case is(ev, terminal.KeyNone, "[") && n > 0: a.pi, a.row, a.inspect = (a.pi+n-1)%n, [3]int{}, false case is(ev, terminal.KeyCtrlC, "q"): a.quit() } } // barKey takes the keys on the pipeline bar: switch, add, rename, delete func (a *app) barKey(ev terminal.Event) { n := len(a.comp.Pipelines) switch p := a.pipeline(); { case is(ev, terminal.KeyLeft, "h["): a.pi, a.row = (a.pi+n-1)%n, [3]int{} case is(ev, terminal.KeyRight, "l]"): a.pi, a.row = (a.pi+1)%n, [3]int{} case is(ev, terminal.KeyDown, "j") || is(ev, terminal.KeyEscape, ""): a.bar = false case is(ev, terminal.KeyNone, "a"): a.dialog = a.pipelineMenu("Add a pipeline", (*app).append) case is(ev, terminal.KeyEnter, ""): f := &form{title: "Rename pipeline " + p.Name, params: []config.PresetParam{{Name: "name", Required: true, Help: "a name no other pipeline has"}}, apply: func(a *app, v map[string]string) error { err := a.comp.RenamePipeline(a.pi, v["name"]) if err == nil { a.edit(nil) } return err }} f.fill(map[string]string{"name": p.Name}) a.dialog = f case is(ev, terminal.KeyNone, "d"): remove := func(a *app) { a.edit(a.comp.RemovePipeline(a.pi)) a.pi, a.row, a.insp.open = max(0, a.pi-1), [3]int{}, map[string]bool{} // folds are keyed by index if len(a.comp.Pipelines) == 0 { a.bar, a.dialog = false, a.presetMenu() } } if len(p.PluginSources)+len(p.PluginSinks) == 0 { remove(a) return } a.dialog = &confirm{question: "Delete pipeline " + p.Name + "?", yes: remove} default: a.globalKey(ev) } } // changeType offers a source or sink the other types of its role; a filter // or the format changes type on its type row func (a *app) changeType() { n, ok := a.node() if l := a.lines(); a.inspect && ok && len(l) > 0 { n = l[min(a.insp.cursor, len(l)-1)].node } switch { case !ok || n.Role == "filters" && !a.inspect: a.note = "c changes the type of a source, sink, filter or format" case n.Role == "source" || n.Role == "sink": a.dialog = a.typeMenu(n) case n.Role == "filters" && n.Index < len(a.pipeline().Flow.Filters), n.Role == "format": a.choose(n) a.inspectAt(func(l line) bool { return l.node == n && l.key.Name == "type" }) default: a.note = "c changes the type of a source, sink, filter or format" } } // run leaves to start the pipelines, once they are valid func (a *app) run() { if a.problem != nil { a.status = "not valid yet: ! shows the problem" return } a.leave(Result{Exit: Start, Pipelines: a.comp.Pipelines}) } // switchStage turns a flow stage on with its defaults, or off; a stage with // an enabled key switches that func (a *app) switchStage(n compose.Node) { if n.Role == "source" || n.Role == "sink" || n.Role == "filters" { return } opts, on, err := a.comp.Options(a.pi, n) switch _, has := opts["enabled"]; { case err != nil: a.edit(err) case slicesHasKey(a.keys(n), "enabled"): a.edit(a.comp.Set(a.pi, n, "enabled", fmt.Sprint(!on || !has))) case on: a.edit(a.comp.Remove(a.pi, n)) default: a.edit(a.comp.Unset(a.pi, n, a.keys(n)[0].Name)) } } func slicesHasKey(keys []config.Key, name string) bool { for _, k := range keys { if k.Name == name { return true } } return false } // paste starts over from a pasted command line, asking first when there are // edited pipelines to lose func (a *app) paste(text string) { c, err := compose.FromCommandLine(text, config.HostIsDir) if err != nil { a.status = "paste: " + err.Error() return } replace := func(a *app) { a.comp, a.pi, a.row, a.inspect, a.changed = c, 0, [3]int{}, false, true a.note = fmt.Sprintf("pasted %d pipeline(s)", len(c.Pipelines)) a.check() } if !a.changed || len(a.comp.Pipelines) == 0 { replace(a) return } a.dialog = &confirm{question: "Replace the pipelines with the pasted command line?", yes: replace} } func (a *app) quit() { leave := func(a *app) { a.leave(Result{}) } switch { case a.live.active(): a.dialog = &confirm{question: "Stop the pipelines and quit?", yes: leave} case a.changed: a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: leave} default: leave(a) } } // showProblem chooses the part the first problem names and shows them all func (a *app) showProblem() { if a.problem == nil { a.note = "the pipelines are valid" return } if pi, n, key, _, ok := compose.Locate(compose.Problems(a.problem)[0]); ok && pi < len(a.comp.Pipelines) { a.pi = pi a.choose(n) a.inspectAt(func(l line) bool { return l.node == n && l.key.Name == key }) } a.dialog = textNotice("Problem", a.problem.Error()) } // inspectFirst opens the inspector on a node's first required key, or on a // filter's group func (a *app) inspectFirst(n compose.Node) { a.inspectAt(func(l line) bool { return l.node == n && (l.key.Required || l.key.Name == "") }) } // inspectAt opens the inspector, which takes the keys from the bar, on the // first line at holds for, or on its top func (a *app) inspectAt(at func(line) bool) { a.inspect, a.bar = true, false a.insp.cursor = max(0, slices.IndexFunc(a.lines(), at)) } // choose puts the canvas cursor on a node, a filter on the filters stage func (a *app) choose(n compose.Node) { if n.Role == "filters" { a.insp.open[a.foldKey(n, "")] = true n = compose.Node{Role: "filters"} } for col := range columns { for i, m := range a.nodes(col) { if m == n { a.col, a.row[col] = col, i } } } } // draw draws the screen: a title row, the diagram, a status row, and any // dialog over them. Once something ran, the live view takes the rows under // the diagram from 40 rows, unless the inspector below it is in use, and the // whole screen under that while it takes the keys. func (a *app) draw(r ui.Region) { r.FillStyle(a.th.Text) status, style := a.bottom(r.W) if len(a.comp.Pipelines) > 0 { a.pi = min(a.pi, len(a.comp.Pipelines)-1) a.node() // a delete can leave the row past its column's end a.drawBar(r.Sub(0, 0, r.W, 1)) body, l := r.Sub(0, 1, r.W, r.H-1-len(status)), a.layout(r.W) a.view.sync(a.point(), a.live.tap) switch watched := a.viewing || a.live.tap != nil || len(a.view.rows) > 0; { case watched && r.H >= splitMin && (!a.inspect || l.inspector > 0): top := l.h if a.inspect { top = max(top, body.H/2) } top = min(top, body.H*2/3) a.drawDiagram(body.Sub(0, 0, r.W, top), l, false) a.drawView(body.Sub(0, top, r.W, body.H-top)) case a.viewing: a.drawView(body) default: a.drawDiagram(body, l, true) } } for i, row := range status { r.TextStyled(1, r.H-len(status)+i, row, style) } if a.status == "" && a.note == "" && a.problem == nil { x := 4 + ui.RuneLen(status[0]) r.TextStyled(x, r.H-1, a.hints(r.W-x-1), a.th.Muted) } if p := a.view.prompt; a.viewing && p != nil { row := r.Sub(0, r.H-1, r.W, 1) row.FillStyle(a.th.Text) row.TextStyled(1, 0, "/", a.th.Accent) row.Sub(3, 0, r.W-4, 1).TextInput(p, "a pattern: lower case ignores case; empty shows all", true, a.th) } if a.dialog != nil { a.dialog.draw(a, r) } } // drawDiagram draws the canvas with the inspector beside it from 110 // columns, else, with below, under it func (a *app) drawDiagram(r ui.Region, l layout, below bool) { switch { case l.inspector > 0: a.drawCanvas(r.Sub(0, 0, l.w, r.H), l) a.drawInspector(r.Sub(r.W-l.inspector, 0, l.inspector, r.H)) case !below: a.drawCanvas(r, l) default: ch := min(l.h, r.H-min(10, r.H/2)) a.drawCanvas(r.Sub(0, 0, r.W, ch), l) a.drawInspector(r.Sub(0, ch+1, r.W, r.H-ch-1)) } } // drawBar draws the title row: the pipelines by name, the one shown marked, // or it and its place when they do not fit; with the keys, the bar's verbs // after them func (a *app) drawBar(r ui.Region) { th, f := a.th, a.font full, hint := r, "" if a.bar { r.TextStyled(0, 0, string(f.Focus), th.Accent) if r.W >= 60 { hint = "a add " + f.enter + " rename d delete" r = r.Sub(0, 0, r.W-ui.RuneLen(hint)-2, 1) } } r.TextStyled(2, 0, "LogWisp", th.Accent) names, w := a.comp.Pipelines, 11 for _, p := range names { w += ui.RuneLen(p.Name) + 4 } if w > r.W { names = names[a.pi : a.pi+1] } x := 11 for _, p := range names { mark, style := f.Off, th.Muted if p.Name == a.pipeline().Name { mark, style = f.On, th.Text if a.bar { style = th.Text.On(th.Selected) } } label := ui.Truncate(string(mark)+" "+p.Name, max(0, r.W-x)) r.TextStyled(x, 0, label, style) x += ui.RuneLen(label) + 2 } if len(names) < len(a.comp.Pipelines) { place := fmt.Sprintf("%d/%d", a.pi+1, len(a.comp.Pipelines)) r.TextStyled(x, 0, place, th.Muted) x += ui.RuneLen(place) + 2 } if mark, style := a.runMark(); mark != "" { mark = ui.Truncate(mark, max(0, r.W-x)) r.TextStyled(x, 0, mark, style) x += ui.RuneLen(mark) + 2 } full.TextStyled(min(x+1, full.W-ui.RuneLen(hint)-1), 0, hint, th.Muted) } // paint draws the screen into cells, each rune one the font's terminal shows func (a *app) paint(cells []terminal.Cell, w, h int) { a.draw(ui.NewRegion(cells, w, 0, 0, w, h)) for i, c := range cells { cells[i].Rune = a.font.fold(c.Rune) } } // bottom is the status rows, wrapped to three at most: an edit's refusal, a // note, the first problem by its part and how many follow, or the keys while // all is valid func (a *app) bottom(w int) ([]string, ui.Style) { th, f := a.th, a.font text, style := "", th.Error switch { case a.status != "": text = a.status case a.note != "": text, style = a.note, th.Muted case a.problem != nil: problems := compose.Problems(a.problem) pi, n, key, msg, _ := compose.Locate(problems[0]) if where := a.where(pi, n, key); where != "" { msg = where + ": " + msg } if len(problems) > 1 { msg += fmt.Sprintf(" (%d more: ! shows them)", len(problems)-1) } text = f.invalid + " " + msg default: return []string{f.valid + " valid"}, th.Accent } return clip(ui.WrapText(text, w-2), 3), style } // hints are the status row's keys in w columns, the view's while it takes // them, shown after the status while nothing else needs the row func (a *app) hints(w int) string { keys := "a add d delete " + a.font.space + " on/off t tap p preset o output ? help" if a.viewing { keys = "/ filter 0-5 level v mode s point " + a.font.enter + " detail t back ? help" } if ui.RuneLen(keys) > w { keys = "? help" } return keys } // clip keeps n lines, the last cut short when more follow func clip(lines []string, n int) []string { if len(lines) > n { if n < 1 { return nil } lines = lines[:n] lines[n-1] = ui.Truncate(lines[n-1]+"…", ui.RuneLen(lines[n-1])) } return lines }