package tui import ( "crypto/rand" "errors" "fmt" "strings" "github.com/lixenwraith/logwisp/internal/compose" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/terminal" ui "github.com/lixenwraith/terminal/tui" ) // dialog is a box over the screen that takes every event while open type dialog interface { draw(a *app, r ui.Region) handle(a *app, ev terminal.Event) } // frame draws a dialog's box around an inside w by h, centered in r and // clipped to it, with its title, and returns the inside; sized to what it // holds, it ends on its last line func (a *app) frame(r ui.Region, w, h int, title string) ui.Region { return ui.Center(r, min(w+4, r.W), min(h+2, r.H)).Frame(title, a.th) } // inside is a dialog's widest inside on a screen w wide; one narrower than // stacked puts labels and keys above what they name func inside(w int) int { return max(1, min(72, w-4)) } const stacked = 32 // chooser picks one option from a filtered list type chooser struct { title string list *ui.OptionListState pick func(a *app, i int) // the option chosen, by its index in the list back func(a *app) // on Esc, after the chooser closes paste func(a *app, text string) // a paste, in place of filtering; nil filters } func (c *chooser) draw(a *app, r ui.Region) { w := inside(r.W) a.frame(r, w, c.list.Rows(w), c.title).OptionList(c.list, a.th) } func (c *chooser) handle(a *app, ev terminal.Event) { switch { case ev.Type == terminal.EventPaste && c.paste != nil: c.paste(a, ev.Text) case ev.Type == terminal.EventPaste: c.list.Paste(ev.Text) case ev.Type != terminal.EventKey: case is(ev, terminal.KeyEscape, ""): a.dialog = nil if c.back != nil { c.back(a) } case is(ev, terminal.KeyEnter, ""): if i, ok := c.list.Chosen(); ok { a.dialog = nil c.pick(a, i) } case ev.Key == terminal.KeyRune && c.list.Menu: if i, ok := c.list.Pick(ev.Rune); ok { a.dialog = nil c.pick(a, i) } default: c.list.HandleKey(ev.Key, ev.Rune, ev.Modifiers) } } // filterHints describe the filter types the flow column adds var filterHints = map[string]string{"include": "pass only entries that match", "exclude": "drop entries that match"} // addMenu adds to the chosen column: a source or sink from the catalogue, or // a filter, which opens it in the inspector func (a *app) addMenu() dialog { role := columns[a.col].role var options []ui.Option if role == "flow" { role = "filters" for _, typ := range []string{"include", "exclude"} { options = append(options, ui.Option{Name: typ, Hint: filterHints[typ]}) } } for _, p := range config.Plugins() { if p.Role == role { options = append(options, ui.Option{Name: p.Type, Hint: p.Summary}) } } return &chooser{title: "Add a " + strings.TrimSuffix(role, "s"), list: ui.NewOptionListState(options), pick: func(a *app, i int) { n, err := a.comp.Add(a.pi, role, options[i].Name) if !a.edit(err) { return } a.choose(n) a.inspectFirst(n) }} } // typeMenu changes a source's or sink's type, in its place func (a *app) typeMenu(n compose.Node) dialog { p, _ := a.plugin(n) var options []ui.Option for _, q := range config.Plugins() { if q.Role == n.Role && q.Type != p.Type { options = append(options, ui.Option{Name: q.Type, Hint: q.Summary}) } } return &chooser{title: "Change " + p.Type, list: ui.NewOptionListState(options), pick: func(a *app, i int) { m, err := a.comp.Retype(a.pi, n, options[i].Name) if a.edit(err) { a.choose(m) a.inspectFirst(m) } }} } // enumMenu sets an enum key to the option chosen, the cursor on its value func (a *app) enumMenu(l line, cur any) dialog { var options []ui.Option for _, e := range l.key.Enum { hint := "" if e == l.key.Default { hint = "the default" } options = append(options, ui.Option{Name: e, Hint: hint}) } list := ui.NewOptionListState(options) list.Cursor = max(0, choice(l.key, cur)) return &chooser{title: label(l), list: list, pick: func(a *app, i int) { a.edit(a.comp.Set(a.pi, l.node, l.key.Name, options[i].Name)) }} } // presetMenu replaces the pipeline shown from a preset, or the pipelines // from a pasted command line; on a bare start, Esc starts empty func (a *app) presetMenu() dialog { title := "Start from a preset, or paste an lw command line" if len(a.comp.Pipelines) > 0 { title = "Replace pipeline " + a.pipeline().Name + " with a preset" } menu := a.pipelineMenu(title, (*app).use) menu.paste = func(a *app, text string) { a.dialog = nil if a.paste(text); len(a.comp.Pipelines) == 0 { a.dialog = menu // refused: the status says why } } menu.back = func(a *app) { if len(a.comp.Pipelines) == 0 { a.use(empty()) } } return menu } // pipelineMenu offers the presets and an empty pipeline, which place puts // on screen func (a *app) pipelineMenu(title string, place func(a *app, p config.PipelineConfig)) *chooser { presets := config.Presets() var options []ui.Option for _, p := range presets { options = append(options, ui.Option{Name: p.Name, Hint: p.Summary}) } options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"}) menu := &chooser{title: title, list: ui.NewOptionListState(options)} menu.pick = func(a *app, i int) { if i == len(presets) { place(a, empty()) return } p := presets[i] f := &form{title: "preset " + p.Name, summary: p.Summary, params: p.Params, back: func(a *app) { a.dialog = menu }, apply: func(a *app, values map[string]string) error { c, err := compose.FromPreset(p.Name, values, config.HostIsDir) if err == nil { place(a, c.Pipelines[0]) } return err }} if f.fill(nil); len(p.Params) == 0 { f.submit(a) return } a.dialog = f } return menu } func empty() config.PipelineConfig { return config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}} } // use puts a pipeline in place of the one shown, under its name, asking // first when that one has sources or sinks; the first pipeline keeps its own, // and its parts are work quit asks about func (a *app) use(p config.PipelineConfig) { if len(a.comp.Pipelines) == 0 { a.comp.Pipelines, a.changed = []config.PipelineConfig{p}, len(p.PluginSources)+len(p.PluginSinks) > 0 a.check() return } old := a.pipeline() p.Name = old.Name replace := func(a *app) { a.comp.Pipelines[a.pi], a.row, a.inspect, a.changed = p, [3]int{}, false, true a.check() } if len(old.PluginSources)+len(old.PluginSinks) == 0 { replace(a) return } a.dialog = &confirm{question: "Replace pipeline " + old.Name + "?", yes: replace} } // append adds a pipeline after the others, under its name or the first free // one after it, and shows it func (a *app) append(p config.PipelineConfig) { var names []string for _, q := range a.comp.Pipelines { names = append(names, q.Name) } p.Name = config.FreeID(p.Name, names) a.comp.Pipelines = append(a.comp.Pipelines, p) a.pi, a.row, a.bar, a.changed = len(a.comp.Pipelines)-1, [3]int{}, false, true a.check() } // form takes a few keys, their defaults as placeholders, then applies them; // a refusal stays in the form type form struct { title, summary string params []config.PresetParam fields []*ui.TextFieldState focus ui.Focus scroll ui.ViewportScroll // the fields' rows err string mask bool // draws each rune typed as '*' apply func(a *app, values map[string]string) error back func(a *app) // on Esc, after the form closes } // fill gives the fields their first values func (f *form) fill(values map[string]string) { f.focus, f.fields = ui.Focus{Len: len(f.params)}, nil for _, p := range f.params { f.fields = append(f.fields, ui.NewTextFieldState(values[p.Name])) } } // draw lays the form out at the screen's width: the summary, the fields, // their labels beside them or, when stacked, above, then the focused // field's help or the refusal, in rows kept for the longest of them func (f *form) draw(a *app, r ui.Region) { th, w := a.th, inside(r.W) labelW, tall := 0, 2 if w >= stacked { for _, p := range f.params { labelW = max(labelW, ui.RuneLen(p.Name)+2) } tall = 1 } summary, noteRows := wrap(f.summary, w, 2), len(wrap(f.err, w, 3)) for _, p := range f.params { noteRows = max(noteRows, len(wrap(p.Usage(), w, 3))) } top, bottom := len(summary)+min(1, len(summary)), noteRows+min(1, noteRows) in := a.frame(r, w, top+len(f.fields)*tall+bottom, f.title) for i, l := range summary { in.TextStyled(0, i, l, th.Muted) } view := in.Sub(0, top, in.W, max(1, in.H-top-bottom)) f.scroll.SetDimensions(len(f.fields)*tall, view.H) f.scroll.EnsureRange(f.focus.Index*tall, tall) view.Window(len(f.fields)*tall, &f.scroll, th.Text, func(all ui.Region) { for i, p := range f.params { v, _ := all.Field(i*tall, labelW, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th) field := f.fields[i] if !f.mask { v.TextInput(field, p.Default, i == f.focus.Index, th) continue } shown := *field shown.Text = []rune(strings.Repeat("*", len(field.Text))) v.TextInput(&shown, p.Default, i == f.focus.Index, th) field.Scroll = shown.Scroll } }) note, style := f.params[f.focus.Index].Usage(), th.Muted if f.err != "" { note, style = f.err, th.Error } lines := wrap(note, in.W, noteRows) // on the last rows, the box ending on text for i, l := range lines { in.TextStyled(0, in.H-len(lines)+i, l, style) } } // wrap is s wrapped to w, at most n lines, the last cut with '…'; none when // s is empty func wrap(s string, w, n int) []string { if s == "" { return nil } return clip(ui.WrapText(s, w), n) } func (f *form) handle(a *app, ev terminal.Event) { field := f.fields[f.focus.Index] switch { case ev.Type == terminal.EventPaste: field.Paste(ev.Text) case ev.Type != terminal.EventKey: case is(ev, terminal.KeyEscape, ""): a.dialog = nil if f.back != nil { f.back(a) } case is(ev, terminal.KeyEnter, ""): f.submit(a) case is(ev, terminal.KeyUp, ""): f.focus.Index = max(0, f.focus.Index-1) case is(ev, terminal.KeyDown, ""): f.focus.Index = min(f.focus.Len-1, f.focus.Index+1) case f.focus.HandleKey(ev.Key, ev.Modifiers): default: field.HandleKey(ev.Key, ev.Rune, ev.Modifiers) } } // submit applies the values typed, closing the form unless they are refused func (f *form) submit(a *app) { values := map[string]string{} for i, p := range f.params { if v := f.fields[i].Value(); v != "" { values[p.Name] = v } } a.dialog = nil if err := f.apply(a, values); err != nil { f.err, a.dialog = err.Error(), f } } // outputMenu is what o offers: run, tap, or print one of the forms func (a *app) outputMenu() dialog { forms := compose.Forms() options := []ui.Option{{Name: "run", Key: 'r', Hint: "start the pipelines now, with lw's other settings"}, {Name: "tap", Key: 't', Hint: "run here and watch what flows; it runs for real"}} for _, f := range forms { options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint}) } list := ui.NewOptionListState(options) list.Menu = true return &chooser{title: "Run, or print once the screen closes", list: list, pick: func(a *app, i int) { switch i { case 0: a.run() return case 1: a.tap() return } text, err := forms[i-2].Write(a.comp) if err != nil { a.status = err.Error() return } a.leave(Result{Exit: Print, Output: text}) }} } // askPassword asks in a masked form for a password a terminal would be asked // for; with generate, Enter alone draws one and shows it once func (a *app) askPassword(user string, generate bool, reply chan<- []byte) { answer := func(pw []byte) { select { case reply <- pw: default: } } if a.live.leaving { answer(nil) return } help := "the password lw would ask on the terminal" if generate { help += "; Enter alone generates one, shown once" } f := &form{title: "Password for " + user, params: []config.PresetParam{{Name: "password", Required: !generate, Help: help}}, mask: true, apply: func(a *app, v map[string]string) error { pw := v["password"] switch { case pw == "" && !generate: return errors.New("type the password, or esc to give none") case pw == "": // answered once read: the start goes on only then, so no // later ask or error replaces the notice unread pw = rand.Text() n := textNotice("Password for "+user, "generated, shown this once: "+pw) n.closed, a.dialog = func(*app) { answer([]byte(pw)) }, n return nil } answer([]byte(pw)) return nil }, back: func(*app) { answer(nil) }} f.fill(nil) a.dialog = f } // notice shows text, wrapped to the screen, until a key other than one // that scrolls it type notice struct { title string body func(w int) []string // the lines at a width view ui.ViewportScroll closed func(a *app) // after a key closed it } // textNotice is a notice of plain text, its paragraphs split at '\n' func textNotice(title, body string) *notice { return ¬ice{title: title, body: func(w int) []string { return ui.WrapText(body, w) }} } func (n *notice) draw(a *app, r ui.Region) { lines, w := n.body(inside(r.W)), ui.RuneLen(n.title)+2 for _, l := range lines { w = max(w, ui.RuneLen(l)) } in := a.frame(r, min(w, inside(r.W)), len(lines), n.title) rows := a.window(in, &n.view, len(lines)) for y := range min(rows, len(lines)-n.view.Offset) { in.TextStyled(0, y, lines[n.view.Offset+y], a.th.Text) } } func (n *notice) handle(a *app, ev terminal.Event) { switch { case ev.Type != terminal.EventKey, scroll(&n.view, ev): default: if a.dialog = nil; n.closed != nil { n.closed(a) } } } // window sets v to n lines in r and returns the rows they take: all of r, or // all but its last, which says where v is when they do not fit func (a *app) window(r ui.Region, v *ui.ViewportScroll, n int) int { rows := r.H if n > r.H { rows = max(1, r.H-1) } v.SetDimensions(n, rows) if rows < r.H { where := fmt.Sprintf("j k: %d-%d of %d", v.Offset+1, v.Offset+rows, n) r.TextStyled(0, rows, ui.Truncate(where, r.W), a.th.Muted) } return rows } // scroll moves v on a key that scrolls, reporting whether ev was one func scroll(v *ui.ViewportScroll, ev terminal.Event) bool { switch { case is(ev, terminal.KeyUp, "k"): v.ScrollBy(-1) case is(ev, terminal.KeyDown, "j"): v.ScrollBy(1) case is(ev, terminal.KeyPageUp, ""): v.PageUp() case is(ev, terminal.KeyPageDown, ""): v.PageDown() case is(ev, terminal.KeyHome, "g"): v.Home() case is(ev, terminal.KeyEnd, "G"): v.End() default: return false } return true } // entry shows a row of the live view over the whole screen, every detail // line, until Esc, i or q; e edits the stage that dropped it type entry struct { row row // a copy, which the ring's evictions leave whole view ui.ViewportScroll } func (e *entry) draw(a *app, r ui.Region) { r.FillStyle(a.th.Text) hint := "esc back" if e.row.dropped() { hint = "e edit the stage that dropped it " + hint } r.Rule(0, "entry", hint, a.th) body := r.Sub(0, 1, r.W, r.H-1) lines := a.details(&e.row, r.W-2) rows := a.window(body.Sub(1, 0, body.W-1, body.H), &e.view, len(lines)) a.drawLines(body.Sub(0, 0, body.W, rows), lines, e.view.Offset) } func (e *entry) handle(a *app, ev terminal.Event) { switch { case ev.Type != terminal.EventKey, scroll(&e.view, ev): case is(ev, terminal.KeyNone, "e"): a.dialog = nil a.editDrop(&e.row) case is(ev, terminal.KeyEscape, "iq"), is(ev, terminal.KeyCtrlC, ""): a.dialog = nil } } // confirm asks a yes or no question: y or n answers, as does Enter on the // answer the arrows chose; Esc is no type confirm struct { question string yes func(a *app) state ui.ConfirmState } func (c *confirm) draw(a *app, r ui.Region) { lines := ui.WrapText(c.question, inside(r.W)) w := 12 // the answers for _, l := range lines { w = max(w, ui.RuneLen(l)) } in := a.frame(r, w, len(lines)+1, "Confirm") for i, l := range lines { in.TextStyled(0, i, l, a.th.Text) } in.Radio(0, in.H-1, []string{"yes", "no"}, map[bool]int{true: 0, false: 1}[c.state.FocusYes], a.th) } func (c *confirm) handle(a *app, ev terminal.Event) { if ev.Type != terminal.EventKey || !c.state.HandleKey(ev.Key, ev.Rune) { return } if a.dialog = nil; c.state.Result == ui.ConfirmYes { c.yes(a) } } // help lists the keys of the diagram or, viewing, the view's, each beside // its description or, narrow, above it func help(f font, viewing bool) dialog { keys := [][2]string{ {"arrows, hjkl", "move; up from the top row to the pipeline bar"}, {f.enter, "inspect a part; edit a value; open a list; add"}, {"tab", "next value"}, {"esc", "back"}, {"a", "add a source, sink or filter; on the bar, a pipeline"}, {"c", "change a source's or sink's type"}, {"d", "delete it; a value back to its default"}, {f.space, "a flow stage or a switch on or off"}, {"J K", "move a filter down or up"}, {"[ ]", "previous or next pipeline"}, {"p", "start over from a preset"}, {"r", "run the pipelines"}, {"o", "run, tap, or print a command line, environment or file"}, {"t", "tap: run here and watch what flows; it runs for real: binds, dials, writes files"}, {"!", "every problem, moving to the first"}, {"paste", "an lw command line replaces the pipelines"}, {"q", "quit"}, } if viewing { keys = [][2]string{ {"arrows, jk", "move; up pauses following, down to the newest follows"}, {"g G", "the oldest row; the newest, following; f follows too"}, {f.enter, "the row's detail; J K scroll it"}, {"i", "the row full screen; esc, i or q back"}, {"/", "only the rows matching a pattern; lower case ignores case"}, {"1-5 0", "trace, debug, info, warn or error and up; all"}, {"v", "all that reaches the point, the dropped dimmed; what passed it; what it dropped"}, {"s", "look at a source, a flow stage, the sinks or lw's own log"}, {"e", "edit the stage that dropped the row"}, {"c", "clear the rows"}, {"t esc", "back to the diagram; the pipelines keep running"}, {"q", "stop the pipelines and quit"}, } } return ¬ice{title: "Keys", body: func(w int) []string { var lines []string for _, k := range keys { if w < stacked { lines = append(lines, k[0]) for _, l := range ui.WrapText(k[1], w-2) { lines = append(lines, " "+l) } continue } key := k[0] for _, l := range ui.WrapText(k[1], w-14) { lines, key = append(lines, ui.PadRight(key, 14)+l), "" } } return lines }} }