diff --git a/cmd/lw/cli.go b/cmd/lw/cli.go index f978f53..b46fdcd 100644 --- a/cmd/lw/cli.go +++ b/cmd/lw/cli.go @@ -52,13 +52,13 @@ var shorts = map[string]struct { top bool }{ "c": {"config", true}, "h": {"help", true}, "p": {"preset", true}, - "q": {"quiet", true}, "t": {"check", true}, "V": {"version", true}, + "q": {"quiet", true}, "t": {"check", true}, "T": {"tui", true}, "V": {"version", true}, "u": {"user", false}, } // switches are lw's own options that take no value and are no setting, so no // file or environment variable turns one on -var switches = []string{"check", "dump", "version", "schema"} +var switches = []string{"check", "dump", "version", "schema", "tui"} // usageError is a command-line mistake: exit status 2 rather than 1 type usageError string diff --git a/cmd/lw/cli_test.go b/cmd/lw/cli_test.go index a6e5556..d03969a 100644 --- a/cmd/lw/cli_test.go +++ b/cmd/lw/cli_test.go @@ -39,6 +39,7 @@ func TestCommandLineGrammar(t *testing.T) { {[]string{"--quiet", "-h"}, invocation{help: true, load: config.Args{Overrides: []string{"--quiet"}}}}, {[]string{"-c", "-h"}, invocation{help: true}}, {[]string{"help"}, invocation{help: true}}, + {[]string{"-T", "-c", "a.toml"}, invocation{on: map[string]bool{"tui": true}, load: config.Args{File: "a.toml"}}}, {[]string{"tls", "ca", "-h"}, invocation{command: &commands[1], args: []string{"ca", "-h"}}}, {[]string{"-q", "-t", "-V", "-p", "tail,path=x", "-c=a.toml", "--logging.level", "debug", "--logging.file.retention_hours=-1", "--dump"}, invocation{on: map[string]bool{"check": true, "version": true, "dump": true}, @@ -52,7 +53,7 @@ func TestCommandLineGrammar(t *testing.T) { } for _, argv := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"}, {"-v"}, {"-qt"}, {"-u", "x"}, {"-config", "x"}, {"--logging.level"}, {"--logging.file.retention_hours", "-1"}, - {"--check=false"}, {"--version=true"}, {"--source"}, {"--sink=", "x"}, {"-p"}, {"--filter", "--sink", "x"}} { + {"--check=false"}, {"--version=true"}, {"--tui=x"}, {"-T", "x"}, {"--source"}, {"--sink=", "x"}, {"-p"}, {"--filter", "--sink", "x"}} { if _, err := parseCommandLine(argv); err == nil { t.Errorf("%q accepted", argv) } @@ -435,3 +436,15 @@ func TestCompletionScriptsAreCurrent(t *testing.T) { } } } + +// A named configuration file that is missing is a usage error, on the screen +// as without it +func TestAMissingNamedFileIsAUsageError(t *testing.T) { + inv, err := parseCommandLine([]string{"-T", "-c", filepath.Join(t.TempDir(), "none.toml")}) + if err != nil { + t.Fatal(err) + } + if code, exit := composeOnScreen(&inv); code != 2 || !exit { + t.Fatalf("exit %d, %v", code, exit) + } +} diff --git a/cmd/lw/help.go b/cmd/lw/help.go index d168d6c..bc20390 100644 --- a/cmd/lw/help.go +++ b/cmd/lw/help.go @@ -18,6 +18,8 @@ Usage: lw -p|--preset SPEC [options] Run a preset's pipeline lw -t|--check [options] Build every pipeline and plugin, report, exit lw --dump [options] Print the effective configuration as TOML, exit + lw -T|--tui [options] Compose pipelines on screen, then run them or + print them as flags, variables or a file lw help | -h | --help lw -V | --version | --schema @@ -31,6 +33,9 @@ after '='; short options do not combine. lw takes options only. else ./logwisp.toml) -t, --check Build every pipeline and plugin, report, exit --dump Print the effective configuration, exit + -T, --tui Compose the pipelines on a full-screen + terminal, starting from the ones lw would run + or a preset; then run them, or print them -q, --quiet Silence lw's own log and notices; pipeline output still flows --color [WHEN] Level names in color on console sinks: auto diff --git a/cmd/lw/main.go b/cmd/lw/main.go index 4d08b5d..c8737eb 100644 --- a/cmd/lw/main.go +++ b/cmd/lw/main.go @@ -13,6 +13,7 @@ import ( "github.com/lixenwraith/logwisp/internal/compose" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/core" + "github.com/lixenwraith/logwisp/internal/tui" "github.com/lixenwraith/logwisp/internal/version" "github.com/lixenwraith/log" @@ -45,18 +46,19 @@ func main() { os.Exit(0) } + if inv.on["tui"] { + if code, exit := composeOnScreen(&inv); exit { + os.Exit(code) + } + } + // --- 1. Initial setup --- // Emulates nohup signal.Ignore(syscall.SIGHUP) manager, err := config.Load(inv.load) if err != nil { - if errors.Is(err, config.ErrConfigNotFound) { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(2) - } - fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err) - os.Exit(1) + os.Exit(loadFailed(err)) } defer manager.Close() cfg, err := manager.Snapshot() @@ -216,3 +218,49 @@ func readsStdin(cfg *config.Config) bool { } return false } + +// loadFailed reports a configuration that did not load and returns lw's exit +// code: a named file that is missing is a usage error +func loadFailed(err error) int { + if errors.Is(err, config.ErrConfigNotFound) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 2 + } + fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err) + return 1 +} + +// composeOnScreen is lw --tui: it starts from the pipelines lw would load, or +// the preset menu in place of the built-in pipe, then prints a form and exits, +// or leaves inv to run the pipelines composed in place of the loaded ones +func composeOnScreen(inv *invocation) (code int, exit bool) { + fail := func(err error) (int, bool) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1, true + } + manager, err := config.Load(inv.load) + if err != nil { + return loadFailed(err), true + } + cfg, err := manager.Snapshot() + builtIn := manager.BuiltIn() + manager.Close() + start := &compose.Composition{} + if err == nil && !builtIn { + start, err = compose.FromConfig(cfg) + } + if err != nil { + return fail(err) + } + res, err := tui.Run(start, cfg.Color) + switch { + case err != nil: + return fail(err) + case res.Exit == tui.Print: + fmt.Print(res.Output) + case res.Exit == tui.Start: + inv.load.Specs = config.PipelineSpecs(res.Pipelines) + return 0, false + } + return 0, true +} diff --git a/deploy/package/completion/_lw b/deploy/package/completion/_lw index 9c3ef14..24c76fa 100644 --- a/deploy/package/completion/_lw +++ b/deploy/package/completion/_lw @@ -206,4 +206,5 @@ _arguments -S \ '*--sink=:value:_default' \ '*--source=:value:_default' \ '--status_reporter' \ + '(-T --tui)'{-T,--tui} \ '(-V --version)'{-V,--version} diff --git a/deploy/package/completion/lw.bash b/deploy/package/completion/lw.bash index cb73301..00822b2 100644 --- a/deploy/package/completion/lw.bash +++ b/deploy/package/completion/lw.bash @@ -10,7 +10,7 @@ _lw() { elif [[ $prev == = ]]; then prev=${COMP_WORDS[i-2]} fi case ${COMP_WORDS[1]}:$i in - *:1) words='--auto_reload --check -t --color --config -c --dump --filter --format --heartbeat --help -h --logging.console.target --logging.file.directory --logging.file.max_size_mb --logging.file.max_total_size_mb --logging.file.name --logging.file.retention_hours --logging.format --logging.level --logging.output --logging.sanitization --pipeline --preset -p --quiet -q --rate-limit --schema --sink --source --status_reporter --version -V auth tls preset config help' ;; + *:1) words='--auto_reload --check -t --color --config -c --dump --filter --format --heartbeat --help -h --logging.console.target --logging.file.directory --logging.file.max_size_mb --logging.file.max_total_size_mb --logging.file.name --logging.file.retention_hours --logging.format --logging.level --logging.output --logging.sanitization --pipeline --preset -p --quiet -q --rate-limit --schema --sink --source --status_reporter --tui -T --version -V auth tls preset config help' ;; auth:2) words='add-user remove-user token stream' ;; auth:*) case ${COMP_WORDS[2]} in @@ -95,7 +95,7 @@ _lw() { --preset|-p) mapfile -t COMPREPLY < <(compgen -W 'pipe tail serve edge aggregator' -- "$cur"); return ;; --config|-c|--filter|--format|--heartbeat|--logging.console.target|--logging.file.max_size_mb|--logging.file.max_total_size_mb|--logging.file.name|--logging.file.retention_hours|--logging.format|--logging.level|--logging.output|--logging.sanitization|--pipeline|--rate-limit|--sink|--source) COMPREPLY=(); return ;; esac - words='--auto_reload --check -t --color --config -c --dump --filter --format --heartbeat --help -h --logging.console.target --logging.file.directory --logging.file.max_size_mb --logging.file.max_total_size_mb --logging.file.name --logging.file.retention_hours --logging.format --logging.level --logging.output --logging.sanitization --pipeline --preset -p --quiet -q --rate-limit --schema --sink --source --status_reporter --version -V' ;; + words='--auto_reload --check -t --color --config -c --dump --filter --format --heartbeat --help -h --logging.console.target --logging.file.directory --logging.file.max_size_mb --logging.file.max_total_size_mb --logging.file.name --logging.file.retention_hours --logging.format --logging.level --logging.output --logging.sanitization --pipeline --preset -p --quiet -q --rate-limit --schema --sink --source --status_reporter --tui -T --version -V' ;; esac mapfile -t COMPREPLY < <(compgen -W "$words" -- "$cur") } diff --git a/deploy/package/completion/lw.fish b/deploy/package/completion/lw.fish index 4e2e5ee..84b5d2a 100644 --- a/deploy/package/completion/lw.fish +++ b/deploy/package/completion/lw.fish @@ -41,6 +41,7 @@ complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset; and not __lw_in config' -l sink -r -F complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset; and not __lw_in config' -l source -r -F complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset; and not __lw_in config' -l status_reporter +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset; and not __lw_in config' -l tui -o T complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset; and not __lw_in config' -l version -o V complete -c lw -n '__lw_in auth; and test (count (commandline -opc)) -eq 2' -a add-user -d 'Add a user to a credentials file, or replace its password' diff --git a/doc/cli.md b/doc/cli.md index 5efae76..2d17b1d 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -6,6 +6,7 @@ lw -c|--config FILE [options] lw -p|--preset NAME[,KEY=VALUE...] [options] lw -t|--check [options] lw --dump [options] +lw -T|--tui [options] lw auth COMMAND [flags] lw tls COMMAND [flags] lw preset NAME [flags] @@ -31,7 +32,7 @@ first argument. - lw's own options have a long form (`--config`), and the frequent ones a one-letter short form that stands for it in every command that has it: - `-c` `--config`, `-p` `--preset`, `-q` `--quiet`, `-t` `--check`, - `-V` `--version`, `-h` `--help` + `-T` `--tui`, `-V` `--version`, `-h` `--help` - `-u` `--user` in `lw auth` and `lw preset edge` - A value follows as the next argument or after `=` (`-c FILE`, `-c=FILE`, `--config FILE`, `--config=FILE`); a value that starts with `-` follows `=`, @@ -66,6 +67,8 @@ pipelines have [their own flags](#pipelines): exit; see [Usage Patterns](#usage-patterns) - `--dump`: print the effective configuration, flags, environment and presets resolved into it, as TOML that `-c` reads back, and exit +- `-T`, `--tui`: compose the pipelines on screen, then run or print them + ([below](#lw---tui)) - `--schema`: print every setting, pipeline flag, flow stage, plugin option and preset as one JSON document (kinds, defaults, rules, help), and exit; tools build their option lists from it @@ -430,6 +433,40 @@ kill -HUP $(pidof lw) kill -USR1 $(pidof lw) ``` +## `lw --tui` + +`lw --tui` (`-T`) composes pipelines on a full-screen terminal, drawn as the +website's configurator draws them, with the engine the website runs as +WebAssembly. + +- Start: the pipelines `lw` would run with the same options (`-c FILE`, + pipeline flags, `LOGWISP_SOURCE`, a discovered file); with none but the + built-in pipe, the preset menu (or `empty`). A pasted `lw` command line + replaces the pipelines, in the preset menu too. +- Screen: SOURCES, FLOW and SINKS side by side from 64 columns, stacked + below; each source and sink a tinted bar with its type, whether it listens + or dials, and a summary; the four flow stages in their order in a dashed + box, the ones off dimmed. The inspector, the chosen part's options, sits + below the canvas, and beside it from 110 columns. +- Keys: arrows or `hjkl` move; Enter inspects, then edits a value (empty + returns it to its default); Tab sets it and goes to the next; Esc back; `a` adds a source, + sink or filter; `d` deletes, or returns a value to its default; Space turns + a flow stage or a switch on or off; `J`/`K` move a filter; `[` `]` switch + pipelines; `p` presets; `c` shows the first problem; `o` output; `?` keys; + `q` quits. +- Output, `o`: run the pipelines with the other options given (so `--check` + and `--dump` apply to them, and reloads keep them), or print one of the + command line, the environment variables (one pipeline) and the file's + pipelines on standard output once the screen closes. +- The screen draws on the controlling terminal: `lw -T > pipelines.toml` and + `cmd | lw -T` keep standard input and output as data. lw never writes the + file it starts from, but the shell empties a redirect's target before lw + reads it: redirect to a file lw does not load. +- Color follows the `color` setting: `auto` the terminal and `NO_COLOR`, + `always` the terminal, `never` none. True color and 256 colors paint the + website's dark theme; 16 colors and none keep the terminal's own; a text + console draws with CP437 glyphs, and a terminal outside UTF-8 with ASCII. + ## `lw auth` Manages the credentials files of `auth.type = "scram"` listeners and logs in to diff --git a/doc/lw.1 b/doc/lw.1 index 496a4c4..69d895a 100644 --- a/doc/lw.1 +++ b/doc/lw.1 @@ -20,6 +20,9 @@ .Op Fl \-rate\-limit Ar spec .Op Fl \-heartbeat Ar spec .Nm +.Fl T | Fl \-tui +.Op Ar options +.Nm .Cm auth | tls | preset | config .Ar command .Op Ar flags @@ -146,6 +149,23 @@ Print the effective configuration, with the options, environment and presets resolved into it, as a file .Fl c reads back, and exit. +.It Fl T , Fl \-tui +Compose pipelines on a full-screen terminal, starting from the ones +.Nm +would run with the other options given, or from a preset when that is only +the built-in standard input to standard output pipe. +On leaving, run them with the other options, as +.Fl \-check +and +.Fl \-dump +also do, or print them as a command line, environment variables or a +configuration file's pipelines. +The screen draws on the controlling terminal, so standard input and output +stay data: +.Ql lw \-T > pipelines.toml . +A file is read, never written; the shell empties a redirect's target before +.Nm +reads it, so the redirect names a file it does not load. .It Fl \-schema Print every setting, pipeline flag, flow stage, plugin option and preset as one JSON document, with kinds, defaults, rules and help, and exit; tools build diff --git a/doc/todo.md b/doc/todo.md index fda8afb..aa331c3 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -109,61 +109,11 @@ Steps: 1. Schema, in `internal/config`: done. 2. Engine, `internal/compose`: done. 3. `lw --schema`: done. The site generates its catalogue from it. -4. In `lixenwraith/terminal`, released and pinned before the TUI: - - Full-screen 8- and 16-color output (SGR 30-37, 90-97, 40-47) and the - default colors 39/49; it emits only 256-color and truecolor today. - - Detection as `inline` does it: 16 colors on text consoles, none under - `NO_COLOR`, refusal on `TERM=dumb`, and an override for LogWisp's `color` - key. - - Drawing on `/dev/tty`, so `lw --tui > logwisp.toml` and `cmd | lw --tui` - keep stdin and stdout as data. - - Bracketed paste into text fields. - - `tui` widgets the site's controls have: segmented choice, filterable - option list, toggle, typed fields (default as placeholder, required mark, - error and help line, folded groups), a focus ring; every widget styled - from one `tui.Theme`. - - In `color`, optional: export the 16 xterm reference shades. RGBTo16 maps - the site's hues badly (green to cyan, red to bright black), so the - 8-color tier is chosen by hand. -5. `lw --tui`. - - A parser switch, `-T` for short (`-t` is `--check`). With `-c FILE`, - pipeline flags or `LOGWISP_SOURCE` it starts from that composition, and - never rewrites FILE (TOML encoding drops its comments); bare, it opens the - preset menu (pipe, tail, serve, edge, aggregator, empty). - - The pipeline as the site draws it: SOURCES, FLOW, SINKS; a node is a - tinted bar, its type, listens or dials, and a summary line; the four flow - stages in order inside a dashed box, the ones off dimmed; wires drawn from - per-cell connectivity so every junction is right. The inspector sits - below at 80 to 109 columns, beside at 110 and over; under 64 columns the - stages stack, as on the site. - ``` - ● SOURCES ● FLOW ● SINKS - ▌ file ─┐ ╭╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮ ┌─▌ http listens - ▌ /var/log *.log │ ╎ ▌ rate_limit 400/s ╎ │ ▌ :8080 /stream - █ tcp_chain listens─┴─►╎ ▌ filters -TRACE ╎─►┤ - █ :9000 tls scram ╎ ▌ format txt ╎ └─▌ console - ╎ ○ heartbeat off ╎ ▌ stdout - ╰╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯ - ── tcp_chain · source · id src-2 ─────────────────── ⏎ edit tab next ── - host 0.0.0.0 default format raw [txt] json - port * 9000▌ tls ▸ on (auth, acl folded) - ✓ valid a add d delete ␣ on/off J/K move p preset o output ? help - ``` - - Keys: arrows or hjkl move, Enter edits, `a` adds from the catalogue, `d` - deletes, Space switches a flow stage, `J`/`K` reorder filters, `p` - presets, `[` `]` switch pipelines, `c` lists errors, `o` output, `?` - help, `q` quits. - - Theme: the site's dark tokens as one role table in four tiers. Truecolor - and 256 colors paint the site's background; 8 colors and mono keep the - terminal's. Sources cyan, flow violet, sinks green, accent blue, muted - grey, errors red. A selection always changes a glyph (bar, `▸`, - underline), never only a shade. Glyphs: Unicode, a CP437 set on text - consoles, ASCII outside UTF-8. - - Output, `o`: Run hands `config.Args` built from the composition to the - usual `config.Load`, after the terminal is released and before anything - reads stdin; reloads keep the pipelines, and `--tui --check` and `--tui - --dump` follow. Or exactly one of command line, environment and file, - printed to stdout once the screen closes. +4. `lixenwraith/terminal` v0.2.0: done: 16 and no color, the terminal's + default colors, `/dev/tty`, bracketed paste, themed form controls. Still + optional, in `color`: export the 16 xterm reference shades (RGBTo16 maps + the site's hues badly, so the 16-color tier is chosen by hand). +5. `lw --tui`: done ([doc/cli.md](cli.md#lw---tui)). 6. WebAssembly: `cmd/lwconf` (`js && wasm`) exposes schema, preset, parse, validate and emit to the page as JSON strings. `make wasm` builds `bin/lwconf.wasm` with the version stamped, copies the toolchain's diff --git a/internal/compose/compose.go b/internal/compose/compose.go index 47ecb87..3c01b37 100644 --- a/internal/compose/compose.go +++ b/internal/compose/compose.go @@ -94,14 +94,18 @@ func FromCommandLine(line string, isDir config.IsDir) (*Composition, error) { return c, c.settle() } -// settle gives every flow stage its defaults and every plugin's options their -// kinds; the rules are Validate's +// settle gives every flow stage its defaults, every plugin's options their +// kinds and every source and sink an id; the rules are Validate's func (c *Composition) settle() error { for i := range c.Pipelines { p := &c.Pipelines[i] if p.Flow == nil { p.Flow = &config.FlowConfig{} } + name(len(p.PluginSources), func(j int) *string { return &p.PluginSources[j].ID }, + func(j int) string { return p.PluginSources[j].Type }) + name(len(p.PluginSinks), func(j int) *string { return &p.PluginSinks[j].ID }, + func(j int) string { return p.PluginSinks[j].Type }) for _, stage := range stages(p.Flow) { if !stage.IsNil() { config.Fill(stage.Interface()) @@ -126,6 +130,23 @@ func (c *Composition) settle() error { return nil } +// name gives the parts a file left unnamed the ids their specs would get: the +// shell forms write no empty id +func name(n int, id func(int) *string, typ func(int) string) { + var taken []string + for j := range n { + if *id(j) != "" { + taken = append(taken, *id(j)) + } + } + for j := range n { + if *id(j) == "" { + *id(j) = config.FreeID(typ(j), taken) + taken = append(taken, *id(j)) + } + } +} + // stages are the flow table's fields that hold one stage each, by key: a // pointer, nil while the stage is off. The filters are a list. func stages(f *config.FlowConfig) map[string]reflect.Value { @@ -358,6 +379,31 @@ func editStage(stage reflect.Value, change func(map[string]any) error) error { return nil } +// Options returns a copy of a node's options that are set, and whether it is +// on: a flow stage that is off has none +func (c *Composition) Options(pi int, n Node) (map[string]any, bool, error) { + p, err := c.pipeline(pi) + if err != nil { + return nil, false, err + } + switch i := index(p, n); { + case i < 0: + case n.Role == "source": + return config.Clone(p.PluginSources[i].Config), true, nil + case n.Role == "sink": + return config.Clone(p.PluginSinks[i].Config), true, nil + case n.Role == "filters": + return config.Values(&p.Flow.Filters[i]), true, nil + } + if stage, ok := stages(p.Flow)[n.Role]; ok { + if stage.IsNil() { + return nil, false, nil + } + return config.Values(stage.Interface()), true, nil + } + return nil, false, n.missing() +} + // Validate applies every rule lw applies at load, naming the refused key's path func (c *Composition) Validate() error { return config.ValidatePipelines(c.Pipelines) } diff --git a/internal/compose/compose_test.go b/internal/compose/compose_test.go index a4c65c8..05dcfa3 100644 --- a/internal/compose/compose_test.go +++ b/internal/compose/compose_test.go @@ -7,6 +7,8 @@ import ( "slices" "strings" "testing" + + "github.com/lixenwraith/logwisp/internal/config" ) // The engine builds as WebAssembly for the website: it links no TLS, HTTP, @@ -117,3 +119,43 @@ func TestSetKeepsAKeyItCannotTake(t *testing.T) { t.Fatalf("options %v, heartbeat %+v", m, beat) } } + +// Options are a copy: changing them changes no pipeline; a stage that is off +// has none +func TestOptionsAreACopy(t *testing.T) { + c := &Composition{} + if err := c.AddPipeline("p"); err != nil { + t.Fatal(err) + } + n, _ := c.Add(0, "sink", "tcp") + if err := c.Set(0, n, "port", "9000"); err != nil { + t.Fatal(err) + } + opts, on, err := c.Options(0, n) + opts["port"] = int64(1) + if _, rate, _ := c.Options(0, Node{Role: "rate_limit"}); err != nil || !on || rate || c.Pipelines[0].PluginSinks[0].Config["port"] != int64(9000) { + t.Fatalf("%v %v %v %v", opts, on, rate, err) + } +} + +// A file may leave a source's or sink's id out: the engine names it as a +// spec would, around the ids the file gave, so the shell forms load back +func TestUnnamedPartsTakeFreeIDs(t *testing.T) { + c, err := FromConfig(&config.Config{Pipelines: []config.PipelineConfig{{Name: "p", + PluginSources: []config.PluginSourceConfig{{Type: "null"}, {ID: "null", Type: "null"}}, + PluginSinks: []config.PluginSinkConfig{{Type: "null"}}}}}) + if err != nil { + t.Fatal(err) + } + p := c.Pipelines[0] + if ids := []string{p.PluginSources[0].ID, p.PluginSources[1].ID, p.PluginSinks[0].ID}; !slices.Equal(ids, []string{"null_2", "null", "null"}) { + t.Fatalf("ids %q", ids) + } + line, err := c.CommandLine() + if err == nil { + _, err = FromCommandLine(line, nil) + } + if err != nil { + t.Fatalf("%v\n%s", err, line) + } +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 4449aa5..22f60ba 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -94,6 +94,13 @@ func Load(args Args) (*Manager, error) { return m, nil } +// BuiltIn reports whether the pipelines are lw's built-in pipe: no file, flag +// or variable defined one +func (m *Manager) BuiltIn() bool { + _, inFile := m.config.GetSource("pipelines", lconfig.SourceFile) + return !inFile && len(m.specs) == 0 +} + // Snapshot validates a detached candidate before it is used to build a service. // Builder validators only run during Load, so every reload needs this check too. func (m *Manager) Snapshot() (*Config, error) { diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index e207665..d37941c 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" "time" @@ -290,6 +291,33 @@ func TestNoConfigFileMeansQuietLogging(t *testing.T) { } } +// The pipelines are lw's built-in pipe only when no file, flag or variable +// defined one: a file of settings alone runs the pipe +func TestBuiltInPipeIsWhatNothingNamed(t *testing.T) { + isolateConfig(t) + builtIn := func(args Args) bool { + t.Helper() + m, err := Load(args) + if err != nil { + t.Fatal(err) + } + defer m.Close() + return m.BuiltIn() + } + named := []bool{builtIn(Args{}), builtIn(Args{Specs: []Spec{{Flag: "source", Value: "null"}}})} + t.Setenv("LOGWISP_SOURCE", "null") + named = append(named, builtIn(Args{})) + t.Setenv("LOGWISP_SOURCE", "") + testutil.WriteFile(t, "logwisp.toml", "[logging]\nlevel = \"warn\"\n") + named = append(named, builtIn(Args{})) + testutil.WriteFile(t, "logwisp.toml", "[[pipelines]]\nname = \"p\"\n"+ + "[[pipelines.plugin_sources]]\ntype = \"null\"\n[[pipelines.plugin_sinks]]\ntype = \"null\"\n") + named = append(named, builtIn(Args{})) + if !slices.Equal(named, []bool{true, false, false, true, false}) { + t.Fatalf("nothing, a flag, a variable, a file of settings, a file's pipeline: %v", named) + } +} + // A string in the file is one list entry: split at its comma, an exclude // pattern such as password=\S{8,64} would become two that match nothing func TestFileStringIsOneListEntry(t *testing.T) { diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go new file mode 100644 index 0000000..04fa497 --- /dev/null +++ b/internal/tui/canvas.go @@ -0,0 +1,376 @@ +package tui + +import ( + "fmt" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/lixenwraith/logwisp/internal/compose" + "github.com/lixenwraith/logwisp/internal/config" + + "github.com/lixenwraith/terminal" + ui "github.com/lixenwraith/terminal/tui" +) + +const ( + pitch = 3 // rows a source or sink takes, its gap included + gutter = 4 // columns of wire on either side of the flow box + wideMin = 64 // the narrowest canvas with the parts side by side +) + +// spot is where a node draws: its first row and the columns it may use +type spot struct{ x, y, w int } + +// layout places a pipeline's parts: side by side with wires from 64 columns, +// stacked below that +type layout struct { + wide bool + h int + title [3]spot + nodes [3][]spot // a column's nodes, or its one empty slot + box spot // the flow box's top left and width + boxH int + entry, exit int // the rows wires enter and leave the flow box +} + +func (a *app) layout(w int) layout { + var l layout + counts := [3]int{} + for col := range columns { + counts[col] = max(1, len(a.nodes(col))) + } + stages := counts[flowCol] + l.boxH = stages + 2 + if w >= wideMin { + l.wide = true + boxW := max(18, min(30, (w-2*gutter)/3)) + sideW := (w - boxW - 2*gutter) / 2 + xs := [3]int{0, sideW + gutter, sideW + 2*gutter + boxW} + ws := [3]int{sideW, boxW, w - xs[sinksCol]} + for col := range columns { + l.title[col] = spot{xs[col], 0, ws[col]} + } + l.box = spot{xs[flowCol], 1, boxW} + for _, col := range []int{sourcesCol, sinksCol} { + for i := range counts[col] { + l.nodes[col] = append(l.nodes[col], spot{xs[col], 1 + i*pitch, ws[col]}) + } + } + l.entry = l.box.y + 1 + (stages-1)/2 + l.exit = l.entry + l.h = 1 + max(counts[sourcesCol]*pitch-1, l.boxH, counts[sinksCol]*pitch-1) + } else { + y := 0 + for col := range columns { + l.title[col] = spot{0, y, w} + y++ + if col == flowCol { + l.box = spot{0, y, w} + y += l.boxH + } else { + for range counts[col] { + l.nodes[col] = append(l.nodes[col], spot{0, y, w}) + y += pitch + } + y-- + } + if col != sinksCol { + y++ // the arrow down to the next part + } + } + l.h = y + } + for i := range stages { + l.nodes[flowCol] = append(l.nodes[flowCol], spot{l.box.x + 2, l.box.y + 1 + i, l.box.w - 4}) + } + return l +} + +// drawCanvas draws the pipeline, scrolled to keep the chosen node in view +func (a *app) drawCanvas(r ui.Region) { + l := a.layout(r.W) + chosen := l.nodes[a.col][min(a.row[a.col], len(l.nodes[a.col])-1)] + if chosen.y < a.scroll { + a.scroll = chosen.y + } + if tall := 2 - a.col%2; chosen.y+tall > a.scroll+r.H { + a.scroll = chosen.y + tall - r.H + } + a.scroll = max(0, min(a.scroll, l.h-r.H)) + + // Drawn whole off screen, then the rows in view copied + cells := make([]terminal.Cell, r.W*l.h) + full := ui.NewRegion(cells, r.W, 0, 0, r.W, l.h) + full.FillStyle(a.th.Text) + a.drawParts(full, l) + for y := range min(r.H, l.h-a.scroll) { + copy(r.Cells[(r.Y+y)*r.TotalW+r.X:][:r.W], cells[(a.scroll+y)*r.W:][:r.W]) + } +} + +func (a *app) drawParts(r ui.Region, l layout) { + th, f := a.th, a.font + tints := [3]ui.Style{a.look.Source, a.look.Flow, a.look.Sink} + for col, c := range columns { + t := l.title[col] + r.TextStyled(t.x, t.y, string(f.dot)+" "+c.title, tints[col].On(th.Text)) + } + a.drawBox(r, l) + g := wires{w: r.W, arms: make([]uint8, r.W*l.h)} + for _, col := range []int{sourcesCol, sinksCol} { + nodes := a.nodes(col) + for i, s := range l.nodes[col] { + chosen := a.col == col && a.row[col] == i + if len(nodes) == 0 { + a.drawEmpty(r, s, columns[col].role, chosen) + continue + } + a.drawNode(r, s, nodes[i], tints[col], chosen) + } + if l.wide && len(nodes) > 0 { + g.join(l, col) + } + } + if !l.wide { + for _, col := range []int{sourcesCol, flowCol} { + r.TextStyled(2, l.title[col+1].y-1, string(f.down), th.Border) + } + return + } + for i, arms := range g.arms { + if arms != 0 { + r.TextStyled(i%r.W, i/r.W, string(f.wire[arms]), th.Border) + } + } + if len(a.nodes(sourcesCol)) > 0 { + r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border) + } + if len(a.nodes(sinksCol)) > 0 { + r.TextStyled(l.box.x+l.box.w+1, l.exit, string(f.arrow), th.Border) + } +} + +// wires are the canvas's cells by the arms they have: up 1, right 2, down 4, +// left 8, so every junction draws from what meets there +type wires struct { + w int + arms []uint8 +} + +func (g wires) h(y, x0, x1 int) { + for x := x0; x < x1; x++ { + g.arms[y*g.w+x] |= 2 + g.arms[y*g.w+x+1] |= 8 + } +} + +func (g wires) v(x, y0, y1 int) { + for y := y0; y < y1; y++ { + g.arms[y*g.w+x] |= 4 + g.arms[(y+1)*g.w+x] |= 1 + } +} + +// join wires a column's nodes to a trunk beside the flow box, and the trunk +// to the box's side +func (g wires) join(l layout, col int) { + ys := []int{l.entry} + if col == sinksCol { + ys[0] = l.exit + } + for _, s := range l.nodes[col] { + ys = append(ys, s.y) + } + if col == sourcesCol { + trunk := l.box.x - gutter + 1 + for _, s := range l.nodes[col] { + g.h(s.y, trunk-1, trunk) + } + g.v(trunk, slices.Min(ys), slices.Max(ys)) + g.h(l.entry, trunk, l.box.x-1) + return + } + right := l.box.x + l.box.w + trunk := right + 2 + g.h(l.exit, right, trunk) + g.v(trunk, slices.Min(ys), slices.Max(ys)) + for _, s := range l.nodes[col] { + g.h(s.y, trunk, s.x-1) + } +} + +// drawNode draws a source or sink: a tinted bar, its type and whether it +// listens or dials, then a summary of its options. The chosen node's bar is +// solid; a node the first problem names has its bar in the error style. +func (a *app) drawNode(r ui.Region, s spot, n compose.Node, tint ui.Style, chosen bool) { + th := a.th + bg, bar := th.Text, a.font.bar + if chosen { + bg, bar = th.Text.On(th.Selected), a.font.barOn + } + if a.faulty(n) { + tint = th.Error + } + p, _ := a.plugin(n) + way := map[config.Side]string{config.Listener: "listens", config.ChainListener: "listens", config.Dialer: "dials"}[p.Side] + for dy, text := range []string{p.Type, a.summary(n)} { + row := r.Sub(s.x, s.y+dy, s.w, 1) + row.FillStyle(bg) + row.TextStyled(0, 0, string(bar), tint.On(bg)) + style := th.Muted.On(bg) + if dy == 0 { + style = th.Text.On(bg) + row.TextStyled(row.W-ui.RuneLen(way), 0, way, th.Muted.On(bg)) + } + row.TextStyled(2, 0, ui.Truncate(text, row.W-ui.RuneLen(way)-3), style) + } +} + +func (a *app) drawEmpty(r ui.Region, s spot, role string, chosen bool) { + mark, style := " ", a.th.Muted + if chosen { + mark, style = string(a.th.Glyphs.Pointer), a.th.Muted.On(a.th.Selected) + } + row := r.Sub(s.x, s.y, s.w-1, 1) + if chosen { + row.FillStyle(a.th.Text.On(a.th.Selected)) + } + row.TextStyled(0, 0, mark+" a adds a "+role, style) +} + +// drawBox draws the flow box, its stages in the order entries pass them; a +// stage that is off is dimmed with the Off glyph +func (a *app) drawBox(r ui.Region, l layout) { + th, f := a.th, a.font + b := l.box + edge := []rune(f.box) + across := strings.Repeat(string(edge[4]), max(0, b.w-2)) + r.TextStyled(b.x, b.y, string(edge[0])+across+string(edge[1]), th.Border) + r.TextStyled(b.x, b.y+l.boxH-1, string(edge[2])+across+string(edge[3]), th.Border) + for y := b.y + 1; y < b.y+l.boxH-1; y++ { + r.TextStyled(b.x, y, string(edge[5]), th.Border) + r.TextStyled(b.x+b.w-1, y, string(edge[5]), th.Border) + } + for i, n := range a.nodes(flowCol) { + s := l.nodes[flowCol][i] + chosen := a.col == flowCol && a.row[flowCol] == i + bg := th.Text + if chosen { + bg = th.Text.On(th.Selected) + } + row := r.Sub(s.x, s.y, s.w, 1) + row.FillStyle(bg) + mark, tint, text := f.bar, a.look.Flow, th.Text + if chosen { + mark = f.barOn + } + summary := a.summary(n) + if !a.on(n) { + mark, tint, text, summary = f.Off, th.Muted, th.Muted, "off" + if chosen { + mark = th.Glyphs.Pointer + } + } + if a.faulty(n) { + tint = th.Error + } + row.TextStyled(0, 0, string(mark), tint.On(bg)) + row.TextStyled(2, 0, n.Role, text.On(bg)) + x := 3 + ui.RuneLen(n.Role) + row.TextStyled(x, 0, ui.Truncate(summary, row.W-x), th.Muted.On(bg)) + } +} + +// on reports whether a node is on: a flow stage that is off has no options, +// or an enabled key that is false +func (a *app) on(n compose.Node) bool { + if n.Role == "filters" { + return len(a.pipeline().Flow.Filters) > 0 + } + opts, on, _ := a.comp.Options(a.pi, n) + if slicesHasKey(a.keys(n), "enabled") { + return on && opts["enabled"] == true + } + return on +} + +// summary is a node's options in a line: the values set, a table by its +// name, a switch by its key; the filters as +include and -exclude patterns +func (a *app) summary(n compose.Node) string { + if n.Role == "filters" { + var out []string + for _, f := range a.pipeline().Flow.Filters { + sign := map[config.FilterType]string{"include": "+", "exclude": "-"}[f.Type] + out = append(out, sign+strings.Join(f.Patterns, "|")) + } + return strings.Join(out, " ") + } + opts, _, _ := a.comp.Options(a.pi, n) + var out []string + for _, k := range a.keys(n) { + v, ok := opts[k.Name] + switch { + case !ok || k.Name == "enabled" || v == false: + case k.Kind == "table": + if t, _ := v.(map[string]any); t["enabled"] != false { + out = append(out, k.Name) + } + case v == true: + out = append(out, k.Name) + default: + out = append(out, show(v)) + } + } + return strings.Join(out, " ") +} + +// show writes a value as a form does: a list joined by commas +func show(v any) string { + switch v := v.(type) { + case []any: + var out []string + for _, e := range v { + out = append(out, show(e)) + } + return strings.Join(out, ",") + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + } + return fmt.Sprint(v) +} + +// problemPath reads the part a validation error names: its pipeline, node and +// key, as config's key paths write them +var problemPath = regexp.MustCompile(`^pipelines?\[(\d+)\](?:\.(plugin_sources|plugin_sinks)\[([^\]]*)\](?:\.config\.?([\w.]*))?|\.flow\.(\w+)(?:\[(\d+)\])?\.?([\w.]*))?`) + +func locate(err error) (pi int, n compose.Node, key string, ok bool) { + m := problemPath.FindStringSubmatch(err.Error()) + if m == nil { + return 0, n, "", false + } + pi, _ = strconv.Atoi(m[1]) + switch { + case m[2] != "": + n = compose.Node{Role: map[string]string{"plugin_sources": "source", "plugin_sinks": "sink"}[m[2]], ID: m[3]} + key = m[4] + case m[5] != "": + n = compose.Node{Role: m[5]} + if m[6] != "" { + n.Index, _ = strconv.Atoi(m[6]) + } + key = m[7] + } + return pi, n, key, true +} + +// faulty reports whether the first problem names this node, or a filter +// when n is the filters stage +func (a *app) faulty(n compose.Node) bool { + if a.problem == nil { + return false + } + pi, m, _, ok := locate(a.problem) + return ok && pi == a.pi && m.Role == n.Role && m.ID == n.ID +} diff --git a/internal/tui/dialog.go b/internal/tui/dialog.go new file mode 100644 index 0000000..82f95c1 --- /dev/null +++ b/internal/tui/dialog.go @@ -0,0 +1,366 @@ +package tui + +import ( + "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, w by h at most, centered in r with its title, +// and returns the inside +func (a *app) frame(r ui.Region, w, h int, title string) ui.Region { + b := ui.Center(r, min(w, r.W), min(h, r.H)) + b.FillStyle(a.th.Text) + wire := a.font.wire + across := strings.Repeat(string(wire[10]), max(0, b.W-2)) + b.TextStyled(0, 0, string(wire[6])+across+string(wire[12]), a.th.Border) + b.TextStyled(0, b.H-1, string(wire[3])+across+string(wire[9]), a.th.Border) + for y := 1; y < b.H-1; y++ { + b.TextStyled(0, y, string(wire[5]), a.th.Border) + b.TextStyled(b.W-1, y, string(wire[5]), a.th.Border) + } + b.TextStyled(2, 0, " "+ui.Truncate(title, b.W-6)+" ", a.th.Accent) + return b.Sub(2, 1, b.W-4, b.H-2) +} + +// 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) { + in := a.frame(r, 76, len(c.list.Options)+4, c.title) + in.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) + } + 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.inspect, a.insp.cursor = true, 0 + for j, l := range a.lines() { + if l.group != "" && l.node == n { + a.insp.cursor = j + } + } + }} +} + +// presetMenu starts the pipeline over from a preset, or the pipelines from a +// pasted command line; on a bare start, Esc starts empty +func (a *app) presetMenu() dialog { + 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"}) + 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" + } + empty := func(a *app) { + a.use(config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}}) + } + menu := &chooser{title: title, list: ui.NewOptionListState(options)} + 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.pick = func(a *app, i int) { + switch { + case i == len(presets): + empty(a) + case len(presets[i].Params) == 0: + (&presetForm{preset: presets[i]}).apply(a) + default: + a.dialog = newPresetForm(presets[i]) + } + } + menu.back = func(a *app) { + if len(a.comp.Pipelines) == 0 { + empty(a) + } + } + return menu +} + +// 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} +} + +// presetForm takes a preset's keys, its defaults as placeholders +type presetForm struct { + preset config.Preset + fields []*ui.TextFieldState + focus ui.Focus + scroll int // the first field shown + err string +} + +func newPresetForm(p config.Preset) *presetForm { + f := &presetForm{preset: p, focus: ui.Focus{Len: len(p.Params)}} + for range p.Params { + f.fields = append(f.fields, ui.NewTextFieldState("")) + } + return f +} + +func (f *presetForm) draw(a *app, r ui.Region) { + th := a.th + in := a.frame(r, 76, len(f.fields)+6, "preset "+f.preset.Name) + in.TextStyled(0, 0, ui.Truncate(f.preset.Summary, in.W), th.Muted) + rows := max(1, in.H-3) // between the summary and the note + f.scroll = max(0, min(f.scroll, f.focus.Index, len(f.fields)-rows), f.focus.Index-rows+1) + for i := f.scroll; i < len(f.fields) && i-f.scroll < rows; i++ { + p := f.preset.Params[i] + v, _ := in.Field(2+i-f.scroll, 14, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th) + v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th) + } + note, style := f.preset.Params[f.focus.Index].Help, th.Muted + if f.err != "" { + note, style = f.err, th.Error + } + in.TextStyled(0, in.H-1, ui.Truncate(note, in.W), style) +} + +func (f *presetForm) 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 = a.presetMenu() + case is(ev, terminal.KeyEnter, ""): + f.apply(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) + } +} + +// apply builds the preset's pipeline, on this host: a path is a directory +// when one is there +func (f *presetForm) apply(a *app) { + values := map[string]string{} + for i, p := range f.preset.Params { + if v := f.fields[i].Value(); v != "" { + values[p.Name] = v + } + } + c, err := compose.FromPreset(f.preset.Name, values, config.HostIsDir) + if err != nil { + f.err = err.Error() + return + } + a.dialog = nil + a.use(c.Pipelines[0]) +} + +// outputs are what o offers: run, or print one form +var outputs = []struct { + name, hint string + form func(*compose.Composition) (string, error) // nil runs +}{ + {"run", "start the pipelines now, with lw's other settings", nil}, + {"command line", "print lw's flags", (*compose.Composition).CommandLine}, + {"environment", "print LOGWISP_ variables: one pipeline", (*compose.Composition).Environment}, + {"file", "print a configuration file's pipelines", func(c *compose.Composition) (string, error) { + data, err := c.File() + return string(data), err + }}, +} + +func (a *app) outputMenu() dialog { + var options []ui.Option + for _, o := range outputs { + options = append(options, ui.Option{Name: o.name, Hint: o.hint}) + } + return &chooser{title: "Output, once the screen closes", list: ui.NewOptionListState(options), + pick: func(a *app, i int) { + o := outputs[i] + if o.form == nil { + if a.problem != nil { + a.status = "not valid yet: c shows the problem" + return + } + a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true + return + } + text, err := o.form(a.comp) + if err != nil { + a.status = err.Error() + return + } + a.result, a.done = Result{Exit: Print, Output: text}, true + }} +} + +// notice shows lines until any key +type notice struct { + title string + lines []string +} + +func (n *notice) draw(a *app, r ui.Region) { + w, lines := ui.RuneLen(n.title)+8, fit(n.lines, r.W) + for _, l := range lines { + w = max(w, ui.RuneLen(l)+6) + } + in := a.frame(r, w, len(lines)+4, n.title) + for i, l := range lines { + in.TextStyled(0, 1+i, l, a.th.Text) + } +} + +// fit wraps lines to a dialog's inside on a screen w wide +func fit(lines []string, w int) []string { + var out []string + for _, l := range lines { + out = append(out, ui.WrapText(l, max(1, w-6))...) + } + return out +} + +func (n *notice) handle(a *app, ev terminal.Event) { + if ev.Type == terminal.EventKey { + a.dialog = nil + } +} + +// confirm asks a yes or no question +type confirm struct { + question string + yes func(a *app) +} + +func (c *confirm) draw(a *app, r ui.Region) { + lines := fit([]string{c.question}, r.W) + w := 0 + for _, l := range lines { + w = max(w, ui.RuneLen(l)+6) + } + in := a.frame(r, w, len(lines)+5, "Confirm") + for i, l := range lines { + in.TextStyled(0, 1+i, l, a.th.Text) + } + in.TextStyled(0, 1+len(lines), "y yes n no", a.th.Muted) +} + +func (c *confirm) handle(a *app, ev terminal.Event) { + switch { + case is(ev, terminal.KeyNone, "yY"): + a.dialog = nil + c.yes(a) + case is(ev, terminal.KeyEscape, "nNq"): + a.dialog = nil + } +} + +// help lists the keys +func help(f font) dialog { + keys := [][2]string{ + {"arrows, hjkl", "move"}, + {f.enter, "inspect the chosen part; edit a value"}, + {"tab", "next value"}, + {"esc", "back"}, + {"a", "add a source, sink or filter"}, + {"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"}, + {"o", "run, or print a command line, environment or file"}, + {"c", "the first problem"}, + {"paste", "an lw command line replaces the pipelines"}, + {"q", "quit"}, + } + n := ¬ice{title: "Keys"} + for _, k := range keys { + n.lines = append(n.lines, ui.PadRight(k[0], 14)+k[1]) + } + return n +} diff --git a/internal/tui/inspector.go b/internal/tui/inspector.go new file mode 100644 index 0000000..504701f --- /dev/null +++ b/internal/tui/inspector.go @@ -0,0 +1,415 @@ +package tui + +import ( + "cmp" + "fmt" + "strconv" + "strings" + + "github.com/lixenwraith/logwisp/internal/compose" + "github.com/lixenwraith/logwisp/internal/config" + + "github.com/lixenwraith/terminal" + ui "github.com/lixenwraith/terminal/tui" +) + +// inspector lists the chosen node's keys a row each; tables, lists and +// filters fold +type inspector struct { + cursor, scroll int + open map[string]bool // unfolded groups, by foldKey + field *ui.TextFieldState // the value being typed, nil while none +} + +// line is one inspector row: a key, a group that folds, or a list's element +type line struct { + node compose.Node + key config.Key // its Name is the dotted path in the node's options + depth int + group string // a group's title, "" for a value + item int // a list element's index, the list's length on the row that adds one; -1 elsewhere +} + +func (a *app) foldKey(n compose.Node, path string) string { + return fmt.Sprintf("%d/%s/%s/%d/%s", a.pi, n.Role, n.ID, n.Index, path) +} + +// lines are the chosen node's rows; the filters stage lists each filter +func (a *app) lines() []line { + n, ok := a.node() + if !ok { + return nil + } + if n.Role != "filters" { + return a.keyLines(nil, n, a.keys(n), "", 0) + } + var out []line + for i, f := range a.pipeline().Flow.Filters { + fn := compose.Node{Role: "filters", Index: i} + out = append(out, line{node: fn, group: fmt.Sprintf("%d %s", i+1, f.Type), item: -1}) + if a.insp.open[a.foldKey(n, strconv.Itoa(i))] { + out = a.keyLines(out, fn, a.keys(n), "", 1) + } + } + return out +} + +func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix string, depth int) []line { + for _, k := range keys { + name := k.Name + k.Name = prefix + k.Name + switch k.Kind { + case "table": + out = append(out, line{node: n, key: k, depth: depth, group: name, item: -1}) + if a.insp.open[a.foldKey(n, k.Name)] { + p, _ := a.plugin(n) + out = a.keyLines(out, n, sided(a.tables[name], p.Side), k.Name+".", depth+1) + } + case "list": + out = append(out, line{node: n, key: k, depth: depth, group: name, item: -1}) + if a.insp.open[a.foldKey(n, k.Name)] { + items := a.items(n, k.Name) + for i := range len(items) + 1 { + out = append(out, line{node: n, key: k, depth: depth + 1, item: i}) + } + } + default: + out = append(out, line{node: n, key: k, depth: depth, item: -1}) + } + } + return out +} + +// value is a key's value at its dotted path, nil while unset +func (a *app) value(n compose.Node, path string) any { + opts, _, _ := a.comp.Options(a.pi, n) + var v any = opts + for _, part := range strings.Split(path, ".") { + m, _ := v.(map[string]any) + v = m[part] + } + return v +} + +func (a *app) items(n compose.Node, path string) []string { + var out []string + list, _ := a.value(n, path).([]any) + for _, v := range list { + out = append(out, show(v)) + } + return out +} + +// placeholder says what an unset key means: its default, what zero means, or +// the kind of value it takes +func placeholder(k config.Key) string { + if k.Zero != "" { + return "(" + k.Zero + ")" + } + return cmp.Or(k.Default, k.Hint) +} + +func (a *app) drawInspector(r ui.Region) { + th, f := a.th, a.font + n, ok := a.node() + title := "nothing chosen" + if ok { + title = a.nodeTitle(n) + } + hint := f.enter + " inspect" + if a.inspect { + hint = f.enter + " edit esc back" + } + r.TextStyled(0, 0, strings.Repeat(string(f.rule), r.W), th.Border) + r.TextStyled(2, 0, " "+ui.Truncate(title, r.W-ui.RuneLen(hint)-10)+" ", th.Text) + r.TextStyled(r.W-ui.RuneLen(hint)-3, 0, " "+hint+" ", th.Muted) + lines := a.lines() + switch { + case !ok: + r.TextStyled(2, 2, "a adds a "+columns[a.col].role, th.Muted) + return + case len(lines) == 0 && n.Role == "filters": + r.TextStyled(2, 2, "no filters: a adds one", th.Muted) + return + case len(lines) == 0: + r.TextStyled(2, 2, "no options", th.Muted) + return + } + a.insp.cursor = max(0, min(a.insp.cursor, len(lines)-1)) + rows := r.H - 2 + cols, perCol := 1, rows + if half := (len(lines) + 1) / 2; r.W >= 80 && half <= rows { + cols, perCol = 2, half + } + if cols == 1 { + a.insp.scroll = max(0, min(a.insp.scroll, a.insp.cursor, len(lines)-rows)) + a.insp.scroll = max(a.insp.scroll, a.insp.cursor-rows+1) + } else { + a.insp.scroll = 0 + } + colW, labelW := r.W/cols, 0 + for _, l := range lines { + labelW = max(labelW, 2*l.depth+ui.RuneLen(label(l))+2) + } + labelW = min(labelW, colW/2) + for i := a.insp.scroll; i < len(lines) && i-a.insp.scroll < perCol*cols; i++ { + at := i - a.insp.scroll + row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1) + a.drawLine(row, lines[i], labelW, a.inspect && i == a.insp.cursor) + } + if a.inspect && rows > 0 { + help, style := a.help(lines[a.insp.cursor]) + r.TextStyled(2, r.H-1, ui.Truncate(help, r.W-4), style) + } +} + +func (a *app) nodeTitle(n compose.Node) string { + switch n.Role { + case "source", "sink": + p, _ := a.plugin(n) + return p.Type + " · " + n.Role + " · id " + n.ID + case "filters": + return fmt.Sprintf("filters · flow · %d", len(a.pipeline().Flow.Filters)) + } + return n.Role + " · flow" +} + +// help is the focused row's problem, or its key's help and default +func (a *app) help(l line) (string, ui.Style) { + if a.problem != nil { + if pi, n, key, ok := locate(a.problem); ok && pi == a.pi && n == l.node && key == l.key.Name { + return a.problem.Error(), a.th.Error + } + } + if l.group != "" && l.key.Name == "" { + return "J and K move this filter; d deletes it", a.th.Muted + } + help := l.key.Help + if d := placeholder(l.key); d != "" && l.group == "" { + help += "; unset: " + d + } + return help, a.th.Muted +} + +// label is a row's name: its key's last part, or a list element's number +func label(l line) string { + if l.item >= 0 { + return strconv.Itoa(l.item + 1) + } + return l.key.Name[strings.LastIndex(l.key.Name, ".")+1:] +} + +func (a *app) drawLine(r ui.Region, l line, labelW int, focused bool) { + th := a.th + r = r.Sub(2*l.depth, 0, r.W-2*l.depth, 1) + k := l.key + if l.group != "" { + open := a.insp.open[a.foldKey(l.node, k.Name)] + if k.Name == "" { + open = a.insp.open[a.foldKey(compose.Node{Role: "filters"}, strconv.Itoa(l.node.Index))] + } + r.Group(0, l.group, a.groupSummary(l), open, focused, th) + return + } + v, _ := r.Field(0, labelW-2*l.depth, ui.Field{Label: label(l), Required: k.Required && l.item < 0}, focused, th) + items := a.items(l.node, k.Name) + cur := a.value(l.node, k.Name) + switch { + case focused && a.insp.field != nil: + v.TextInput(a.insp.field, placeholder(k), true, th) + case l.item == len(items): + v.TextStyled(0, 0, "+ add", th.Muted) + case l.item >= 0: + v.TextStyled(0, 0, ui.Truncate(items[l.item], v.W), th.Text) + case len(k.Enum) > 0: + v.Choice(0, 0, k.Enum, choice(k, cur), th) + case k.Kind == "bool": + v.Toggle(0, 0, cur == true || cur == nil && k.Default == "true", th) + case cur == nil: + v.TextStyled(0, 0, ui.Truncate(placeholder(k), v.W), th.Muted) + default: + v.TextStyled(0, 0, ui.Truncate(show(cur), v.W), th.Text) + } +} + +// choice is the index of an enum key's value, or of its default while unset +func choice(k config.Key, cur any) int { + want := cmp.Or(fmt.Sprint(cmp.Or(cur, any(""))), k.Default) + for i, e := range k.Enum { + if e == want { + return i + } + } + return -1 +} + +// groupSummary is what a folded group holds: a list's values, a table's set +// keys, a filter's logic and patterns +func (a *app) groupSummary(l line) string { + if l.key.Name == "" { + f := a.pipeline().Flow.Filters[l.node.Index] + return string(f.Logic) + " " + strings.Join(f.Patterns, " ") + } + switch v := a.value(l.node, l.key.Name).(type) { + case []any: + return show(v) + case map[string]any: + var out []string + for _, k := range a.tables[l.group] { + if s, ok := v[k.Name]; ok { + out = append(out, k.Name+" "+show(s)) + } + } + return strings.Join(out, " ") + } + return "" +} + +// inspectKey handles a key in the inspector, false for one it leaves to +// the global keys +func (a *app) inspectKey(ev terminal.Event) bool { + lines := a.lines() + if a.insp.field != nil && len(lines) > 0 { + a.typeKey(ev, lines[min(a.insp.cursor, len(lines)-1)]) + return true + } + if is(ev, terminal.KeyEscape, "") { + a.inspect = false + return true + } + if is(ev, terminal.KeyNone, "a") { + a.dialog = a.addMenu() + return true + } + if len(lines) == 0 { + return false + } + a.insp.cursor = max(0, min(a.insp.cursor, len(lines)-1)) + l := lines[a.insp.cursor] + k := l.key + cur := a.value(l.node, k.Name) + focus := ui.Focus{Index: a.insp.cursor, Len: len(lines)} + toggle := is(ev, terminal.KeyEnter, " ") || is(ev, terminal.KeySpace, "") + switch { + case focus.HandleKey(ev.Key, ev.Modifiers): + a.insp.cursor = focus.Index + case is(ev, terminal.KeyUp, "k"): + a.insp.cursor = max(0, a.insp.cursor-1) + case is(ev, terminal.KeyDown, "j"): + a.insp.cursor = min(len(lines)-1, a.insp.cursor+1) + case l.group != "" && (toggle || is(ev, terminal.KeyRight, "l") || is(ev, terminal.KeyLeft, "h")): + fold := a.foldKey(l.node, k.Name) + if k.Name == "" { + fold = a.foldKey(compose.Node{Role: "filters"}, strconv.Itoa(l.node.Index)) + } + a.insp.open[fold] = toggle && !a.insp.open[fold] || is(ev, terminal.KeyRight, "l") + case l.group != "" && l.key.Name == "" && is(ev, terminal.KeyNone, "JK"): + a.moveFilter(l.node.Index, map[rune]int{'J': 1, 'K': -1}[ev.Rune]) + case is(ev, terminal.KeyNone, "d"): + a.clear(l) + case len(k.Enum) > 0 && l.item < 0 && (toggle || is(ev, terminal.KeyLeft, "h") || is(ev, terminal.KeyRight, "l")): + i := choice(k, cur) + switch next, moved := ui.StepChoice(ev.Key, ev.Rune, i, len(k.Enum)); { + case moved: + i = next + case !toggle: + return true // an arrow at either end + default: + i = (i + 1) % len(k.Enum) + } + a.edit(a.comp.Set(a.pi, l.node, k.Name, k.Enum[i])) + case k.Kind == "bool" && l.item < 0 && toggle: + on := cur == true || cur == nil && k.Default == "true" + a.edit(a.comp.Set(a.pi, l.node, k.Name, strconv.FormatBool(!on))) + case is(ev, terminal.KeyEnter, ""): + text := show(cmp.Or(cur, any(""))) + if items := a.items(l.node, k.Name); l.item >= 0 { + text = "" + if l.item < len(items) { + text = items[l.item] + } + } + a.insp.field = ui.NewTextFieldState(text) + a.insp.field.Accept = map[string]func(rune) bool{"integer": ui.AcceptInteger, "number": ui.AcceptNumber}[k.Kind] + default: + return false + } + return true +} + +// typeKey edits the value being typed: Enter sets it, Tab sets it and moves +// on, Esc drops it +func (a *app) typeKey(ev terminal.Event, l line) { + switch { + case is(ev, terminal.KeyEscape, ""): + a.insp.field = nil + case is(ev, terminal.KeyEnter, "") || is(ev, terminal.KeyTab, "") || is(ev, terminal.KeyBacktab, ""): + value := a.insp.field.Value() + if a.commit(l, value) { + a.insp.field = nil + if l.item >= 0 && l.item == len(a.items(l.node, l.key.Name))-1 && value != "" { + a.insp.cursor++ // stays on the row that adds one + } + focus := ui.Focus{Index: a.insp.cursor, Len: len(a.lines())} + if focus.HandleKey(ev.Key, ev.Modifiers) { + a.insp.cursor = focus.Index + } + } + default: + a.insp.field.HandleKey(ev.Key, ev.Rune, ev.Modifiers) + } +} + +// commit sets a typed value: empty unsets a key or drops a list element +func (a *app) commit(l line, value string) bool { + k := l.key + if l.item < 0 { + if value == "" { + return a.edit(a.comp.Unset(a.pi, l.node, k.Name)) + } + return a.edit(a.comp.Set(a.pi, l.node, k.Name, value)) + } + items := a.items(l.node, k.Name) + switch { + case l.item == len(items) && value == "": + return true + case l.item == len(items): + items = append(items, value) + case value == "": + items = append(items[:l.item], items[l.item+1:]...) + default: + items[l.item] = value + } + if len(items) == 0 { + return a.edit(a.comp.Unset(a.pi, l.node, k.Name)) + } + return a.edit(a.comp.Set(a.pi, l.node, k.Name, items...)) +} + +// clear deletes what a row holds: a filter, a list element, or a key's +// value, which returns to its default +func (a *app) clear(l line) { + switch { + case l.group != "" && l.key.Name == "": + a.edit(a.comp.Remove(a.pi, l.node)) + case l.item >= 0: + a.commit(l, "") + default: + a.edit(a.comp.Unset(a.pi, l.node, l.key.Name)) + } +} + +// moveFilter moves a filter by one, its fold and the cursor with it +func (a *app) moveFilter(i, by int) { + stage := compose.Node{Role: "filters"} + if i+by < 0 || i+by >= len(a.pipeline().Flow.Filters) || !a.edit(a.comp.MoveFilter(a.pi, i, i+by)) { + return // nothing moves past either end, as j and k stop there + } + from, to := a.foldKey(stage, strconv.Itoa(i)), a.foldKey(stage, strconv.Itoa(i+by)) + a.insp.open[from], a.insp.open[to] = a.insp.open[to], a.insp.open[from] + for j, l := range a.lines() { + if l.group != "" && l.key.Name == "" && l.node.Index == i+by { + a.insp.cursor = j + } + } +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..1a725ed --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,114 @@ +package tui + +import ( + "os" + "strings" + "unicode" + + "github.com/lixenwraith/color" + "github.com/lixenwraith/terminal" + ui "github.com/lixenwraith/terminal/tui" +) + +// look is a color tier's styles: the widgets' theme and each column's tint +type look struct { + ui.Theme + Source, Flow, Sink ui.Style +} + +// The site's dark tokens; true color and 256 colors paint its background +var ( + siteBg = color.RGB{R: 0x14, G: 0x17, B: 0x1d} + sitePanel = color.RGB{R: 0x1c, G: 0x20, B: 0x28} + siteFg = color.RGB{R: 0xe3, G: 0xe7, B: 0xef} + siteMuted = color.RGB{R: 0x9a, G: 0xa3, B: 0xb5} + siteAccent = color.RGB{R: 0x6e, G: 0xa0, B: 0xff} +) + +var siteLook = look{ + Theme: ui.Theme{ + Text: ui.Style{Fg: siteFg, Bg: siteBg}, + Muted: ui.Style{Fg: siteMuted}, + Accent: ui.Style{Fg: siteAccent, Attr: terminal.AttrBold}, + Selected: ui.Style{Bg: color.RGB{R: 0x26, G: 0x2c, B: 0x38}}, + Input: ui.Style{Bg: sitePanel}, + Cursor: ui.Style{Fg: siteBg, Bg: siteFg}, + Error: ui.Style{Fg: color.RGB{R: 0xff, G: 0x6b, B: 0x6b}}, + Border: ui.Style{Fg: color.RGB{R: 0x5a, G: 0x63, B: 0x75}}, + }, + Source: ui.Style{Fg: color.RGB{R: 0x56, G: 0xc8, B: 0xd8}}, + Flow: ui.Style{Fg: color.RGB{R: 0xb1, G: 0x8c, B: 0xff}}, + Sink: ui.Style{Fg: color.RGB{R: 0x6f, G: 0xcf, B: 0x8f}}, +} + +// ansi is a palette index the terminal shows in its own shade +func ansi(i uint8) ui.Style { return ui.Style{Fg: color.RGB{R: i}, Attr: terminal.AttrFg256} } + +// looks holds one look per color tier: 16 colors and none keep the +// terminal's background +var looks = map[terminal.ColorMode]look{ + terminal.ColorModeTrueColor: siteLook, + terminal.ColorMode256: siteLook, + terminal.ColorMode16: {Theme: ui.Theme16, + Source: ansi(color.ANSICyan), Flow: ansi(color.ANSIMagenta), Sink: ansi(color.ANSIGreen)}, + terminal.ColorModeNone: {Theme: ui.MonoTheme, + Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text}, +} + +// font is the marks one character set draws: the widgets' and the canvas's +type font struct { + ui.Glyphs + wire []rune // a wire cell by its arms: up 1, right 2, down 4, left 8 + box []rune // the flow box: top left, top right, bottom left, bottom right, across, down + arrow, down rune // into the flow box and a sink; between stacked parts + bar, barOn rune // a node, and the chosen node + dot, rule rune // before a column's title; the inspector's rule + valid string + invalid string + enter string + space string + folds map[rune]rune // runes its terminal lacks, by the rune drawn instead + ascii bool // past ASCII, any other rune draws as '?' +} + +// fold is r as the font's terminal shows it: the library truncates with '…' +// and the inspector's titles join with '·' +func (f font) fold(r rune) rune { + if to, ok := f.folds[r]; ok { + return to + } + if f.ascii && r > unicode.MaxASCII { + return '?' + } + return r +} + +// fonts: Unicode, a text console's CP437, and ASCII outside UTF-8 +var ( + unicodeFont = font{Glyphs: ui.GlyphsUnicode, wire: []rune(" │─└││┌├─┘─┴┐┤┬┼"), box: []rune("╭╮╰╯╌╎"), + arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', rule: '─', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"} + cp437Font = font{Glyphs: ui.GlyphsCP437, wire: []rune(" │─└││┌├─┘─┴┐┤┬┼"), box: []rune("┌┐└┘─│"), + arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', rule: '─', valid: "√", invalid: "x", enter: "enter", space: "space", + folds: map[rune]rune{'…': '.'}} + asciiFont = font{Glyphs: ui.GlyphsASCII, wire: []rune(" |-+||++-+-+++++"), box: []rune("++++-|"), + arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', rule: '-', valid: "ok", invalid: "x", enter: "enter", space: "space", + folds: map[rune]rune{'…': '.', '·': '-'}, ascii: true} +) + +// fontFor picks ASCII outside a UTF-8 locale, CP437 on a text console (the +// terminals with 16 colors), Unicode elsewhere +func fontFor(detected terminal.ColorMode) font { + locale := "" + for _, name := range []string{"LC_ALL", "LC_CTYPE", "LANG"} { + if locale = os.Getenv(name); locale != "" { + break + } + } + switch l := strings.ToLower(locale); { + case l != "" && !strings.Contains(l, "utf-8") && !strings.Contains(l, "utf8"): + return asciiFont + case detected == terminal.ColorMode16: + return cp437Font + } + return unicodeFont +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..b9287c2 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,430 @@ +// 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" + "strings" + + "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. +func Run(c *compose.Composition, color string) (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())) + w, h := t.Size() + for !a.done { + cells := make([]terminal.Cell, w*h) + 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 + 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 + tables map[string][]config.Key + pi int // the pipeline shown + col int // the chosen column + row [3]int // the chosen node of each column + scroll int // the canvas's first row + inspect bool // keys go to the inspector + insp inspector + dialog dialog // takes every key while open + status string // the last edit's refusal + note string // what the last action did + problem error // the composition's first problem + 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, tables: compose.NewSchema().Tables} + 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 of its side, a stage's +func (a *app) keys(n compose.Node) []config.Key { + if p, ok := a.plugin(n); ok { + return sided(p.Keys(), p.Side) + } + for _, s := range config.FlowStages() { + if s.Key == n.Role { + return s.Keys + } + } + return nil +} + +// sided drops the keys of the other network side +func sided(keys []config.Key, side config.Side) []config.Key { + name := map[config.Side]string{config.Listener: "listener", config.ChainListener: "listener", config.Dialer: "dialer"}[side] + var out []config.Key + for _, k := range keys { + if k.Side == "" || k.Side == name { + out = append(out, k) + } + } + return out +} + +// 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: an open dialog takes all, then the inspector, then +// the canvas +func (a *app) handle(ev terminal.Event) { + if ev.Type == terminal.EventKey || ev.Type == terminal.EventPaste { + a.status, a.note = "", "" + } + switch { + case a.dialog != nil: + a.dialog.handle(a, 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.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] = max(0, a.row[a.col]-1) + case is(ev, terminal.KeyDown, "j"): + a.row[a.col]++ + a.node() + case is(ev, terminal.KeyEnter, "") && ok: + a.inspect, a.insp.cursor = true, 0 + 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) + case is(ev, terminal.KeyNone, "c"): + a.showProblem() + 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() + } +} + +// 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 +func (a *app) paste(text string) { + c, err := compose.FromCommandLine(text, config.HostIsDir) + if err == nil && len(c.Pipelines) == 0 { + err = errors.New("no pipeline flags") + } + 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 { + replace(a) + return + } + a.dialog = &confirm{question: "Replace the pipelines with the pasted command line?", yes: replace} +} + +func (a *app) quit() { + if !a.changed { + a.done = true + return + } + a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: func(a *app) { a.done = true }} +} + +// showProblem chooses the part the first problem names and shows it whole +func (a *app) showProblem() { + if a.problem == nil { + a.note = "the pipelines are valid" + return + } + if pi, n, key, ok := locate(a.problem); ok && pi < len(a.comp.Pipelines) { + a.pi = pi + a.choose(n) + a.inspect = true + a.insp.cursor = 0 + for i, l := range a.lines() { + if l.node == n && l.key.Name == key { + a.insp.cursor = i + } + } + } + a.dialog = ¬ice{title: "Problem", lines: ui.WrapText(a.problem.Error(), 60)} +} + +// 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(compose.Node{Role: "filters"}, fmt.Sprint(n.Index))] = 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 canvas with the inspector beside +// it from 110 columns or below it, a status row, and any dialog over them +func (a *app) draw(r ui.Region) { + r.FillStyle(a.th.Text) + 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 + p := a.pipeline() + title := fmt.Sprintf("LogWisp pipeline %s %d of %d", p.Name, a.pi+1, len(a.comp.Pipelines)) + r.TextStyled(1, 0, ui.Truncate(title, r.W-2), a.th.Accent) + body := r.Sub(0, 1, r.W, r.H-2) + if r.W >= 110 { + iw := min(60, r.W*2/5) + a.drawCanvas(body.Sub(0, 0, r.W-iw-1, body.H)) + a.drawInspector(body.Sub(r.W-iw, 0, iw, body.H)) + } else { + ch := min(a.layout(r.W).h, body.H-min(10, body.H/2)) + a.drawCanvas(body.Sub(0, 0, r.W, ch)) + a.drawInspector(body.Sub(0, ch+1, r.W, body.H-ch-1)) + } + } + a.drawStatus(r.Sub(0, r.H-1, r.W, 1)) + if a.dialog != nil { + a.dialog.draw(a, r) + } +} + +// 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) + } +} + +// drawStatus shows an edit's refusal, a note, or whether the pipelines are +// valid, and the keys +func (a *app) drawStatus(r ui.Region) { + th, f := a.th, a.font + text, style := f.valid+" valid", th.Accent + switch { + case a.status != "": + text, style = a.status, th.Error + case a.note != "": + text, style = a.note, th.Muted + case a.problem != nil: + text, style = f.invalid+" "+a.problem.Error(), th.Error + } + keys := "a add d delete " + f.space + " on/off J/K move p preset o output ? help" + if ui.RuneLen(keys)+24 > r.W { + keys = "? help" + } + r.TextStyled(1, 0, ui.Truncate(text, r.W-ui.RuneLen(keys)-4), style) + r.TextStyled(r.W-ui.RuneLen(keys)-1, 0, keys, th.Muted) +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..3328502 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,474 @@ +package tui + +import ( + "slices" + "strings" + "testing" + + "github.com/lixenwraith/logwisp/internal/compose" + "github.com/lixenwraith/logwisp/internal/config" + + "github.com/lixenwraith/terminal" + ui "github.com/lixenwraith/terminal/tui" +) + +// press sends keys: a string's runes, a terminal.Key, or a pasted []byte +func press(a *app, in ...any) { + for _, k := range in { + switch k := k.(type) { + case string: + for _, r := range k { + a.handle(terminal.Event{Type: terminal.EventKey, Key: terminal.KeyRune, Rune: r}) + } + case terminal.Key: + a.handle(terminal.Event{Type: terminal.EventKey, Key: k}) + case []byte: + a.handle(terminal.Event{Type: terminal.EventPaste, Text: string(k)}) + } + } +} + +// render paints the screen as Run does and returns its cells +func render(a *app, w, h int) []terminal.Cell { + cells := make([]terminal.Cell, w*h) + a.paint(cells, w, h) + return cells +} + +func text(cells []terminal.Cell, w int) []string { + var rows []string + for y := range len(cells) / w { + var b strings.Builder + for _, c := range cells[y*w : (y+1)*w] { + b.WriteRune(max(c.Rune, ' ')) + } + rows = append(rows, b.String()) + } + return rows +} + +// pipeline builds a composition of one pipeline from typed parts +func pipeline(t *testing.T, sources, sinks []string) *compose.Composition { + t.Helper() + c := &compose.Composition{} + if err := c.AddPipeline("p"); err != nil { + t.Fatal(err) + } + for role, types := range map[string][]string{"source": sources, "sink": sinks} { + for _, typ := range types { + if _, err := c.Add(0, role, typ); err != nil { + t.Fatal(err) + } + } + } + return c +} + +func mono(c *compose.Composition) *app { return newApp(c, looks[terminal.ColorModeNone], unicodeFont) } + +// focusKey puts the inspector's cursor on a key's row +func focusKey(t *testing.T, a *app, name string) line { + t.Helper() + for i, l := range a.lines() { + if l.key.Name == name && l.item < 0 { + a.insp.cursor = i + return l + } + } + t.Fatalf("no row %s", name) + return line{} +} + +// Each wire glyph's arms meet the cells beside it, whatever the count of +// sources and sinks and the width, and every source and sink is wired to +// the flow box's arrow +func TestWiresJoinWhereTheyMeet(t *testing.T) { + arms := map[rune]uint8{'│': 5, '─': 10, '└': 3, '┌': 6, '├': 7, '┘': 9, '┴': 11, '┐': 12, '┤': 13, '┬': 14, '┼': 15, '►': 10} + steps := map[uint8][2]int{1: {0, -1}, 2: {1, 0}, 4: {0, 1}, 8: {-1, 0}} + opposite := map[uint8]uint8{1: 4, 2: 8, 4: 1, 8: 2} + for n := range 16 { + sources, sinks := slices.Repeat([]string{"null"}, n%4), slices.Repeat([]string{"null"}, n/4) + for _, w := range []int{64, 80, 109, 130} { + a := mono(pipeline(t, sources, sinks)) + l := a.layout(w) + cells := make([]terminal.Cell, w*l.h) + a.drawCanvas(ui.NewRegion(cells, w, 0, 0, w, l.h)) + glyph := func(x, y int) rune { + if x < 0 || x >= w || y < 0 || y >= l.h { + return ' ' + } + return cells[y*w+x].Rune + } + reached := map[[2]int]bool{} + var walk func(x, y int) + walk = func(x, y int) { + m, wire := arms[glyph(x, y)] + if !wire || reached[[2]int{x, y}] { + return + } + reached[[2]int{x, y}] = true + for arm, d := range steps { + if m&arm == 0 { + continue + } + next, ok := arms[glyph(x+d[0], y+d[1])] + if !ok && glyph(x, y) != '─' && glyph(x, y) != '►' { + t.Errorf("%d sources, %d sinks, %d columns: %q at %d,%d points at %q", len(sources), len(sinks), w, glyph(x, y), x, y, glyph(x+d[0], y+d[1])) + } + if ok && next&opposite[arm] == 0 { + t.Errorf("%d sources, %d sinks, %d columns: %q at %d,%d meets %q", len(sources), len(sinks), w, glyph(x, y), x, y, glyph(x+d[0], y+d[1])) + } + walk(x+d[0], y+d[1]) + } + } + walk(l.box.x-1, l.entry) + walk(l.box.x+l.box.w+1, l.exit) + for i := range sources { + if s := l.nodes[sourcesCol][i]; !reached[[2]int{s.x + s.w, s.y}] { + t.Errorf("%d sources, %d sinks, %d columns: source %d not wired", len(sources), len(sinks), w, i) + } + } + for i := range sinks { + if s := l.nodes[sinksCol][i]; !reached[[2]int{s.x - 1, s.y}] { + t.Errorf("%d sources, %d sinks, %d columns: sink %d not wired", len(sources), len(sinks), w, i) + } + } + } + } +} + +// The parts stack under 64 columns; from 64 they sit side by side with the +// inspector below, and from 110 the inspector sits beside them +func TestLayoutFollowsTheWidth(t *testing.T) { + a := mono(pipeline(t, []string{"null"}, []string{"null"})) + for w, want := range map[int]string{63: "stacked", 64: "below", 109: "below", 110: "beside"} { + rows := text(render(a, w, 40), w) + at := func(s string) (int, int) { + for y, r := range rows { + if x := strings.Index(r, s); x >= 0 { + return ui.RuneLen(r[:x]), y + } + } + return -1, -1 + } + _, sourcesY := at("● SOURCES") + _, flowY := at("● FLOW") + ruleX, ruleY := at("── null · source") + got := "below" + switch { + case flowY > sourcesY: + got = "stacked" + case ruleX > 0 && ruleY == 1: + got = "beside" + case ruleX != 0 || ruleY <= flowY: + got = "lost" + } + if got != want { + t.Errorf("%d columns: %s, want %s\n%s", w, got, want, strings.Join(rows, "\n")) + } + } +} + +// Without color, the chosen part still changes a glyph: a source's or a +// sink's bar, a stage's on or off mark, an empty column's slot +func TestSelectionChangesAGlyph(t *testing.T) { + for _, f := range []font{unicodeFont, cp437Font, asciiFont} { + c := pipeline(t, []string{"null"}, nil) + if err := c.Set(0, compose.Node{Role: "format"}, "type", "txt"); err != nil { + t.Fatal(err) + } + a := newApp(c, looks[terminal.ColorModeNone], f) + l := a.layout(80) + at := func(col, row int) rune { + cells := make([]terminal.Cell, 80*l.h) + a.drawCanvas(ui.NewRegion(cells, 80, 0, 0, 80, l.h)) + s := l.nodes[col][row] + return cells[s.y*80+s.x].Rune + } + for _, n := range [][2]int{{sourcesCol, 0}, {flowCol, 0}, {flowCol, 2}, {sinksCol, 0}} { + a.col, a.row = (n[0]+1)%3, [3]int{} + off := at(n[0], n[1]) + a.col, a.row[n[0]] = n[0], n[1] + if on := at(n[0], n[1]); on == off { + t.Errorf("%c: column %d row %d draws %q either way", f.bar, n[0], n[1], on) + } + } + } +} + +// The inspector types a value and sets it, typed by the key's kind, and Tab +// moves on; empty or d returns it to its default; arrows step a choice, +// setting nothing past either end, and Space a switch +func TestInspectorSetsKeys(t *testing.T) { + a := mono(pipeline(t, []string{"file"}, []string{"null"})) + press(a, terminal.KeyEnter) + file := compose.Node{Role: "source", ID: "file"} + opts := func() map[string]any { + o, _, _ := a.comp.Options(0, file) + return o + } + focusKey(t, a, "from") + if press(a, terminal.KeyRight); opts()["from"] != nil || a.changed { + t.Fatalf("Right past the default's end set %v", opts()["from"]) + } + focusKey(t, a, "directory") + at := a.insp.cursor + if press(a, terminal.KeyEnter, "/srv/a b", terminal.KeyTab); a.insp.cursor != at+1 { + t.Fatalf("Tab left the cursor on row %d", a.insp.cursor) + } + focusKey(t, a, "check_interval_ms") + press(a, terminal.KeyEnter, "1x5", terminal.KeyEnter) + focusKey(t, a, "from") + press(a, terminal.KeyLeft) + focusKey(t, a, "raw") + press(a, " ") + if o := opts(); o["directory"] != "/srv/a b" || o["check_interval_ms"] != int64(15) || o["from"] != "start" || o["raw"] != true { + t.Fatalf("%v", o) + } + focusKey(t, a, "directory") + press(a, terminal.KeyEnter, terminal.KeyCtrlU, terminal.KeyEnter) + focusKey(t, a, "raw") + press(a, "d") + if o := opts(); o["directory"] != nil || o["raw"] != nil { + t.Fatalf("%v", o) + } +} + +// A list's elements are its rows: one is added, edited or dropped whole, a +// comma in it included +func TestListElementsAreRows(t *testing.T) { + a := mono(pipeline(t, []string{"null"}, []string{"null"})) + press(a, terminal.KeyRight, "a", "exc", terminal.KeyEnter) + if len(a.pipeline().Flow.Filters) != 1 { + t.Fatalf("filters %+v", a.pipeline().Flow.Filters) + } + focusKey(t, a, "patterns") + press(a, terminal.KeyEnter, terminal.KeyDown) + press(a, terminal.KeyEnter, "a{1,3}", terminal.KeyEnter, terminal.KeyEnter, "c", terminal.KeyEnter) + press(a, terminal.KeyUp, terminal.KeyUp, terminal.KeyEnter, terminal.KeyCtrlU, "x", terminal.KeyEnter) + if got := a.pipeline().Flow.Filters[0].Patterns; !slices.Equal(got, []string{"x", "c"}) { + t.Fatalf("%q", got) + } + press(a, terminal.KeyDown, "d") + if got := a.pipeline().Flow.Filters[0].Patterns; !slices.Equal(got, []string{"x"}) { + t.Fatalf("%q", got) + } +} + +// Space turns a flow stage on with its defaults and off again; a stage with +// an enabled key switches that +func TestSpaceSwitchesAStage(t *testing.T) { + a := mono(pipeline(t, []string{"null"}, []string{"null"})) + a.col = flowCol + rate, beat := compose.Node{Role: "rate_limit"}, compose.Node{Role: "heartbeat"} + var got []bool + for _, n := range []compose.Node{rate, rate, beat, beat} { + a.choose(n) + press(a, " ") + got = append(got, a.on(n)) + } + if !slices.Equal(got, []bool{true, false, true, false}) { + t.Fatalf("%v", got) + } + if _, on, _ := a.comp.Options(0, beat); !on { + t.Fatal("heartbeat switched off by dropping its options") + } +} + +// J and K move a filter, its fold and the cursor with it, and stop at +// either end as j and k do +func TestFiltersMoveWithTheirFold(t *testing.T) { + c := pipeline(t, []string{"null"}, []string{"null"}) + for _, typ := range []string{"include", "exclude"} { + if _, err := c.Add(0, "filters", typ); err != nil { + t.Fatal(err) + } + } + a := mono(c) + a.choose(compose.Node{Role: "filters", Index: 0}) + press(a, terminal.KeyEnter, "J", "J") + types := []config.FilterType{a.pipeline().Flow.Filters[0].Type, a.pipeline().Flow.Filters[1].Type} + l := a.lines()[a.insp.cursor] + if !slices.Equal(types, []config.FilterType{"exclude", "include"}) || l.node.Index != 1 || !a.insp.open[a.foldKey(compose.Node{Role: "filters"}, "1")] || a.status != "" { + t.Fatalf("types %v, cursor on %+v, open %v, status %q", types, l, a.insp.open, a.status) + } +} + +// o leaves with the chosen form, printed as the engine writes it, or with +// the pipelines to run; a composition that does not validate stays +func TestOutputLeavesWithTheForm(t *testing.T) { + c := pipeline(t, []string{"null"}, []string{"null"}) + line, err := c.CommandLine() + if err != nil { + t.Fatal(err) + } + a := mono(c) + press(a, "o", "comm", terminal.KeyEnter) + if !a.done || a.result.Exit != Print || a.result.Output != line { + t.Fatalf("%+v", a.result) + } + a = mono(pipeline(t, []string{"null"}, []string{"null"})) + press(a, "o", terminal.KeyEnter) + if !a.done || a.result.Exit != Start || len(a.result.Pipelines) != 1 { + t.Fatalf("%+v", a.result) + } + a = mono(pipeline(t, []string{"file"}, []string{"null"})) + press(a, "o", terminal.KeyEnter) + if a.done || a.status == "" { + t.Fatalf("ran an invalid composition: %+v", a.result) + } +} + +// With nothing to start from the preset menu opens: Esc starts empty, a +// preset asks its keys and is work q asks about; p later replaces the +// pipeline, under its name, once confirmed +func TestPresetsStartThePipeline(t *testing.T) { + a := mono(&compose.Composition{}) + press(a, terminal.KeyEscape) + if a.dialog != nil || len(a.comp.Pipelines) != 1 || len(a.pipeline().PluginSources) != 0 { + t.Fatalf("Esc: %+v", a.comp.Pipelines) + } + if press(a, "q"); !a.done { + t.Fatal("q asked about an empty start") + } + a = mono(&compose.Composition{}) + press(a, "tail", terminal.KeyEnter, "/var/log/", terminal.KeyEnter) + if a.dialog != nil || a.pipeline().Name != "tail" || a.pipeline().PluginSources[0].Type != "file" { + t.Fatalf("tail: %+v", a.comp.Pipelines) + } + if press(a, "q"); a.done { + t.Fatal("q dropped the preset without asking") + } + press(a, "n") + press(a, "p", "pipe", terminal.KeyEnter, terminal.KeyEnter) + if _, asks := a.dialog.(*confirm); !asks { + t.Fatalf("replaced without asking: %T", a.dialog) + } + press(a, "y") + if a.pipeline().Name != "tail" || a.pipeline().PluginSources[0].Type != "console" { + t.Fatalf("pipe: %+v", a.comp.Pipelines) + } +} + +// A pasted command line replaces the pipelines, from the preset menu too, +// asking first once they were edited; one that names none is refused +func TestPasteReplacesThePipelines(t *testing.T) { + a := mono(&compose.Composition{}) + if press(a, []byte("lw\n")); a.status == "" { + t.Fatalf("took a paste without pipelines: %+v", a.comp.Pipelines) + } + if _, menu := a.dialog.(*chooser); !menu { + t.Fatalf("a refused paste closed the preset menu: %T", a.dialog) + } + press(a, []byte("lw --pipeline x --source null --sink null\r\n")) + if a.dialog != nil || len(a.comp.Pipelines) != 1 || a.pipeline().Name != "x" { + t.Fatalf("%T %+v", a.dialog, a.comp.Pipelines) + } + press(a, terminal.KeyRight, terminal.KeyDown, terminal.KeyDown, " ", []byte("lw --pipeline y --source null --sink null"), "n") + if a.pipeline().Name != "x" { + t.Fatalf("replaced edited pipelines without asking: %+v", a.comp.Pipelines) + } +} + +// The first problem marks the part it names, and c moves there, to the key +func TestProblemNamesItsPart(t *testing.T) { + a := mono(pipeline(t, []string{"null"}, []string{"null", "tcp"})) + sink := compose.Node{Role: "sink", ID: "tcp"} + if !a.faulty(sink) || a.faulty(compose.Node{Role: "source", ID: "null"}) || a.faulty(compose.Node{Role: "sink", ID: "null"}) { + t.Fatalf("problem %v", a.problem) + } + press(a, "c") + n, _ := a.node() + if l := a.lines()[a.insp.cursor]; n != sink || !a.inspect || l.key.Name != "port" { + t.Fatalf("on %+v, row %+v", n, l) + } +} + +// 16 colors and none keep the terminal's own background; more paint the site's +func TestOnlyFullColorPaintsTheBackground(t *testing.T) { + for mode, l := range looks { + own := l.Text.Attr&terminal.AttrBgDefault != 0 + if own != (mode == terminal.ColorMode16 || mode == terminal.ColorModeNone) || !own && l.Text.Bg != siteBg { + t.Errorf("mode %v: %+v", mode, l.Text) + } + } +} + +// ASCII outside a UTF-8 locale, CP437 on a text console, else Unicode +func TestFontFollowsTheLocale(t *testing.T) { + for _, c := range []struct { + lang string + mode terminal.ColorMode + want rune + }{{"C", terminal.ColorMode256, '|'}, {"en_US.UTF-8", terminal.ColorMode16, cp437Font.dot}, {"", terminal.ColorMode256, '▌'}} { + t.Setenv("LC_ALL", "") + t.Setenv("LC_CTYPE", "") + t.Setenv("LANG", c.lang) + if f := fontFor(c.mode); f.bar != c.want && f.dot != c.want { + t.Errorf("LANG=%q, mode %v: %c", c.lang, c.mode, f.bar) + } + } +} + +// The chosen row stays on a part of its column: after the last one is +// deleted, and down an empty column +func TestTheChosenRowStaysInItsColumn(t *testing.T) { + a := mono(pipeline(t, nil, []string{"null", "null"})) + a.col, a.row[sinksCol] = sinksCol, 1 + press(a, "d") + if screen := strings.Join(text(render(a, 80, 24), 80), "\n"); !strings.Contains(screen, string(a.font.barOn)+" null") { + t.Fatalf("after d, the sink left is not chosen:\n%s", screen) + } + a.col = sourcesCol + press(a, "j", "j") + if screen := strings.Join(text(render(a, 80, 24), 80), "\n"); !strings.Contains(screen, string(a.th.Glyphs.Pointer)+" a adds a source") { + t.Fatalf("down an empty column, its slot is not chosen:\n%s", screen) + } +} + +// A font draws only what its terminal shows: ASCII alone outside UTF-8, no +// ellipsis on a text console, though the library truncates with one +func TestAFontDrawsOnlyWhatItsTerminalShows(t *testing.T) { + for _, c := range []struct { + f font + ellipsis, past bool + }{{unicodeFont, true, true}, {cp437Font, false, true}, {asciiFont, false, false}} { + comp := pipeline(t, []string{"null"}, []string{"null"}) + comp.Pipelines[0].Name = strings.Repeat("é", 100) + var drawn []rune + for _, cell := range render(newApp(comp, looks[terminal.ColorModeNone], c.f), 80, 24) { + drawn = append(drawn, cell.Rune) + } + ellipsis, past := slices.Contains(drawn, '…'), slices.ContainsFunc(drawn, func(r rune) bool { return r > 0x7f }) + if ellipsis != c.ellipsis || past != c.past { + t.Errorf("%c: ellipsis %v, past ASCII %v", c.f.bar, ellipsis, past) + } + } +} + +// A preset form scrolls to its focused field on a short screen +func TestPresetFormShowsTheFocusedField(t *testing.T) { + presets := config.Presets() + p := slices.MaxFunc(presets, func(x, y config.Preset) int { return len(x.Params) - len(y.Params) }) + a := mono(pipeline(t, []string{"null"}, []string{"null"})) + a.dialog = newPresetForm(p) + press(a, terminal.KeyBacktab) + last := p.Params[len(p.Params)-1].Name + if screen := strings.Join(text(render(a, 80, 12), 80), "\n"); !strings.Contains(screen, last) { + t.Fatalf("%s: focused %s not shown:\n%s", p.Name, last, screen) + } +} + +// A dialog's text wraps to a narrow screen +func TestDialogsWrapToTheScreen(t *testing.T) { + a := mono(pipeline(t, []string{"null"}, []string{"null"})) + a.changed = true + for _, open := range []any{"q", "?"} { + a.dialog = nil + press(a, open) + if screen := strings.Join(text(render(a, 40, 30), 40), " "); !strings.Contains(screen, "pipelines?") && open == "q" || + !strings.Contains(screen, "environment") && open == "?" { + t.Errorf("%s at 40 columns:\n%s", open, strings.Join(text(render(a, 40, 30), 40), "\n")) + } + } +}