v0.30.0 lw --tui, compose pipeline on screen
This commit is contained in:
1 parent
2d72772943
commit
883af0fcaa
20 files changed
+2442
-69
No files matched your search
+2
-2
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/logwisp/internal/compose"
|
||||||
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
ui "github.com/lixenwraith/terminal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dialog is a box over the screen that takes every event while open
|
||||||
|
type dialog interface {
|
||||||
|
draw(a *app, r ui.Region)
|
||||||
|
handle(a *app, ev terminal.Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// frame draws a dialog's box, w by h at most, centered in r with its title,
|
||||||
|
// and returns the inside
|
||||||
|
func (a *app) frame(r ui.Region, w, h int, title string) ui.Region {
|
||||||
|
b := ui.Center(r, min(w, r.W), min(h, r.H))
|
||||||
|
b.FillStyle(a.th.Text)
|
||||||
|
wire := a.font.wire
|
||||||
|
across := strings.Repeat(string(wire[10]), max(0, b.W-2))
|
||||||
|
b.TextStyled(0, 0, string(wire[6])+across+string(wire[12]), a.th.Border)
|
||||||
|
b.TextStyled(0, b.H-1, string(wire[3])+across+string(wire[9]), a.th.Border)
|
||||||
|
for y := 1; y < b.H-1; y++ {
|
||||||
|
b.TextStyled(0, y, string(wire[5]), a.th.Border)
|
||||||
|
b.TextStyled(b.W-1, y, string(wire[5]), a.th.Border)
|
||||||
|
}
|
||||||
|
b.TextStyled(2, 0, " "+ui.Truncate(title, b.W-6)+" ", a.th.Accent)
|
||||||
|
return b.Sub(2, 1, b.W-4, b.H-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chooser picks one option from a filtered list
|
||||||
|
type chooser struct {
|
||||||
|
title string
|
||||||
|
list *ui.OptionListState
|
||||||
|
pick func(a *app, i int) // the option chosen, by its index in the list
|
||||||
|
back func(a *app) // on Esc, after the chooser closes
|
||||||
|
paste func(a *app, text string) // a paste, in place of filtering; nil filters
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chooser) draw(a *app, r ui.Region) {
|
||||||
|
in := a.frame(r, 76, len(c.list.Options)+4, c.title)
|
||||||
|
in.OptionList(c.list, a.th)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chooser) handle(a *app, ev terminal.Event) {
|
||||||
|
switch {
|
||||||
|
case ev.Type == terminal.EventPaste && c.paste != nil:
|
||||||
|
c.paste(a, ev.Text)
|
||||||
|
case ev.Type == terminal.EventPaste:
|
||||||
|
c.list.Paste(ev.Text)
|
||||||
|
case ev.Type != terminal.EventKey:
|
||||||
|
case is(ev, terminal.KeyEscape, ""):
|
||||||
|
a.dialog = nil
|
||||||
|
if c.back != nil {
|
||||||
|
c.back(a)
|
||||||
|
}
|
||||||
|
case is(ev, terminal.KeyEnter, ""):
|
||||||
|
if i, ok := c.list.Chosen(); ok {
|
||||||
|
a.dialog = nil
|
||||||
|
c.pick(a, i)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
c.list.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterHints describe the filter types the flow column adds
|
||||||
|
var filterHints = map[string]string{"include": "pass only entries that match", "exclude": "drop entries that match"}
|
||||||
|
|
||||||
|
// addMenu adds to the chosen column: a source or sink from the catalogue, or
|
||||||
|
// a filter, which opens it in the inspector
|
||||||
|
func (a *app) addMenu() dialog {
|
||||||
|
role := columns[a.col].role
|
||||||
|
var options []ui.Option
|
||||||
|
if role == "flow" {
|
||||||
|
role = "filters"
|
||||||
|
for _, typ := range []string{"include", "exclude"} {
|
||||||
|
options = append(options, ui.Option{Name: typ, Hint: filterHints[typ]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range config.Plugins() {
|
||||||
|
if p.Role == role {
|
||||||
|
options = append(options, ui.Option{Name: p.Type, Hint: p.Summary})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &chooser{title: "Add a " + strings.TrimSuffix(role, "s"), list: ui.NewOptionListState(options),
|
||||||
|
pick: func(a *app, i int) {
|
||||||
|
n, err := a.comp.Add(a.pi, role, options[i].Name)
|
||||||
|
if !a.edit(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.choose(n)
|
||||||
|
a.inspect, a.insp.cursor = true, 0
|
||||||
|
for j, l := range a.lines() {
|
||||||
|
if l.group != "" && l.node == n {
|
||||||
|
a.insp.cursor = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetMenu starts the pipeline over from a preset, or the pipelines from a
|
||||||
|
// pasted command line; on a bare start, Esc starts empty
|
||||||
|
func (a *app) presetMenu() dialog {
|
||||||
|
presets := config.Presets()
|
||||||
|
var options []ui.Option
|
||||||
|
for _, p := range presets {
|
||||||
|
options = append(options, ui.Option{Name: p.Name, Hint: p.Summary})
|
||||||
|
}
|
||||||
|
options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"})
|
||||||
|
title := "Start from a preset, or paste an lw command line"
|
||||||
|
if len(a.comp.Pipelines) > 0 {
|
||||||
|
title = "Replace pipeline " + a.pipeline().Name + " with a preset"
|
||||||
|
}
|
||||||
|
empty := func(a *app) {
|
||||||
|
a.use(config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}})
|
||||||
|
}
|
||||||
|
menu := &chooser{title: title, list: ui.NewOptionListState(options)}
|
||||||
|
menu.paste = func(a *app, text string) {
|
||||||
|
a.dialog = nil
|
||||||
|
if a.paste(text); len(a.comp.Pipelines) == 0 {
|
||||||
|
a.dialog = menu // refused: the status says why
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu.pick = func(a *app, i int) {
|
||||||
|
switch {
|
||||||
|
case i == len(presets):
|
||||||
|
empty(a)
|
||||||
|
case len(presets[i].Params) == 0:
|
||||||
|
(&presetForm{preset: presets[i]}).apply(a)
|
||||||
|
default:
|
||||||
|
a.dialog = newPresetForm(presets[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu.back = func(a *app) {
|
||||||
|
if len(a.comp.Pipelines) == 0 {
|
||||||
|
empty(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
// use puts a pipeline in place of the one shown, under its name, asking
|
||||||
|
// first when that one has sources or sinks; the first pipeline keeps its own,
|
||||||
|
// and its parts are work quit asks about
|
||||||
|
func (a *app) use(p config.PipelineConfig) {
|
||||||
|
if len(a.comp.Pipelines) == 0 {
|
||||||
|
a.comp.Pipelines, a.changed = []config.PipelineConfig{p}, len(p.PluginSources)+len(p.PluginSinks) > 0
|
||||||
|
a.check()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
old := a.pipeline()
|
||||||
|
p.Name = old.Name
|
||||||
|
replace := func(a *app) {
|
||||||
|
a.comp.Pipelines[a.pi], a.row, a.inspect, a.changed = p, [3]int{}, false, true
|
||||||
|
a.check()
|
||||||
|
}
|
||||||
|
if len(old.PluginSources)+len(old.PluginSinks) == 0 {
|
||||||
|
replace(a)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.dialog = &confirm{question: "Replace pipeline " + old.Name + "?", yes: replace}
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetForm takes a preset's keys, its defaults as placeholders
|
||||||
|
type presetForm struct {
|
||||||
|
preset config.Preset
|
||||||
|
fields []*ui.TextFieldState
|
||||||
|
focus ui.Focus
|
||||||
|
scroll int // the first field shown
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPresetForm(p config.Preset) *presetForm {
|
||||||
|
f := &presetForm{preset: p, focus: ui.Focus{Len: len(p.Params)}}
|
||||||
|
for range p.Params {
|
||||||
|
f.fields = append(f.fields, ui.NewTextFieldState(""))
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *presetForm) draw(a *app, r ui.Region) {
|
||||||
|
th := a.th
|
||||||
|
in := a.frame(r, 76, len(f.fields)+6, "preset "+f.preset.Name)
|
||||||
|
in.TextStyled(0, 0, ui.Truncate(f.preset.Summary, in.W), th.Muted)
|
||||||
|
rows := max(1, in.H-3) // between the summary and the note
|
||||||
|
f.scroll = max(0, min(f.scroll, f.focus.Index, len(f.fields)-rows), f.focus.Index-rows+1)
|
||||||
|
for i := f.scroll; i < len(f.fields) && i-f.scroll < rows; i++ {
|
||||||
|
p := f.preset.Params[i]
|
||||||
|
v, _ := in.Field(2+i-f.scroll, 14, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th)
|
||||||
|
v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th)
|
||||||
|
}
|
||||||
|
note, style := f.preset.Params[f.focus.Index].Help, th.Muted
|
||||||
|
if f.err != "" {
|
||||||
|
note, style = f.err, th.Error
|
||||||
|
}
|
||||||
|
in.TextStyled(0, in.H-1, ui.Truncate(note, in.W), style)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *presetForm) handle(a *app, ev terminal.Event) {
|
||||||
|
field := f.fields[f.focus.Index]
|
||||||
|
switch {
|
||||||
|
case ev.Type == terminal.EventPaste:
|
||||||
|
field.Paste(ev.Text)
|
||||||
|
case ev.Type != terminal.EventKey:
|
||||||
|
case is(ev, terminal.KeyEscape, ""):
|
||||||
|
a.dialog = a.presetMenu()
|
||||||
|
case is(ev, terminal.KeyEnter, ""):
|
||||||
|
f.apply(a)
|
||||||
|
case is(ev, terminal.KeyUp, ""):
|
||||||
|
f.focus.Index = max(0, f.focus.Index-1)
|
||||||
|
case is(ev, terminal.KeyDown, ""):
|
||||||
|
f.focus.Index = min(f.focus.Len-1, f.focus.Index+1)
|
||||||
|
case f.focus.HandleKey(ev.Key, ev.Modifiers):
|
||||||
|
default:
|
||||||
|
field.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply builds the preset's pipeline, on this host: a path is a directory
|
||||||
|
// when one is there
|
||||||
|
func (f *presetForm) apply(a *app) {
|
||||||
|
values := map[string]string{}
|
||||||
|
for i, p := range f.preset.Params {
|
||||||
|
if v := f.fields[i].Value(); v != "" {
|
||||||
|
values[p.Name] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c, err := compose.FromPreset(f.preset.Name, values, config.HostIsDir)
|
||||||
|
if err != nil {
|
||||||
|
f.err = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.dialog = nil
|
||||||
|
a.use(c.Pipelines[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputs are what o offers: run, or print one form
|
||||||
|
var outputs = []struct {
|
||||||
|
name, hint string
|
||||||
|
form func(*compose.Composition) (string, error) // nil runs
|
||||||
|
}{
|
||||||
|
{"run", "start the pipelines now, with lw's other settings", nil},
|
||||||
|
{"command line", "print lw's flags", (*compose.Composition).CommandLine},
|
||||||
|
{"environment", "print LOGWISP_ variables: one pipeline", (*compose.Composition).Environment},
|
||||||
|
{"file", "print a configuration file's pipelines", func(c *compose.Composition) (string, error) {
|
||||||
|
data, err := c.File()
|
||||||
|
return string(data), err
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) outputMenu() dialog {
|
||||||
|
var options []ui.Option
|
||||||
|
for _, o := range outputs {
|
||||||
|
options = append(options, ui.Option{Name: o.name, Hint: o.hint})
|
||||||
|
}
|
||||||
|
return &chooser{title: "Output, once the screen closes", list: ui.NewOptionListState(options),
|
||||||
|
pick: func(a *app, i int) {
|
||||||
|
o := outputs[i]
|
||||||
|
if o.form == nil {
|
||||||
|
if a.problem != nil {
|
||||||
|
a.status = "not valid yet: c shows the problem"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, err := o.form(a.comp)
|
||||||
|
if err != nil {
|
||||||
|
a.status = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.result, a.done = Result{Exit: Print, Output: text}, true
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notice shows lines until any key
|
||||||
|
type notice struct {
|
||||||
|
title string
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *notice) draw(a *app, r ui.Region) {
|
||||||
|
w, lines := ui.RuneLen(n.title)+8, fit(n.lines, r.W)
|
||||||
|
for _, l := range lines {
|
||||||
|
w = max(w, ui.RuneLen(l)+6)
|
||||||
|
}
|
||||||
|
in := a.frame(r, w, len(lines)+4, n.title)
|
||||||
|
for i, l := range lines {
|
||||||
|
in.TextStyled(0, 1+i, l, a.th.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fit wraps lines to a dialog's inside on a screen w wide
|
||||||
|
func fit(lines []string, w int) []string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range lines {
|
||||||
|
out = append(out, ui.WrapText(l, max(1, w-6))...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *notice) handle(a *app, ev terminal.Event) {
|
||||||
|
if ev.Type == terminal.EventKey {
|
||||||
|
a.dialog = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirm asks a yes or no question
|
||||||
|
type confirm struct {
|
||||||
|
question string
|
||||||
|
yes func(a *app)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *confirm) draw(a *app, r ui.Region) {
|
||||||
|
lines := fit([]string{c.question}, r.W)
|
||||||
|
w := 0
|
||||||
|
for _, l := range lines {
|
||||||
|
w = max(w, ui.RuneLen(l)+6)
|
||||||
|
}
|
||||||
|
in := a.frame(r, w, len(lines)+5, "Confirm")
|
||||||
|
for i, l := range lines {
|
||||||
|
in.TextStyled(0, 1+i, l, a.th.Text)
|
||||||
|
}
|
||||||
|
in.TextStyled(0, 1+len(lines), "y yes n no", a.th.Muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *confirm) handle(a *app, ev terminal.Event) {
|
||||||
|
switch {
|
||||||
|
case is(ev, terminal.KeyNone, "yY"):
|
||||||
|
a.dialog = nil
|
||||||
|
c.yes(a)
|
||||||
|
case is(ev, terminal.KeyEscape, "nNq"):
|
||||||
|
a.dialog = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// help lists the keys
|
||||||
|
func help(f font) dialog {
|
||||||
|
keys := [][2]string{
|
||||||
|
{"arrows, hjkl", "move"},
|
||||||
|
{f.enter, "inspect the chosen part; edit a value"},
|
||||||
|
{"tab", "next value"},
|
||||||
|
{"esc", "back"},
|
||||||
|
{"a", "add a source, sink or filter"},
|
||||||
|
{"d", "delete it; a value back to its default"},
|
||||||
|
{f.space, "a flow stage or a switch on or off"},
|
||||||
|
{"J K", "move a filter down or up"},
|
||||||
|
{"[ ]", "previous or next pipeline"},
|
||||||
|
{"p", "start over from a preset"},
|
||||||
|
{"o", "run, or print a command line, environment or file"},
|
||||||
|
{"c", "the first problem"},
|
||||||
|
{"paste", "an lw command line replaces the pipelines"},
|
||||||
|
{"q", "quit"},
|
||||||
|
}
|
||||||
|
n := ¬ice{title: "Keys"}
|
||||||
|
for _, k := range keys {
|
||||||
|
n.lines = append(n.lines, ui.PadRight(k[0], 14)+k[1])
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
// Package tui is lw --tui: pipelines composed on a full-screen canvas drawn as
|
||||||
|
// the website draws them, then run or written as a command line, environment
|
||||||
|
// or file. The pipelines are a compose.Composition; this package only draws
|
||||||
|
// them and turns keys into the engine's edits.
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/logwisp/internal/compose"
|
||||||
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
ui "github.com/lixenwraith/terminal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exit is how the user left
|
||||||
|
type Exit int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Quit Exit = iota // nothing to do
|
||||||
|
Start // run Pipelines
|
||||||
|
Print // print Output
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is the user's choice on leaving
|
||||||
|
type Result struct {
|
||||||
|
Exit Exit
|
||||||
|
Pipelines []config.PipelineConfig
|
||||||
|
Output string
|
||||||
|
}
|
||||||
|
|
||||||
|
// modes maps lw's color setting to terminal.New's argument: auto follows the
|
||||||
|
// terminal and NO_COLOR, always the terminal alone
|
||||||
|
var modes = map[string][]terminal.ColorMode{
|
||||||
|
"always": {terminal.DetectColorMode()},
|
||||||
|
"never": {terminal.ColorModeNone},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run composes c on the terminal until the user leaves; an empty c opens the
|
||||||
|
// preset menu. color is lw's color setting.
|
||||||
|
func Run(c *compose.Composition, color string) (Result, error) {
|
||||||
|
t := terminal.New(modes[color]...)
|
||||||
|
if err := t.Init(); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
defer t.Fini()
|
||||||
|
if err := t.SetPasteMode(true); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
a := newApp(c, looks[t.ColorMode()], fontFor(terminal.DetectColorMode()))
|
||||||
|
w, h := t.Size()
|
||||||
|
for !a.done {
|
||||||
|
cells := make([]terminal.Cell, w*h)
|
||||||
|
a.paint(cells, w, h)
|
||||||
|
t.Flush(cells, w, h)
|
||||||
|
switch ev := t.PollEvent(); ev.Type {
|
||||||
|
case terminal.EventResize:
|
||||||
|
w, h = ev.Width, ev.Height
|
||||||
|
case terminal.EventClosed:
|
||||||
|
return Result{}, errors.New("the terminal closed")
|
||||||
|
default:
|
||||||
|
a.handle(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// columns are a pipeline's parts, left to right
|
||||||
|
var columns = []struct{ title, role string }{{"SOURCES", "source"}, {"FLOW", "flow"}, {"SINKS", "sink"}}
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourcesCol = iota
|
||||||
|
flowCol
|
||||||
|
sinksCol
|
||||||
|
)
|
||||||
|
|
||||||
|
// app is the screen's state. draw and handle never touch the terminal, so
|
||||||
|
// tests drive them directly.
|
||||||
|
type app struct {
|
||||||
|
comp *compose.Composition
|
||||||
|
look look
|
||||||
|
th ui.Theme
|
||||||
|
font font
|
||||||
|
tables map[string][]config.Key
|
||||||
|
pi int // the pipeline shown
|
||||||
|
col int // the chosen column
|
||||||
|
row [3]int // the chosen node of each column
|
||||||
|
scroll int // the canvas's first row
|
||||||
|
inspect bool // keys go to the inspector
|
||||||
|
insp inspector
|
||||||
|
dialog dialog // takes every key while open
|
||||||
|
status string // the last edit's refusal
|
||||||
|
note string // what the last action did
|
||||||
|
problem error // the composition's first problem
|
||||||
|
changed bool
|
||||||
|
done bool
|
||||||
|
result Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func newApp(c *compose.Composition, l look, f font) *app {
|
||||||
|
a := &app{comp: c, look: l, th: l.Theme, font: f, tables: compose.NewSchema().Tables}
|
||||||
|
a.th.Glyphs = f.Glyphs
|
||||||
|
a.insp.open = map[string]bool{}
|
||||||
|
if len(c.Pipelines) == 0 {
|
||||||
|
a.dialog = a.presetMenu()
|
||||||
|
}
|
||||||
|
a.check()
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) pipeline() *config.PipelineConfig { return &a.comp.Pipelines[a.pi] }
|
||||||
|
|
||||||
|
// nodes are a column's nodes: sources and sinks by id, the flow's stages by key
|
||||||
|
func (a *app) nodes(col int) []compose.Node {
|
||||||
|
if len(a.comp.Pipelines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []compose.Node
|
||||||
|
p := a.pipeline()
|
||||||
|
switch col {
|
||||||
|
case sourcesCol:
|
||||||
|
for _, s := range p.PluginSources {
|
||||||
|
out = append(out, compose.Node{Role: "source", ID: s.ID})
|
||||||
|
}
|
||||||
|
case flowCol:
|
||||||
|
for _, s := range config.FlowStages() {
|
||||||
|
out = append(out, compose.Node{Role: s.Key})
|
||||||
|
}
|
||||||
|
case sinksCol:
|
||||||
|
for _, s := range p.PluginSinks {
|
||||||
|
out = append(out, compose.Node{Role: "sink", ID: s.ID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// node is the chosen node, false in an empty column
|
||||||
|
func (a *app) node() (compose.Node, bool) {
|
||||||
|
nodes := a.nodes(a.col)
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
a.row[a.col] = 0
|
||||||
|
return compose.Node{}, false
|
||||||
|
}
|
||||||
|
a.row[a.col] = max(0, min(a.row[a.col], len(nodes)-1))
|
||||||
|
return nodes[a.row[a.col]], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// plugin is a source's or sink's catalogue row and options
|
||||||
|
func (a *app) plugin(n compose.Node) (config.Plugin, bool) {
|
||||||
|
p := a.pipeline()
|
||||||
|
typ := ""
|
||||||
|
for _, s := range p.PluginSources {
|
||||||
|
if n.Role == "source" && s.ID == n.ID {
|
||||||
|
typ = s.Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range p.PluginSinks {
|
||||||
|
if n.Role == "sink" && s.ID == n.ID {
|
||||||
|
typ = s.Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config.LookupPlugin(n.Role, typ)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keys are the options a node takes: a plugin's of its side, a stage's
|
||||||
|
func (a *app) keys(n compose.Node) []config.Key {
|
||||||
|
if p, ok := a.plugin(n); ok {
|
||||||
|
return sided(p.Keys(), p.Side)
|
||||||
|
}
|
||||||
|
for _, s := range config.FlowStages() {
|
||||||
|
if s.Key == n.Role {
|
||||||
|
return s.Keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sided drops the keys of the other network side
|
||||||
|
func sided(keys []config.Key, side config.Side) []config.Key {
|
||||||
|
name := map[config.Side]string{config.Listener: "listener", config.ChainListener: "listener", config.Dialer: "dialer"}[side]
|
||||||
|
var out []config.Key
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.Side == "" || k.Side == name {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// edit applies one engine edit: a refusal is the status, a change revalidates
|
||||||
|
func (a *app) edit(err error) bool {
|
||||||
|
if err != nil {
|
||||||
|
a.status = err.Error()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.status, a.note, a.changed = "", "", true
|
||||||
|
a.check()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) check() { a.problem = a.comp.Validate() }
|
||||||
|
|
||||||
|
// handle routes an event: an open dialog takes all, then the inspector, then
|
||||||
|
// the canvas
|
||||||
|
func (a *app) handle(ev terminal.Event) {
|
||||||
|
if ev.Type == terminal.EventKey || ev.Type == terminal.EventPaste {
|
||||||
|
a.status, a.note = "", ""
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case a.dialog != nil:
|
||||||
|
a.dialog.handle(a, ev)
|
||||||
|
case ev.Type == terminal.EventPaste && a.insp.field != nil:
|
||||||
|
a.insp.field.Paste(ev.Text)
|
||||||
|
case ev.Type == terminal.EventPaste:
|
||||||
|
a.paste(ev.Text)
|
||||||
|
case ev.Type != terminal.EventKey:
|
||||||
|
case !a.inspect:
|
||||||
|
a.canvasKey(ev)
|
||||||
|
case !a.inspectKey(ev):
|
||||||
|
a.globalKey(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// is reports whether ev is a key or one of the runes
|
||||||
|
func is(ev terminal.Event, key terminal.Key, runes string) bool {
|
||||||
|
return ev.Key == key && key != terminal.KeyNone || ev.Key == terminal.KeyRune && strings.ContainsRune(runes, ev.Rune)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) canvasKey(ev terminal.Event) {
|
||||||
|
n, ok := a.node()
|
||||||
|
switch {
|
||||||
|
case is(ev, terminal.KeyLeft, "h"):
|
||||||
|
a.col = max(0, a.col-1)
|
||||||
|
case is(ev, terminal.KeyRight, "l"):
|
||||||
|
a.col = min(2, a.col+1)
|
||||||
|
case is(ev, terminal.KeyTab, ""):
|
||||||
|
a.col = (a.col + 1) % 3
|
||||||
|
case is(ev, terminal.KeyUp, "k"):
|
||||||
|
a.row[a.col] = max(0, a.row[a.col]-1)
|
||||||
|
case is(ev, terminal.KeyDown, "j"):
|
||||||
|
a.row[a.col]++
|
||||||
|
a.node()
|
||||||
|
case is(ev, terminal.KeyEnter, "") && ok:
|
||||||
|
a.inspect, a.insp.cursor = true, 0
|
||||||
|
case is(ev, terminal.KeyNone, "a"):
|
||||||
|
a.dialog = a.addMenu()
|
||||||
|
case is(ev, terminal.KeyNone, "d") && ok && n.Role == "filters":
|
||||||
|
a.note = "choose a filter with " + a.font.enter + " to delete it"
|
||||||
|
case is(ev, terminal.KeyNone, "d") && ok:
|
||||||
|
a.edit(a.comp.Remove(a.pi, n))
|
||||||
|
case is(ev, terminal.KeySpace, " ") && ok:
|
||||||
|
a.switchStage(n)
|
||||||
|
case is(ev, terminal.KeyNone, "JK"):
|
||||||
|
a.note = "J and K move a filter: " + a.font.enter + " on filters, then on one"
|
||||||
|
default:
|
||||||
|
a.globalKey(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalKey takes the keys that work outside a text field anywhere
|
||||||
|
func (a *app) globalKey(ev terminal.Event) {
|
||||||
|
switch n := len(a.comp.Pipelines); {
|
||||||
|
case is(ev, terminal.KeyNone, "p"):
|
||||||
|
a.dialog = a.presetMenu()
|
||||||
|
case is(ev, terminal.KeyNone, "o"):
|
||||||
|
a.dialog = a.outputMenu()
|
||||||
|
case is(ev, terminal.KeyNone, "?"):
|
||||||
|
a.dialog = help(a.font)
|
||||||
|
case is(ev, terminal.KeyNone, "c"):
|
||||||
|
a.showProblem()
|
||||||
|
case is(ev, terminal.KeyNone, "]") && n > 0:
|
||||||
|
a.pi, a.row, a.inspect = (a.pi+1)%n, [3]int{}, false
|
||||||
|
case is(ev, terminal.KeyNone, "[") && n > 0:
|
||||||
|
a.pi, a.row, a.inspect = (a.pi+n-1)%n, [3]int{}, false
|
||||||
|
case is(ev, terminal.KeyCtrlC, "q"):
|
||||||
|
a.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// switchStage turns a flow stage on with its defaults, or off; a stage with
|
||||||
|
// an enabled key switches that
|
||||||
|
func (a *app) switchStage(n compose.Node) {
|
||||||
|
if n.Role == "source" || n.Role == "sink" || n.Role == "filters" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opts, on, err := a.comp.Options(a.pi, n)
|
||||||
|
switch _, has := opts["enabled"]; {
|
||||||
|
case err != nil:
|
||||||
|
a.edit(err)
|
||||||
|
case slicesHasKey(a.keys(n), "enabled"):
|
||||||
|
a.edit(a.comp.Set(a.pi, n, "enabled", fmt.Sprint(!on || !has)))
|
||||||
|
case on:
|
||||||
|
a.edit(a.comp.Remove(a.pi, n))
|
||||||
|
default:
|
||||||
|
a.edit(a.comp.Unset(a.pi, n, a.keys(n)[0].Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func slicesHasKey(keys []config.Key, name string) bool {
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.Name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// paste starts over from a pasted command line
|
||||||
|
func (a *app) paste(text string) {
|
||||||
|
c, err := compose.FromCommandLine(text, config.HostIsDir)
|
||||||
|
if err == nil && len(c.Pipelines) == 0 {
|
||||||
|
err = errors.New("no pipeline flags")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.status = "paste: " + err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replace := func(a *app) {
|
||||||
|
a.comp, a.pi, a.row, a.inspect, a.changed = c, 0, [3]int{}, false, true
|
||||||
|
a.note = fmt.Sprintf("pasted %d pipeline(s)", len(c.Pipelines))
|
||||||
|
a.check()
|
||||||
|
}
|
||||||
|
if !a.changed {
|
||||||
|
replace(a)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.dialog = &confirm{question: "Replace the pipelines with the pasted command line?", yes: replace}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) quit() {
|
||||||
|
if !a.changed {
|
||||||
|
a.done = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: func(a *app) { a.done = true }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showProblem chooses the part the first problem names and shows it whole
|
||||||
|
func (a *app) showProblem() {
|
||||||
|
if a.problem == nil {
|
||||||
|
a.note = "the pipelines are valid"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pi, n, key, ok := locate(a.problem); ok && pi < len(a.comp.Pipelines) {
|
||||||
|
a.pi = pi
|
||||||
|
a.choose(n)
|
||||||
|
a.inspect = true
|
||||||
|
a.insp.cursor = 0
|
||||||
|
for i, l := range a.lines() {
|
||||||
|
if l.node == n && l.key.Name == key {
|
||||||
|
a.insp.cursor = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.dialog = ¬ice{title: "Problem", lines: ui.WrapText(a.problem.Error(), 60)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// choose puts the canvas cursor on a node, a filter on the filters stage
|
||||||
|
func (a *app) choose(n compose.Node) {
|
||||||
|
if n.Role == "filters" {
|
||||||
|
a.insp.open[a.foldKey(compose.Node{Role: "filters"}, fmt.Sprint(n.Index))] = true
|
||||||
|
n = compose.Node{Role: "filters"}
|
||||||
|
}
|
||||||
|
for col := range columns {
|
||||||
|
for i, m := range a.nodes(col) {
|
||||||
|
if m == n {
|
||||||
|
a.col, a.row[col] = col, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw draws the screen: a title row, the canvas with the inspector beside
|
||||||
|
// it from 110 columns or below it, a status row, and any dialog over them
|
||||||
|
func (a *app) draw(r ui.Region) {
|
||||||
|
r.FillStyle(a.th.Text)
|
||||||
|
if len(a.comp.Pipelines) > 0 {
|
||||||
|
a.pi = min(a.pi, len(a.comp.Pipelines)-1)
|
||||||
|
a.node() // a delete can leave the row past its column's end
|
||||||
|
p := a.pipeline()
|
||||||
|
title := fmt.Sprintf("LogWisp pipeline %s %d of %d", p.Name, a.pi+1, len(a.comp.Pipelines))
|
||||||
|
r.TextStyled(1, 0, ui.Truncate(title, r.W-2), a.th.Accent)
|
||||||
|
body := r.Sub(0, 1, r.W, r.H-2)
|
||||||
|
if r.W >= 110 {
|
||||||
|
iw := min(60, r.W*2/5)
|
||||||
|
a.drawCanvas(body.Sub(0, 0, r.W-iw-1, body.H))
|
||||||
|
a.drawInspector(body.Sub(r.W-iw, 0, iw, body.H))
|
||||||
|
} else {
|
||||||
|
ch := min(a.layout(r.W).h, body.H-min(10, body.H/2))
|
||||||
|
a.drawCanvas(body.Sub(0, 0, r.W, ch))
|
||||||
|
a.drawInspector(body.Sub(0, ch+1, r.W, body.H-ch-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.drawStatus(r.Sub(0, r.H-1, r.W, 1))
|
||||||
|
if a.dialog != nil {
|
||||||
|
a.dialog.draw(a, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// paint draws the screen into cells, each rune one the font's terminal shows
|
||||||
|
func (a *app) paint(cells []terminal.Cell, w, h int) {
|
||||||
|
a.draw(ui.NewRegion(cells, w, 0, 0, w, h))
|
||||||
|
for i, c := range cells {
|
||||||
|
cells[i].Rune = a.font.fold(c.Rune)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawStatus shows an edit's refusal, a note, or whether the pipelines are
|
||||||
|
// valid, and the keys
|
||||||
|
func (a *app) drawStatus(r ui.Region) {
|
||||||
|
th, f := a.th, a.font
|
||||||
|
text, style := f.valid+" valid", th.Accent
|
||||||
|
switch {
|
||||||
|
case a.status != "":
|
||||||
|
text, style = a.status, th.Error
|
||||||
|
case a.note != "":
|
||||||
|
text, style = a.note, th.Muted
|
||||||
|
case a.problem != nil:
|
||||||
|
text, style = f.invalid+" "+a.problem.Error(), th.Error
|
||||||
|
}
|
||||||
|
keys := "a add d delete " + f.space + " on/off J/K move p preset o output ? help"
|
||||||
|
if ui.RuneLen(keys)+24 > r.W {
|
||||||
|
keys = "? help"
|
||||||
|
}
|
||||||
|
r.TextStyled(1, 0, ui.Truncate(text, r.W-ui.RuneLen(keys)-4), style)
|
||||||
|
r.TextStyled(r.W-ui.RuneLen(keys)-1, 0, keys, th.Muted)
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user