v0.30.0 lw --tui, compose pipeline on screen

This commit is contained in:
lixen committed 2026-10-09 19:30:50 -04:00
1 parent 2d72772943
commit 883af0fcaa
20 files changed
+2442 -69

No files matched your search

+2 -2
View File
@@ -52,13 +52,13 @@ var shorts = map[string]struct {
top bool top bool
}{ }{
"c": {"config", true}, "h": {"help", true}, "p": {"preset", true}, "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}, "u": {"user", false},
} }
// switches are lw's own options that take no value and are no setting, so no // switches are lw's own options that take no value and are no setting, so no
// file or environment variable turns one on // 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 // usageError is a command-line mistake: exit status 2 rather than 1
type usageError string type usageError string
+14 -1
View File
@@ -39,6 +39,7 @@ func TestCommandLineGrammar(t *testing.T) {
{[]string{"--quiet", "-h"}, invocation{help: true, load: config.Args{Overrides: []string{"--quiet"}}}}, {[]string{"--quiet", "-h"}, invocation{help: true, load: config.Args{Overrides: []string{"--quiet"}}}},
{[]string{"-c", "-h"}, invocation{help: true}}, {[]string{"-c", "-h"}, invocation{help: true}},
{[]string{"help"}, 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{"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"}, {[]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}, 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"}, for _, argv := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"},
{"-v"}, {"-qt"}, {"-u", "x"}, {"-config", "x"}, {"--logging.level"}, {"--logging.file.retention_hours", "-1"}, {"-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 { if _, err := parseCommandLine(argv); err == nil {
t.Errorf("%q accepted", argv) 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)
}
}
+5
View File
@@ -18,6 +18,8 @@ Usage:
lw -p|--preset SPEC [options] Run a preset's pipeline lw -p|--preset SPEC [options] Run a preset's pipeline
lw -t|--check [options] Build every pipeline and plugin, report, exit lw -t|--check [options] Build every pipeline and plugin, report, exit
lw --dump [options] Print the effective configuration as TOML, 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 help | -h | --help
lw -V | --version | --schema lw -V | --version | --schema
@@ -31,6 +33,9 @@ after '='; short options do not combine. lw takes options only.
else ./logwisp.toml) else ./logwisp.toml)
-t, --check Build every pipeline and plugin, report, exit -t, --check Build every pipeline and plugin, report, exit
--dump Print the effective configuration, 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 -q, --quiet Silence lw's own log and notices; pipeline
output still flows output still flows
--color [WHEN] Level names in color on console sinks: auto --color [WHEN] Level names in color on console sinks: auto
+54 -6
View File
@@ -13,6 +13,7 @@ import (
"github.com/lixenwraith/logwisp/internal/compose" "github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/tui"
"github.com/lixenwraith/logwisp/internal/version" "github.com/lixenwraith/logwisp/internal/version"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
@@ -45,18 +46,19 @@ func main() {
os.Exit(0) os.Exit(0)
} }
if inv.on["tui"] {
if code, exit := composeOnScreen(&inv); exit {
os.Exit(code)
}
}
// --- 1. Initial setup --- // --- 1. Initial setup ---
// Emulates nohup // Emulates nohup
signal.Ignore(syscall.SIGHUP) signal.Ignore(syscall.SIGHUP)
manager, err := config.Load(inv.load) manager, err := config.Load(inv.load)
if err != nil { if err != nil {
if errors.Is(err, config.ErrConfigNotFound) { os.Exit(loadFailed(err))
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)
} }
defer manager.Close() defer manager.Close()
cfg, err := manager.Snapshot() cfg, err := manager.Snapshot()
@@ -216,3 +218,49 @@ func readsStdin(cfg *config.Config) bool {
} }
return false 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
}
+1
View File
@@ -206,4 +206,5 @@ _arguments -S \
'*--sink=:value:_default' \ '*--sink=:value:_default' \
'*--source=:value:_default' \ '*--source=:value:_default' \
'--status_reporter' \ '--status_reporter' \
'(-T --tui)'{-T,--tui} \
'(-V --version)'{-V,--version} '(-V --version)'{-V,--version}
+2 -2
View File
@@ -10,7 +10,7 @@ _lw() {
elif [[ $prev == = ]]; then prev=${COMP_WORDS[i-2]} elif [[ $prev == = ]]; then prev=${COMP_WORDS[i-2]}
fi fi
case ${COMP_WORDS[1]}:$i in 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:2) words='add-user remove-user token stream' ;;
auth:*) auth:*)
case ${COMP_WORDS[2]} in case ${COMP_WORDS[2]} in
@@ -95,7 +95,7 @@ _lw() {
--preset|-p) mapfile -t COMPREPLY < <(compgen -W 'pipe tail serve edge aggregator' -- "$cur"); return ;; --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 ;; --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 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 esac
mapfile -t COMPREPLY < <(compgen -W "$words" -- "$cur") mapfile -t COMPREPLY < <(compgen -W "$words" -- "$cur")
} }
+1
View File
@@ -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 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 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 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 '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' 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'
+38 -1
View File
@@ -6,6 +6,7 @@ lw -c|--config FILE [options]
lw -p|--preset NAME[,KEY=VALUE...] [options] lw -p|--preset NAME[,KEY=VALUE...] [options]
lw -t|--check [options] lw -t|--check [options]
lw --dump [options] lw --dump [options]
lw -T|--tui [options]
lw auth COMMAND [flags] lw auth COMMAND [flags]
lw tls COMMAND [flags] lw tls COMMAND [flags]
lw preset NAME [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 - 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: one-letter short form that stands for it in every command that has it:
- `-c` `--config`, `-p` `--preset`, `-q` `--quiet`, `-t` `--check`, - `-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` - `-u` `--user` in `lw auth` and `lw preset edge`
- A value follows as the next argument or after `=` (`-c FILE`, `-c=FILE`, - A value follows as the next argument or after `=` (`-c FILE`, `-c=FILE`,
`--config FILE`, `--config=FILE`); a value that starts with `-` follows `=`, `--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) exit; see [Usage Patterns](#usage-patterns)
- `--dump`: print the effective configuration, flags, environment and presets - `--dump`: print the effective configuration, flags, environment and presets
resolved into it, as TOML that `-c` reads back, and exit 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 - `--schema`: print every setting, pipeline flag, flow stage, plugin option
and preset as one JSON document (kinds, defaults, rules, help), and exit; and preset as one JSON document (kinds, defaults, rules, help), and exit;
tools build their option lists from it tools build their option lists from it
@@ -430,6 +433,40 @@ kill -HUP $(pidof lw)
kill -USR1 $(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` ## `lw auth`
Manages the credentials files of `auth.type = "scram"` listeners and logs in to Manages the credentials files of `auth.type = "scram"` listeners and logs in to
+20
View File
@@ -20,6 +20,9 @@
.Op Fl \-rate\-limit Ar spec .Op Fl \-rate\-limit Ar spec
.Op Fl \-heartbeat Ar spec .Op Fl \-heartbeat Ar spec
.Nm .Nm
.Fl T | Fl \-tui
.Op Ar options
.Nm
.Cm auth | tls | preset | config .Cm auth | tls | preset | config
.Ar command .Ar command
.Op Ar flags .Op Ar flags
@@ -146,6 +149,23 @@ Print the effective configuration, with the options, environment and
presets resolved into it, as a file presets resolved into it, as a file
.Fl c .Fl c
reads back, and exit. 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 .It Fl \-schema
Print every setting, pipeline flag, flow stage, plugin option and preset as 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 one JSON document, with kinds, defaults, rules and help, and exit; tools build
+5 -55
View File
@@ -109,61 +109,11 @@ Steps:
1. Schema, in `internal/config`: done. 1. Schema, in `internal/config`: done.
2. Engine, `internal/compose`: done. 2. Engine, `internal/compose`: done.
3. `lw --schema`: done. The site generates its catalogue from it. 3. `lw --schema`: done. The site generates its catalogue from it.
4. In `lixenwraith/terminal`, released and pinned before the TUI: 4. `lixenwraith/terminal` v0.2.0: done: 16 and no color, the terminal's
- Full-screen 8- and 16-color output (SGR 30-37, 90-97, 40-47) and the default colors, `/dev/tty`, bracketed paste, themed form controls. Still
default colors 39/49; it emits only 256-color and truecolor today. optional, in `color`: export the 16 xterm reference shades (RGBTo16 maps
- Detection as `inline` does it: 16 colors on text consoles, none under the site's hues badly, so the 16-color tier is chosen by hand).
`NO_COLOR`, refusal on `TERM=dumb`, and an override for LogWisp's `color` 5. `lw --tui`: done ([doc/cli.md](cli.md#lw---tui)).
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.
6. WebAssembly: `cmd/lwconf` (`js && wasm`) exposes schema, preset, parse, 6. WebAssembly: `cmd/lwconf` (`js && wasm`) exposes schema, preset, parse,
validate and emit to the page as JSON strings. `make wasm` builds validate and emit to the page as JSON strings. `make wasm` builds
`bin/lwconf.wasm` with the version stamped, copies the toolchain's `bin/lwconf.wasm` with the version stamped, copies the toolchain's
+48 -2
View File
@@ -94,14 +94,18 @@ func FromCommandLine(line string, isDir config.IsDir) (*Composition, error) {
return c, c.settle() return c, c.settle()
} }
// settle gives every flow stage its defaults and every plugin's options their // settle gives every flow stage its defaults, every plugin's options their
// kinds; the rules are Validate's // kinds and every source and sink an id; the rules are Validate's
func (c *Composition) settle() error { func (c *Composition) settle() error {
for i := range c.Pipelines { for i := range c.Pipelines {
p := &c.Pipelines[i] p := &c.Pipelines[i]
if p.Flow == nil { if p.Flow == nil {
p.Flow = &config.FlowConfig{} 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) { for _, stage := range stages(p.Flow) {
if !stage.IsNil() { if !stage.IsNil() {
config.Fill(stage.Interface()) config.Fill(stage.Interface())
@@ -126,6 +130,23 @@ func (c *Composition) settle() error {
return nil 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 // 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. // pointer, nil while the stage is off. The filters are a list.
func stages(f *config.FlowConfig) map[string]reflect.Value { 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 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 // Validate applies every rule lw applies at load, naming the refused key's path
func (c *Composition) Validate() error { return config.ValidatePipelines(c.Pipelines) } func (c *Composition) Validate() error { return config.ValidatePipelines(c.Pipelines) }
+42
View File
@@ -7,6 +7,8 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"github.com/lixenwraith/logwisp/internal/config"
) )
// The engine builds as WebAssembly for the website: it links no TLS, HTTP, // 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) 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)
}
}
+7
View File
@@ -94,6 +94,13 @@ func Load(args Args) (*Manager, error) {
return m, nil 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. // 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. // Builder validators only run during Load, so every reload needs this check too.
func (m *Manager) Snapshot() (*Config, error) { func (m *Manager) Snapshot() (*Config, error) {
+28
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"slices"
"strings" "strings"
"testing" "testing"
"time" "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 // 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 // pattern such as password=\S{8,64} would become two that match nothing
func TestFileStringIsOneListEntry(t *testing.T) { func TestFileStringIsOneListEntry(t *testing.T) {
+376
View File
@@ -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
}
+366
View File
@@ -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 := &notice{title: "Keys"}
for _, k := range keys {
n.lines = append(n.lines, ui.PadRight(k[0], 14)+k[1])
}
return n
}
+415
View File
@@ -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
}
}
}
+114
View File
@@ -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
}
+430
View File
@@ -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 = &notice{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)
}
+474
View File
@@ -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"))
}
}
}