556 lines
17 KiB
Go
556 lines
17 KiB
Go
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 := in.H
|
|
if len(lines) > in.H { // the last row says where the view is
|
|
rows = max(1, in.H-1)
|
|
}
|
|
n.view.SetDimensions(len(lines), rows)
|
|
for y := range min(rows, len(lines)-n.view.Offset) {
|
|
in.TextStyled(0, y, lines[n.view.Offset+y], a.th.Text)
|
|
}
|
|
if rows < in.H {
|
|
where := fmt.Sprintf("j k: %d-%d of %d", n.view.Offset+1, n.view.Offset+rows, len(lines))
|
|
in.TextStyled(0, rows, ui.Truncate(where, in.W), a.th.Muted)
|
|
}
|
|
}
|
|
|
|
func (n *notice) handle(a *app, ev terminal.Event) {
|
|
switch {
|
|
case ev.Type != terminal.EventKey:
|
|
case is(ev, terminal.KeyUp, "k"):
|
|
n.view.ScrollBy(-1)
|
|
case is(ev, terminal.KeyDown, "j"):
|
|
n.view.ScrollBy(1)
|
|
case is(ev, terminal.KeyPageUp, ""):
|
|
n.view.PageUp()
|
|
case is(ev, terminal.KeyPageDown, ""):
|
|
n.view.PageDown()
|
|
default:
|
|
if a.dialog = nil; n.closed != nil {
|
|
n.closed(a)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"},
|
|
{"/", "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, what passed it, what it dropped"},
|
|
{"s", "look at a source, a flow stage, the sinks or lw's own log"},
|
|
{"i", "inspect 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
|
|
}}
|
|
}
|