v0.31.0 refinements

This commit is contained in:
lixen committed 2026-10-10 12:01:54 -04:00
1 parent bfcc15613a
commit 6bd5e12a60
52 files changed
+1948 -683

No files matched your search

+45 -66
View File
@@ -10,7 +10,6 @@ import (
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/terminal"
ui "github.com/lixenwraith/terminal/tui"
)
@@ -92,22 +91,9 @@ func (a *app) layout(w int) layout {
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])
}
a.scroll.SetDimensions(l.h, r.H)
a.scroll.EnsureRange(chosen.y, 2-a.col%2)
r.Window(l.h, &a.scroll, a.th.Text, func(full ui.Region) { a.drawParts(full, l) })
}
func (a *app) drawParts(r ui.Region, l layout) {
@@ -118,7 +104,7 @@ func (a *app) drawParts(r ui.Region, l layout) {
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)}
g := ui.NewWires(r.W, l.h)
for _, col := range []int{sourcesCol, sinksCol} {
nodes := a.nodes(col)
for i, s := range l.nodes[col] {
@@ -130,7 +116,7 @@ func (a *app) drawParts(r ui.Region, l layout) {
a.drawNode(r, s, nodes[i], tints[col], chosen)
}
if l.wide && len(nodes) > 0 {
g.join(l, col)
join(g, l, col)
}
}
if !l.wide {
@@ -139,11 +125,7 @@ func (a *app) drawParts(r ui.Region, l layout) {
}
return
}
for i, arms := range g.arms {
if arms != 0 {
r.TextStyled(i%r.W, i/r.W, string(f.wire[arms]), th.Border)
}
}
r.DrawWires(g, f.Line, th.Border)
if len(a.nodes(sourcesCol)) > 0 {
r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border)
}
@@ -152,30 +134,9 @@ func (a *app) drawParts(r ui.Region, l layout) {
}
}
// 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) {
func join(g ui.Wires, l layout, col int) {
ys := []int{l.entry}
if col == sinksCol {
ys[0] = l.exit
@@ -186,18 +147,18 @@ func (g wires) join(l layout, col int) {
if col == sourcesCol {
trunk := l.box.x - gutter + 1
for _, s := range l.nodes[col] {
g.h(s.y, trunk-1, trunk)
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)
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))
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)
g.H(s.y, trunk, s.x-1)
}
}
@@ -245,14 +206,7 @@ func (a *app) drawEmpty(r ui.Region, s spot, role string, chosen bool) {
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)
}
r.Sub(b.x, b.y, b.w, l.boxH).BoxStyle(f.box, th.Border)
for i, n := range a.nodes(flowCol) {
s := l.nodes[flowCol][i]
chosen := a.col == flowCol && a.row[flowCol] == i
@@ -345,11 +299,14 @@ func show(v any) string {
// 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
// locate reads a problem's part, and its text past the path
func locate(err error) (pi int, n compose.Node, key, text string, ok bool) {
at := problemPath.FindStringSubmatchIndex(err.Error())
if at == nil {
return 0, n, "", err.Error(), false
}
m := problemPath.FindStringSubmatch(err.Error())
text = strings.TrimPrefix(err.Error()[at[1]:], ": ")
pi, _ = strconv.Atoi(m[1])
switch {
case m[2] != "":
@@ -362,7 +319,7 @@ func locate(err error) (pi int, n compose.Node, key string, ok bool) {
}
key = m[7]
}
return pi, n, key, true
return pi, n, key, text, true
}
// faulty reports whether the first problem names this node, or a filter
@@ -371,6 +328,28 @@ func (a *app) faulty(n compose.Node) bool {
if a.problem == nil {
return false
}
pi, m, _, ok := locate(a.problem)
pi, m, _, _, ok := locate(a.problem)
return ok && pi == a.pi && m.Role == n.Role && m.ID == n.ID
}
// where names a problem's part as the screen shows it: its pipeline when
// another is shown, the part, and its key
func (a *app) where(pi int, n compose.Node, key string) string {
var out []string
if pi != a.pi && pi < len(a.comp.Pipelines) {
out = append(out, "pipeline "+a.comp.Pipelines[pi].Name)
}
switch n.Role {
case "":
case "source", "sink":
out = append(out, n.Role+" "+n.ID)
case "filters":
out = append(out, fmt.Sprintf("filter %d", n.Index+1))
default:
out = append(out, n.Role)
}
if key != "" {
out = append(out, key)
}
return strings.Join(out, " · ")
}
+255 -128
View File
@@ -1,6 +1,7 @@
package tui
import (
"fmt"
"strings"
"github.com/lixenwraith/logwisp/internal/compose"
@@ -16,23 +17,19 @@ type dialog interface {
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
// frame draws a dialog's box around an inside w by h, centered in r and
// clipped to it, with its title, and returns the inside; sized to what it
// holds, it ends on its last line
func (a *app) frame(r ui.Region, w, h int, title string) ui.Region {
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)
return ui.Center(r, min(w+4, r.W), min(h+2, r.H)).Frame(title, a.th)
}
// inside is a dialog's widest inside on a screen w wide; one narrower than
// stacked puts labels and keys above what they name
func inside(w int) int { return max(1, min(72, w-4)) }
const stacked = 32
// chooser picks one option from a filtered list
type chooser struct {
title string
@@ -43,8 +40,8 @@ type chooser struct {
}
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)
w := inside(r.W)
a.frame(r, w, c.list.Rows(w), c.title).OptionList(c.list, a.th)
}
func (c *chooser) handle(a *app, ev terminal.Event) {
@@ -64,6 +61,11 @@ func (c *chooser) handle(a *app, ev terminal.Event) {
a.dialog = nil
c.pick(a, i)
}
case ev.Key == terminal.KeyRune && c.list.Menu:
if i, ok := c.list.Pick(ev.Rune); ok {
a.dialog = nil
c.pick(a, i)
}
default:
c.list.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
}
@@ -95,56 +97,104 @@ func (a *app) addMenu() dialog {
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
}
}
a.inspectFirst(n)
}}
}
// 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()
// typeMenu changes a source's or sink's type, in its place
func (a *app) typeMenu(n compose.Node) dialog {
p, _ := a.plugin(n)
var options []ui.Option
for _, p := range presets {
options = append(options, ui.Option{Name: p.Name, Hint: p.Summary})
for _, q := range config.Plugins() {
if q.Role == n.Role && q.Type != p.Type {
options = append(options, ui.Option{Name: q.Type, Hint: q.Summary})
}
}
options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"})
return &chooser{title: "Change " + p.Type, list: ui.NewOptionListState(options), pick: func(a *app, i int) {
m, err := a.comp.Retype(a.pi, n, options[i].Name)
if a.edit(err) {
a.choose(m)
a.inspectFirst(m)
}
}}
}
// enumMenu sets an enum key to the option chosen, the cursor on its value
func (a *app) enumMenu(l line, cur any) dialog {
var options []ui.Option
for _, e := range l.key.Enum {
hint := ""
if e == l.key.Default {
hint = "the default"
}
options = append(options, ui.Option{Name: e, Hint: hint})
}
list := ui.NewOptionListState(options)
list.Cursor = max(0, choice(l.key, cur))
return &chooser{title: label(l), list: list, pick: func(a *app, i int) {
a.edit(a.comp.Set(a.pi, l.node, l.key.Name, options[i].Name))
}}
}
// presetMenu replaces the pipeline shown from a preset, or the pipelines
// from a pasted command line; on a bare start, Esc starts empty
func (a *app) presetMenu() dialog {
title := "Start from a preset, or paste an lw command line"
if len(a.comp.Pipelines) > 0 {
title = "Replace pipeline " + a.pipeline().Name + " with a preset"
}
empty := func(a *app) {
a.use(config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}})
}
menu := &chooser{title: title, list: ui.NewOptionListState(options)}
menu := a.pipelineMenu(title, (*app).use)
menu.paste = func(a *app, text string) {
a.dialog = nil
if a.paste(text); len(a.comp.Pipelines) == 0 {
a.dialog = menu // refused: the status says why
}
}
menu.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)
a.use(empty())
}
}
return menu
}
// pipelineMenu offers the presets and an empty pipeline, which place puts
// on screen
func (a *app) pipelineMenu(title string, place func(a *app, p config.PipelineConfig)) *chooser {
presets := config.Presets()
var options []ui.Option
for _, p := range presets {
options = append(options, ui.Option{Name: p.Name, Hint: p.Summary})
}
options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"})
menu := &chooser{title: title, list: ui.NewOptionListState(options)}
menu.pick = func(a *app, i int) {
if i == len(presets) {
place(a, empty())
return
}
p := presets[i]
f := &form{title: "preset " + p.Name, summary: p.Summary, params: p.Params, back: func(a *app) { a.dialog = menu },
apply: func(a *app, values map[string]string) error {
c, err := compose.FromPreset(p.Name, values, config.HostIsDir)
if err == nil {
place(a, c.Pipelines[0])
}
return err
}}
if f.fill(nil); len(p.Params) == 0 {
f.submit(a)
return
}
a.dialog = f
}
return menu
}
func empty() config.PipelineConfig {
return config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}}
}
// use puts a pipeline in place of the one shown, under its name, asking
// first when that one has sources or sinks; the first pipeline keeps its own,
// and its parts are work quit asks about
@@ -167,51 +217,102 @@ func (a *app) use(p config.PipelineConfig) {
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
// append adds a pipeline after the others, under its name or the first free
// one after it, and shows it
func (a *app) append(p config.PipelineConfig) {
var names []string
for _, q := range a.comp.Pipelines {
names = append(names, q.Name)
}
p.Name = config.FreeID(p.Name, names)
a.comp.Pipelines = append(a.comp.Pipelines, p)
a.pi, a.row, a.bar, a.changed = len(a.comp.Pipelines)-1, [3]int{}, false, true
a.check()
}
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
// form takes a few keys, their defaults as placeholders, then applies them;
// a refusal stays in the form
type form struct {
title, summary string
params []config.PresetParam
fields []*ui.TextFieldState
focus ui.Focus
scroll ui.ViewportScroll // the fields' rows
err string
apply func(a *app, values map[string]string) error
back func(a *app) // on Esc, after the form closes
}
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)
// fill gives the fields their first values
func (f *form) fill(values map[string]string) {
f.focus, f.fields = ui.Focus{Len: len(f.params)}, nil
for _, p := range f.params {
f.fields = append(f.fields, ui.NewTextFieldState(values[p.Name]))
}
note, style := f.preset.Params[f.focus.Index].Help, th.Muted
}
// draw lays the form out at the screen's width: the summary, the fields,
// their labels beside them or, when stacked, above, then the focused
// field's help or the refusal, in rows kept for the longest of them
func (f *form) draw(a *app, r ui.Region) {
th, w := a.th, inside(r.W)
labelW, tall := 0, 2
if w >= stacked {
for _, p := range f.params {
labelW = max(labelW, ui.RuneLen(p.Name)+2)
}
tall = 1
}
summary, noteRows := wrap(f.summary, w, 2), len(wrap(f.err, w, 3))
for _, p := range f.params {
noteRows = max(noteRows, len(wrap(p.Help, w, 3)))
}
top, bottom := len(summary)+min(1, len(summary)), noteRows+min(1, noteRows)
in := a.frame(r, w, top+len(f.fields)*tall+bottom, f.title)
for i, l := range summary {
in.TextStyled(0, i, l, th.Muted)
}
view := in.Sub(0, top, in.W, max(1, in.H-top-bottom))
f.scroll.SetDimensions(len(f.fields)*tall, view.H)
f.scroll.EnsureRange(f.focus.Index*tall, tall)
view.Window(len(f.fields)*tall, &f.scroll, th.Text, func(all ui.Region) {
for i, p := range f.params {
v, _ := all.Field(i*tall, labelW, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th)
v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th)
}
})
note, style := f.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)
lines := wrap(note, in.W, noteRows) // on the last rows, the box ending on text
for i, l := range lines {
in.TextStyled(0, in.H-len(lines)+i, l, style)
}
}
func (f *presetForm) handle(a *app, ev terminal.Event) {
// wrap is s wrapped to w, at most n lines, the last cut with '…'; none when
// s is empty
func wrap(s string, w, n int) []string {
if s == "" {
return nil
}
return clip(ui.WrapText(s, w), n)
}
func (f *form) handle(a *app, ev terminal.Event) {
field := f.fields[f.focus.Index]
switch {
case ev.Type == terminal.EventPaste:
field.Paste(ev.Text)
case ev.Type != terminal.EventKey:
case is(ev, terminal.KeyEscape, ""):
a.dialog = a.presetMenu()
a.dialog = nil
if f.back != nil {
f.back(a)
}
case is(ev, terminal.KeyEnter, ""):
f.apply(a)
f.submit(a)
case is(ev, terminal.KeyUp, ""):
f.focus.Index = max(0, f.focus.Index-1)
case is(ev, terminal.KeyDown, ""):
@@ -222,39 +323,33 @@ func (f *presetForm) handle(a *app, ev terminal.Event) {
}
}
// apply builds the preset's pipeline, on this host: a path is a directory
// when one is there
func (f *presetForm) apply(a *app) {
// submit applies the values typed, closing the form unless they are refused
func (f *form) submit(a *app) {
values := map[string]string{}
for i, p := range f.preset.Params {
for i, p := range f.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])
if err := f.apply(a, values); err != nil {
f.err, a.dialog = err.Error(), f
}
}
// outputMenu is what o offers: run, or print one of the forms
func (a *app) outputMenu() dialog {
forms := compose.Forms()
options := []ui.Option{{Name: "run", Hint: "start the pipelines now, with lw's other settings"}}
options := []ui.Option{{Name: "run", Key: 'r', Hint: "start the pipelines now, with lw's other settings"}}
for _, f := range forms {
options = append(options, ui.Option{Name: f.Name, Hint: "print " + f.Hint})
options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint})
}
return &chooser{title: "Output, once the screen closes", list: ui.NewOptionListState(options),
list := ui.NewOptionListState(options)
list.Menu = true
return &chooser{title: "Output, once the screen closes", list: list,
pick: func(a *app, i int) {
if i == 0 {
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
a.run()
return
}
text, err := forms[i-1].Write(a.comp)
@@ -266,88 +361,120 @@ func (a *app) outputMenu() dialog {
}}
}
// notice shows lines until any key
// notice shows text, wrapped to the screen, until a key other than one
// that scrolls it
type notice struct {
title string
lines []string
body func(w int) []string // the lines at a width
view ui.ViewportScroll
}
// textNotice is a notice of plain text, its paragraphs split at '\n'
func textNotice(title, body string) *notice {
return &notice{title: title, body: func(w int) []string { return ui.WrapText(body, w) }}
}
func (n *notice) draw(a *app, r ui.Region) {
w, lines := ui.RuneLen(n.title)+8, fit(n.lines, r.W)
lines, w := n.body(inside(r.W)), ui.RuneLen(n.title)+2
for _, l := range lines {
w = max(w, ui.RuneLen(l)+6)
w = max(w, ui.RuneLen(l))
}
in := a.frame(r, w, len(lines)+4, n.title)
for i, l := range lines {
in.TextStyled(0, 1+i, l, a.th.Text)
in := a.frame(r, min(w, inside(r.W)), len(lines), n.title)
rows := in.H
if len(lines) > in.H { // the last row says where the view is
rows = max(1, in.H-1)
}
}
// 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))...)
n.view.SetDimensions(len(lines), rows)
for y := range min(rows, len(lines)-n.view.Offset) {
in.TextStyled(0, y, lines[n.view.Offset+y], a.th.Text)
}
if rows < in.H {
where := fmt.Sprintf("j k: %d-%d of %d", n.view.Offset+1, n.view.Offset+rows, len(lines))
in.TextStyled(0, rows, ui.Truncate(where, in.W), a.th.Muted)
}
return out
}
func (n *notice) handle(a *app, ev terminal.Event) {
if ev.Type == terminal.EventKey {
switch {
case ev.Type != terminal.EventKey:
case is(ev, terminal.KeyUp, "k"):
n.view.ScrollBy(-1)
case is(ev, terminal.KeyDown, "j"):
n.view.ScrollBy(1)
case is(ev, terminal.KeyPageUp, ""):
n.view.PageUp()
case is(ev, terminal.KeyPageDown, ""):
n.view.PageDown()
default:
a.dialog = nil
}
}
// confirm asks a yes or no question
// confirm asks a yes or no question: y or n answers, as does Enter on the
// answer the arrows chose; Esc is no
type confirm struct {
question string
yes func(a *app)
state ui.ConfirmState
}
func (c *confirm) draw(a *app, r ui.Region) {
lines := fit([]string{c.question}, r.W)
w := 0
lines := ui.WrapText(c.question, inside(r.W))
w := 12 // the answers
for _, l := range lines {
w = max(w, ui.RuneLen(l)+6)
w = max(w, ui.RuneLen(l))
}
in := a.frame(r, w, len(lines)+5, "Confirm")
in := a.frame(r, w, len(lines)+1, "Confirm")
for i, l := range lines {
in.TextStyled(0, 1+i, l, a.th.Text)
in.TextStyled(0, i, l, a.th.Text)
}
in.TextStyled(0, 1+len(lines), "y yes n no", a.th.Muted)
in.Radio(0, in.H-1, []string{"yes", "no"}, map[bool]int{true: 0, false: 1}[c.state.FocusYes], a.th)
}
func (c *confirm) handle(a *app, ev terminal.Event) {
switch {
case is(ev, terminal.KeyNone, "yY"):
a.dialog = nil
if ev.Type != terminal.EventKey || !c.state.HandleKey(ev.Key, ev.Rune) {
return
}
if a.dialog = nil; c.state.Result == ui.ConfirmYes {
c.yes(a)
case is(ev, terminal.KeyEscape, "nNq"):
a.dialog = nil
}
}
// help lists the keys
// help lists the keys, each beside its description or, narrow, above it
func help(f font) dialog {
keys := [][2]string{
{"arrows, hjkl", "move"},
{f.enter, "inspect the chosen part; edit a value"},
{"arrows, hjkl", "move; up from the top row to the pipeline bar"},
{f.enter, "inspect a part; edit a value; open a list; add"},
{"tab", "next value"},
{"esc", "back"},
{"a", "add a source, sink or filter"},
{"a", "add a source, sink or filter; on the bar, a pipeline"},
{"c", "change a source's or sink's type"},
{"d", "delete it; a value back to its default"},
{f.space, "a flow stage or a switch on or off"},
{"J K", "move a filter down or up"},
{"[ ]", "previous or next pipeline"},
{"p", "start over from a preset"},
{"r", "run the pipelines"},
{"o", "run, or print a command line, environment or file"},
{"c", "the first problem"},
{"!", "the first problem"},
{"paste", "an lw command line replaces the pipelines"},
{"q", "quit"},
}
n := &notice{title: "Keys"}
for _, k := range keys {
n.lines = append(n.lines, ui.PadRight(k[0], 14)+k[1])
}
return n
return &notice{title: "Keys", body: func(w int) []string {
var lines []string
for _, k := range keys {
if w < stacked {
lines = append(lines, k[0])
for _, l := range ui.WrapText(k[1], w-2) {
lines = append(lines, " "+l)
}
continue
}
key := k[0]
for _, l := range ui.WrapText(k[1], w-14) {
lines, key = append(lines, ui.PadRight(key, 14)+l), ""
}
}
return lines
}}
}
+58 -37
View File
@@ -21,15 +21,21 @@ type inspector struct {
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
// line is one inspector row: a key, a group that folds, a list's element, or
// the row that adds an element or a filter
type line struct {
node compose.Node
key config.Key // its Name is the dotted path in the node's options
key config.Key // its Name is the dotted path in the node's options, "" on a filter's rows
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
}
// adds reports whether a row adds: a list's last row, or the filters'
func (a *app) adds(l line) bool {
return l.item >= 0 && (l.key.Name == "" || l.item == len(a.items(l.node, l.key.Name)))
}
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)
}
@@ -44,14 +50,15 @@ func (a *app) lines() []line {
return a.keyLines(nil, n, a.keys(n), "", 0)
}
var out []line
for i, f := range a.pipeline().Flow.Filters {
filters := a.pipeline().Flow.Filters
for i, f := range 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
return append(out, line{node: compose.Node{Role: "filters", Index: len(filters)}, item: len(filters)})
}
func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix string, depth int) []line {
@@ -120,31 +127,31 @@ func (a *app) drawInspector(r ui.Region) {
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)
r.Rule(0, title, hint, th)
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
var help []string
style := th.Muted
if a.inspect {
var text string
text, style = a.help(lines[a.insp.cursor])
help = clip(ui.WrapText(text, r.W-4), 2)
}
rows := r.H - 1 - len(help)
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 = ui.AdjustScroll(a.insp.cursor, ui.ClampScroll(a.insp.scroll, rows, len(lines)), rows, len(lines))
if cols == 2 {
a.insp.scroll = 0
}
colW, labelW := r.W/cols, 0
@@ -157,9 +164,8 @@ func (a *app) drawInspector(r ui.Region) {
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)
for i, h := range help {
r.TextStyled(2, r.H-len(help)+i, h, style)
}
}
@@ -177,12 +183,15 @@ func (a *app) nodeTitle(n compose.Node) string {
// 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 pi, n, key, text, ok := locate(a.problem); ok && pi == a.pi && n == l.node && key == l.key.Name {
return text, a.th.Error
}
}
if l.group != "" && l.key.Name == "" {
return "J and K move this filter; d deletes it", a.th.Muted
switch {
case l.group != "" && l.key.Name == "":
return "J and K move this filter; c changes its type; d deletes it", a.th.Muted
case a.adds(l) && l.key.Name == "":
return "adds a filter: include passes only the entries that match, exclude drops them", a.th.Muted
}
help := l.key.Help
if d := placeholder(l.key); d != "" && l.group == "" {
@@ -217,12 +226,12 @@ func (a *app) drawLine(r ui.Region, l line, labelW int, focused bool) {
switch {
case focused && a.insp.field != nil:
v.TextInput(a.insp.field, placeholder(k), true, th)
case l.item == len(items):
case a.adds(l):
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)
v.Radio(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:
@@ -289,7 +298,8 @@ func (a *app) inspectKey(ev terminal.Event) bool {
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, "")
enter, space := is(ev, terminal.KeyEnter, ""), is(ev, terminal.KeySpace, " ")
left, right := is(ev, terminal.KeyLeft, "h"), is(ev, terminal.KeyRight, "l")
switch {
case focus.HandleKey(ev.Key, ev.Modifiers):
a.insp.cursor = focus.Index
@@ -297,31 +307,35 @@ func (a *app) inspectKey(ev terminal.Event) bool {
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")):
case a.adds(l) && k.Name == "" && enter:
a.dialog = a.addMenu()
case l.group != "" && (enter || space || left || right):
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.insp.open[fold] = (enter || space) && !a.insp.open[fold] || right
case l.group != "" && k.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")):
case len(k.Enum) > 0 && l.item < 0 && enter:
a.dialog = a.enumMenu(l, cur)
case len(k.Enum) > 0 && l.item < 0 && (space || left || right):
i := choice(k, cur)
switch next, moved := ui.StepChoice(ev.Key, ev.Rune, i, len(k.Enum)); {
case moved:
i = next
case !toggle:
case !space:
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:
case k.Kind == "bool" && l.item < 0 && (enter || space || left || right):
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, ""):
a.edit(a.comp.Set(a.pi, l.node, k.Name, strconv.FormatBool(right || !left && !on)))
case enter:
text := show(cmp.Or(cur, any("")))
if items := a.items(l.node, k.Name); l.item >= 0 {
text = ""
@@ -337,13 +351,14 @@ func (a *app) inspectKey(ev terminal.Event) bool {
return true
}
// typeKey edits the value being typed: Enter sets it, Tab sets it and moves
// on, Esc drops it
// typeKey edits the value being typed: Enter sets it, Tab and the arrows up
// and down set it and move on, Esc drops it
func (a *app) typeKey(ev terminal.Event, l line) {
up, down := is(ev, terminal.KeyUp, ""), is(ev, terminal.KeyDown, "")
switch {
case is(ev, terminal.KeyEscape, ""):
a.insp.field = nil
case is(ev, terminal.KeyEnter, "") || is(ev, terminal.KeyTab, "") || is(ev, terminal.KeyBacktab, ""):
case is(ev, terminal.KeyEnter, "") || is(ev, terminal.KeyTab, "") || is(ev, terminal.KeyBacktab, "") || up || down:
value := a.insp.field.Value()
if a.commit(l, value) {
a.insp.field = nil
@@ -351,7 +366,12 @@ func (a *app) typeKey(ev terminal.Event, l line) {
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) {
switch {
case up:
a.insp.cursor = max(0, a.insp.cursor-1)
case down:
a.insp.cursor = min(focus.Len-1, a.insp.cursor+1)
case focus.HandleKey(ev.Key, ev.Modifiers):
a.insp.cursor = focus.Index
}
}
@@ -390,6 +410,7 @@ func (a *app) commit(l line, value string) bool {
// value, which returns to its default
func (a *app) clear(l line) {
switch {
case a.adds(l):
case l.group != "" && l.key.Name == "":
a.edit(a.comp.Remove(a.pi, l.node))
case l.item >= 0:
+12 -12
View File
@@ -55,14 +55,14 @@ var looks = map[terminal.ColorMode]look{
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
// font is the marks one character set draws: the widgets' and the canvas's;
// Glyphs.Line draws frames, rules and wires
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
box ui.LineType // the flow box
arrow, down rune // into the flow box and a sink; between stacked parts
bar, barOn rune // a node, and the chosen node
dot rune // before a column's title
valid string
invalid string
enter string
@@ -85,13 +85,13 @@ func (f font) fold(r rune) rune {
// 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",
unicodeFont = font{Glyphs: ui.GlyphsUnicode, box: ui.LineDashed,
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"}
cp437Font = font{Glyphs: ui.GlyphsCP437, box: ui.LineSingle,
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', 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",
asciiFont = font{Glyphs: ui.GlyphsASCII, box: ui.LineASCII,
arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', valid: "ok", invalid: "x", enter: "enter", space: "space",
folds: map[rune]rune{'…': '.', '·': '-'}, ascii: true}
)
+190 -35
View File
@@ -7,6 +7,7 @@ package tui
import (
"errors"
"fmt"
"slices"
"strings"
"github.com/lixenwraith/logwisp/internal/compose"
@@ -85,11 +86,12 @@ type app struct {
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
pi int // the pipeline shown
col int // the chosen column
row [3]int // the chosen node of each column
scroll ui.ViewportScroll // the canvas's rows
bar bool // keys go to the pipeline bar
inspect bool // keys go to the inspector
insp inspector
dialog dialog // takes every key while open
status string // the last edit's refusal
@@ -217,6 +219,8 @@ func (a *app) handle(ev terminal.Event) {
case ev.Type == terminal.EventPaste:
a.paste(ev.Text)
case ev.Type != terminal.EventKey:
case a.bar:
a.barKey(ev)
case !a.inspect:
a.canvasKey(ev)
case !a.inspectKey(ev):
@@ -238,13 +242,17 @@ func (a *app) canvasKey(ev terminal.Event) {
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] == 0:
a.bar = true
case is(ev, terminal.KeyUp, "k"):
a.row[a.col] = max(0, a.row[a.col]-1)
a.row[a.col]--
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.KeyEnter, "") && (!ok || n.Role == "filters" && len(a.pipeline().Flow.Filters) == 0):
a.dialog = a.addMenu() // an empty column, or no filters, as a
case is(ev, terminal.KeyEnter, ""):
a.inspectFirst(n)
case is(ev, terminal.KeyNone, "a"):
a.dialog = a.addMenu()
case is(ev, terminal.KeyNone, "d") && ok && n.Role == "filters":
@@ -269,8 +277,12 @@ func (a *app) globalKey(ev terminal.Event) {
a.dialog = a.outputMenu()
case is(ev, terminal.KeyNone, "?"):
a.dialog = help(a.font)
case is(ev, terminal.KeyNone, "c"):
case is(ev, terminal.KeyNone, "!"):
a.showProblem()
case is(ev, terminal.KeyNone, "c"):
a.changeType()
case is(ev, terminal.KeyNone, "r"):
a.run()
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:
@@ -280,6 +292,76 @@ func (a *app) globalKey(ev terminal.Event) {
}
}
// barKey takes the keys on the pipeline bar: switch, add, rename, delete
func (a *app) barKey(ev terminal.Event) {
n := len(a.comp.Pipelines)
switch p := a.pipeline(); {
case is(ev, terminal.KeyLeft, "h["):
a.pi, a.row = (a.pi+n-1)%n, [3]int{}
case is(ev, terminal.KeyRight, "l]"):
a.pi, a.row = (a.pi+1)%n, [3]int{}
case is(ev, terminal.KeyDown, "j") || is(ev, terminal.KeyEscape, ""):
a.bar = false
case is(ev, terminal.KeyNone, "a"):
a.dialog = a.pipelineMenu("Add a pipeline", (*app).append)
case is(ev, terminal.KeyEnter, ""):
f := &form{title: "Rename pipeline " + p.Name, params: []config.PresetParam{{Name: "name", Required: true, Help: "a name no other pipeline has"}},
apply: func(a *app, v map[string]string) error {
err := a.comp.RenamePipeline(a.pi, v["name"])
if err == nil {
a.edit(nil)
}
return err
}}
f.fill(map[string]string{"name": p.Name})
a.dialog = f
case is(ev, terminal.KeyNone, "d"):
remove := func(a *app) {
a.edit(a.comp.RemovePipeline(a.pi))
a.pi, a.row, a.insp.open = max(0, a.pi-1), [3]int{}, map[string]bool{} // folds are keyed by index
if len(a.comp.Pipelines) == 0 {
a.bar, a.dialog = false, a.presetMenu()
}
}
if len(p.PluginSources)+len(p.PluginSinks) == 0 {
remove(a)
return
}
a.dialog = &confirm{question: "Delete pipeline " + p.Name + "?", yes: remove}
default:
a.globalKey(ev)
}
}
// changeType offers a source or sink the other types of its role; a filter
// or the format changes type on its type row
func (a *app) changeType() {
n, ok := a.node()
if l := a.lines(); a.inspect && ok && len(l) > 0 {
n = l[min(a.insp.cursor, len(l)-1)].node
}
switch {
case !ok || n.Role == "filters" && !a.inspect:
a.note = "c changes the type of a source, sink, filter or format"
case n.Role == "source" || n.Role == "sink":
a.dialog = a.typeMenu(n)
case n.Role == "filters" && n.Index < len(a.pipeline().Flow.Filters), n.Role == "format":
a.choose(n)
a.inspectAt(func(l line) bool { return l.node == n && l.key.Name == "type" })
default:
a.note = "c changes the type of a source, sink, filter or format"
}
}
// run leaves to start the pipelines, once they are valid
func (a *app) run() {
if a.problem != nil {
a.status = "not valid yet: ! shows the problem"
return
}
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
}
// 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) {
@@ -308,7 +390,8 @@ func slicesHasKey(keys []config.Key, name string) bool {
return false
}
// paste starts over from a pasted command line
// paste starts over from a pasted command line, asking first when there are
// edited pipelines to lose
func (a *app) paste(text string) {
c, err := compose.FromCommandLine(text, config.HostIsDir)
if err != nil {
@@ -320,7 +403,7 @@ func (a *app) paste(text string) {
a.note = fmt.Sprintf("pasted %d pipeline(s)", len(c.Pipelines))
a.check()
}
if !a.changed {
if !a.changed || len(a.comp.Pipelines) == 0 {
replace(a)
return
}
@@ -341,18 +424,25 @@ func (a *app) showProblem() {
a.note = "the pipelines are valid"
return
}
if pi, n, key, ok := locate(a.problem); ok && pi < len(a.comp.Pipelines) {
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.inspectAt(func(l line) bool { return l.node == n && l.key.Name == key })
}
a.dialog = &notice{title: "Problem", lines: ui.WrapText(a.problem.Error(), 60)}
a.dialog = textNotice("Problem", a.problem.Error())
}
// inspectFirst opens the inspector on a node's first required key, or on a
// filter's group
func (a *app) inspectFirst(n compose.Node) {
a.inspectAt(func(l line) bool { return l.node == n && (l.key.Required || l.key.Name == "") })
}
// inspectAt opens the inspector, which takes the keys from the bar, on the
// first line at holds for, or on its top
func (a *app) inspectAt(at func(line) bool) {
a.inspect, a.bar = true, false
a.insp.cursor = max(0, slices.IndexFunc(a.lines(), at))
}
// choose puts the canvas cursor on a node, a filter on the filters stage
@@ -374,13 +464,12 @@ func (a *app) choose(n compose.Node) {
// 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)
status, style := a.bottom(r.W)
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)
a.drawBar(r.Sub(0, 0, r.W, 1))
body := r.Sub(0, 1, r.W, r.H-1-len(status))
if r.W >= 110 {
iw := min(60, r.W*2/5)
a.drawCanvas(body.Sub(0, 0, r.W-iw-1, body.H))
@@ -391,12 +480,56 @@ func (a *app) draw(r ui.Region) {
a.drawInspector(body.Sub(0, ch+1, r.W, body.H-ch-1))
}
}
a.drawStatus(r.Sub(0, r.H-1, r.W, 1))
for i, l := range status {
r.TextStyled(1, r.H-len(status)+i, l, style)
}
if a.status == "" && a.note == "" && a.problem == nil {
keys := a.hints(r.W)
r.TextStyled(r.W-ui.RuneLen(keys)-1, r.H-1, keys, a.th.Muted)
}
if a.dialog != nil {
a.dialog.draw(a, r)
}
}
// drawBar draws the title row: the pipelines by name, the one shown marked,
// or it and its place when they do not fit; with the keys, the bar's verbs
func (a *app) drawBar(r ui.Region) {
th, f := a.th, a.font
if a.bar {
r.TextStyled(0, 0, string(f.Focus), th.Accent)
hint := "a add " + f.enter + " rename d delete"
if r.W >= 60 {
r.TextStyled(r.W-ui.RuneLen(hint)-1, 0, hint, th.Muted)
r = r.Sub(0, 0, r.W-ui.RuneLen(hint)-2, 1)
}
}
r.TextStyled(2, 0, "LogWisp", th.Accent)
names, w := a.comp.Pipelines, 11
for _, p := range names {
w += ui.RuneLen(p.Name) + 4
}
if w > r.W {
names = names[a.pi : a.pi+1]
}
x := 11
for _, p := range names {
mark, style := f.Off, th.Muted
if p.Name == a.pipeline().Name {
mark, style = f.On, th.Text
if a.bar {
style = th.Text.On(th.Selected)
}
}
label := ui.Truncate(string(mark)+" "+p.Name, max(0, r.W-x))
r.TextStyled(x, 0, label, style)
x += ui.RuneLen(label) + 2
}
if len(names) < len(a.comp.Pipelines) {
r.TextStyled(x, 0, fmt.Sprintf("%d/%d", a.pi+1, len(a.comp.Pipelines)), th.Muted)
}
}
// 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))
@@ -405,23 +538,45 @@ func (a *app) paint(cells []terminal.Cell, w, h int) {
}
}
// drawStatus shows an edit's refusal, a note, or whether the pipelines are
// valid, and the keys
func (a *app) drawStatus(r ui.Region) {
// bottom is the status rows, wrapped to three at most: an edit's refusal, a
// note, the first problem by its part, or the keys while all is valid
func (a *app) bottom(w int) ([]string, ui.Style) {
th, f := a.th, a.font
text, style := f.valid+" valid", th.Accent
text, style := "", th.Error
switch {
case a.status != "":
text, style = a.status, th.Error
text = a.status
case a.note != "":
text, style = a.note, th.Muted
case a.problem != nil:
text, style = f.invalid+" "+a.problem.Error(), th.Error
pi, n, key, msg, _ := locate(a.problem)
if where := a.where(pi, n, key); where != "" {
msg = where + ": " + msg
}
text = f.invalid + " " + msg
default:
return []string{f.valid + " valid"}, th.Accent
}
keys := "a add d delete " + f.space + " on/off J/K move p preset o output ? help"
if ui.RuneLen(keys)+24 > r.W {
return clip(ui.WrapText(text, w-2), 3), style
}
// hints are the status row's keys, shown while nothing else needs the row
func (a *app) hints(w int) string {
keys := "a add d delete " + a.font.space + " on/off J/K move p preset o output ? help"
if ui.RuneLen(keys)+12 > 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)
return keys
}
// clip keeps n lines, the last cut short when more follow
func clip(lines []string, n int) []string {
if len(lines) > n {
if n < 1 {
return nil
}
lines = lines[:n]
lines[n-1] = ui.Truncate(lines[n-1]+"…", ui.RuneLen(lines[n-1]))
}
return lines
}
+248 -12
View File
@@ -197,8 +197,9 @@ func TestSelectionChangesAGlyph(t *testing.T) {
}
// 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
// or Up moves on; empty or d returns it to its default; arrows step a choice,
// setting nothing past either end, and set a switch; Enter opens a choice's
// options, at its value, and Space flips a switch
func TestInspectorSetsKeys(t *testing.T) {
a := mono(pipeline(t, []string{"file"}, []string{"null"}))
press(a, terminal.KeyEnter)
@@ -221,8 +222,24 @@ func TestInspectorSetsKeys(t *testing.T) {
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 {
if press(a, " ", terminal.KeyLeft, terminal.KeyLeft); opts()["raw"] != false {
t.Fatalf("Left left raw %v", opts()["raw"])
}
press(a, terminal.KeyRight, terminal.KeyRight)
focusKey(t, a, "pattern")
at = a.insp.cursor
if press(a, terminal.KeyEnter, "x", terminal.KeyUp); a.insp.cursor != at-1 || opts()["pattern"] != "x" {
t.Fatalf("Up left the cursor on row %d, pattern %v", a.insp.cursor, opts()["pattern"])
}
focusKey(t, a, "from")
if press(a, terminal.KeyEnter); a.dialog.(*chooser).list.Cursor != 0 {
t.Fatalf("the options open off the value: %+v", a.dialog)
}
if press(a, terminal.KeyDown, terminal.KeyEscape); opts()["from"] != "start" {
t.Fatalf("Esc set %v", opts()["from"])
}
press(a, terminal.KeyEnter, terminal.KeyDown, terminal.KeyEnter)
if o := opts(); o["directory"] != "/srv/a b" || o["check_interval_ms"] != int64(15) || o["from"] != "end" || o["raw"] != true {
t.Fatalf("%v", o)
}
focusKey(t, a, "directory")
@@ -294,8 +311,9 @@ func TestFiltersMoveWithTheirFold(t *testing.T) {
}
}
// 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
// o leaves with the form its letter picks, printed as the engine writes it,
// or with the pipelines to run, as r does; a composition that does not
// validate stays
func TestOutputLeavesWithTheForm(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"})
line, err := c.CommandLine()
@@ -303,7 +321,7 @@ func TestOutputLeavesWithTheForm(t *testing.T) {
t.Fatal(err)
}
a := mono(c)
press(a, "o", "comm", terminal.KeyEnter)
press(a, "o", "c")
if !a.done || a.result.Exit != Print || a.result.Output != line {
t.Fatalf("%+v", a.result)
}
@@ -312,8 +330,13 @@ func TestOutputLeavesWithTheForm(t *testing.T) {
if !a.done || a.result.Exit != Start || len(a.result.Pipelines) != 1 {
t.Fatalf("%+v", a.result)
}
a = mono(pipeline(t, []string{"null"}, []string{"null"}))
press(a, "r")
if !a.done || a.result.Exit != Start || len(a.result.Pipelines) != 1 {
t.Fatalf("r: %+v", a.result)
}
a = mono(pipeline(t, []string{"file"}, []string{"null"}))
press(a, "o", terminal.KeyEnter)
press(a, "o", terminal.KeyEnter, "r")
if a.done || a.status == "" {
t.Fatalf("ran an invalid composition: %+v", a.result)
}
@@ -351,7 +374,8 @@ func TestPresetsStartThePipeline(t *testing.T) {
}
// A pasted command line replaces the pipelines, from the preset menu too,
// asking first once they were edited; one that names none is refused
// asking first once they were edited and while some remain; one that names
// none is refused
func TestPasteReplacesThePipelines(t *testing.T) {
a := mono(&compose.Composition{})
if press(a, []byte("lw\n")); a.status == "" {
@@ -368,22 +392,42 @@ func TestPasteReplacesThePipelines(t *testing.T) {
if a.pipeline().Name != "x" {
t.Fatalf("replaced edited pipelines without asking: %+v", a.comp.Pipelines)
}
a = mono(pipeline(t, []string{"null"}, []string{"null"}))
press(a, "k", "d", "y", []byte("lw --pipeline z --source null --sink null"))
if a.dialog != nil || len(a.comp.Pipelines) != 1 || a.pipeline().Name != "z" {
t.Fatalf("a paste after the last pipeline was deleted: %T %+v", a.dialog, a.comp.Pipelines)
}
}
// The first problem marks the part it names, and c moves there, to the key
// The first problem marks the part it names, and ! 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")
press(a, "!")
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)
}
}
// ! and c on the pipeline bar open the inspector, which then takes the keys:
// Enter edits the key it shows, not the pipeline's name
func TestTheInspectorTakesTheKeysFromTheBar(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"tcp"}))
press(a, "k", "!", terminal.KeyEscape, terminal.KeyEnter)
if a.bar || a.insp.field == nil {
t.Fatalf("Enter after ! on the bar: bar %v, %T", a.bar, a.dialog)
}
a = mono(pipeline(t, []string{"null"}, []string{"null"}))
press(a, "k", "c", terminal.KeyEnter, terminal.KeyEnter)
if a.bar || !a.inspect || a.dialog != nil {
t.Fatalf("Enter after c on the bar: bar %v, %T", a.bar, a.dialog)
}
}
// 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 {
@@ -451,7 +495,9 @@ 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)
f := &form{title: p.Name, params: p.Params}
f.fill(nil)
a.dialog = f
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) {
@@ -459,6 +505,96 @@ func TestPresetFormShowsTheFocusedField(t *testing.T) {
}
}
// presetForm is a preset's form, open on a
func presetForm(a *app, name string) *form {
presets := config.Presets()
p := presets[slices.IndexFunc(presets, func(p config.Preset) bool { return p.Name == name })]
f := &form{title: "preset " + p.Name, summary: p.Summary, params: p.Params}
f.fill(nil)
a.dialog = f
return f
}
// A preset form is usable at 25 columns: labels stack above their values,
// which take the row, so typed text shows whole
func TestPresetFormFitsANarrowScreen(t *testing.T) {
a := mono(pipeline(t, nil, nil))
presetForm(a, "serve")
press(a, terminal.KeyTab, terminal.KeyTab, terminal.KeyTab, "127.0.0.1:9000")
if screen := strings.Join(text(render(a, 25, 20), 25), "\n"); !strings.Contains(screen, "listen") || !strings.Contains(screen, "127.0.0.1:9000") {
t.Fatalf("listen and its value not whole at 25 columns:\n%s", screen)
}
}
// A dialog is sized to what it holds: its first and last rows inside the
// box have text, a form without a summary included
func TestDialogsHaveNoBlankEdgeRows(t *testing.T) {
var a *app
for name, open := range map[string]func(){
"add": func() { press(a, "a") },
"presets": func() { press(a, "p") },
"output": func() { press(a, "o") },
"confirm": func() { press(a, "q") },
"help": func() { press(a, "?") },
"problem": func() { press(a, "!") },
"rename": func() { press(a, terminal.KeyUp, terminal.KeyEnter) },
"form": func() { presetForm(a, "serve") },
} {
a = mono(pipeline(t, nil, nil))
a.changed, a.dialog = true, nil
open()
rows := text(render(a, 80, 40), 80)
bottom := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "└") })
top := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "┌") })
if a.dialog == nil || top < 0 || bottom < top+2 {
t.Errorf("%s: no dialog:\n%s", name, strings.Join(rows, "\n"))
continue
}
edge := []rune(rows[bottom])
left, right := slices.Index(edge, '└'), slices.Index(edge, '┘')
for _, y := range []int{top + 1, bottom - 1} {
if inside := strings.Trim(string([]rune(rows[y])[left:right]), " │"); inside == "" {
t.Errorf("%s: row %d blank:\n%s", name, y, strings.Join(rows[top:bottom+1], "\n"))
}
}
}
}
// A question is answered by y or n, or by Enter on the answer the arrows
// chose; no is chosen first
func TestConfirmAnswersOnEnter(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.changed = true
if press(a, "q", terminal.KeyEnter); a.done || a.dialog != nil {
t.Fatalf("Enter on no: done %v, dialog %T", a.done, a.dialog)
}
if press(a, "q", terminal.KeyLeft, terminal.KeyEnter); !a.done {
t.Fatal("Enter on yes did not quit")
}
}
// The canvas scrolls to keep the chosen part in view, its bar solid
func TestCanvasKeepsTheChosenInView(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, slices.Repeat([]string{"null"}, 8)))
a.col, a.row[sinksCol] = sinksCol, 7
if screen := strings.Join(text(render(a, 80, 24), 80), "\n"); !strings.ContainsRune(screen, a.font.barOn) {
t.Fatalf("the eighth sink is out of view:\n%s", screen)
}
}
// A notice taller than the screen scrolls with j and k and says where it is
func TestNoticeScrolls(t *testing.T) {
a := mono(pipeline(t, nil, nil))
press(a, "?")
screen := func() string { return strings.Join(text(render(a, 25, 20), 25), "\n") }
if s := screen(); strings.Contains(s, "quit") || !strings.Contains(s, "j k: 1-") {
t.Fatalf("help at 25 columns:\n%s", s)
}
if press(a, strings.Repeat("j", 60)); a.dialog == nil || !strings.Contains(screen(), "quit") {
t.Fatalf("scrolled to the end:\n%s", screen())
}
}
// A dialog's text wraps to a narrow screen
func TestDialogsWrapToTheScreen(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
@@ -472,3 +608,103 @@ func TestDialogsWrapToTheScreen(t *testing.T) {
}
}
}
// The inspector opens on a part's first required key, after a adds it or
// Enter chooses it
func TestInspectorOpensOnTheFirstRequiredKey(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"tcp_chain"}))
a.col = sinksCol
press(a, terminal.KeyEnter)
at := func() string { return a.lines()[a.insp.cursor].key.Name }
if got := at(); got != "host" {
t.Fatalf("Enter on tcp_chain: %s", got)
}
press(a, terminal.KeyEscape, "a", "http", terminal.KeyEnter)
if got := at(); got != "port" {
t.Fatalf("a adding http: %s", got)
}
}
// The status shows the first problem whole, by its part and key, not by its
// path, on as many rows as it takes
func TestTheProblemShowsWhole(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"http"}))
sink := compose.Node{Role: "sink", ID: "http"}
for key, value := range map[string]string{"port": "8080", "viewer_page": "true"} {
if err := a.comp.Set(0, sink, key, value); err != nil {
t.Fatal(err)
}
}
a.check()
for _, w := range []int{60, 87, 146} {
screen := strings.Join(text(render(a, w, 30), w), "\n")
if !strings.Contains(screen, "sink http · viewer_page: only") || !strings.Contains(screen, "at /") || strings.Contains(screen, "pipelines[0]") {
t.Errorf("%d columns:\n%s", w, screen)
}
}
}
// Enter on a row that adds acts as a does: an empty column's slot, the
// filters stage with none, the inspector's last filter row
func TestEnterOnAnAddRowAdds(t *testing.T) {
a := mono(pipeline(t, nil, []string{"null"}))
if press(a, terminal.KeyEnter); a.dialog == nil {
t.Fatal("Enter on the empty sources column")
}
press(a, "null", terminal.KeyEnter, terminal.KeyEscape, terminal.KeyRight, terminal.KeyDown, terminal.KeyEnter, "exc", terminal.KeyEnter)
if f := a.pipeline().Flow.Filters; len(f) != 1 || f[0].Type != "exclude" {
t.Fatalf("Enter on filters: %+v", f)
}
lines := a.lines()
a.insp.cursor = len(lines) - 1
press(a, terminal.KeyEnter, terminal.KeyEnter)
if f := a.pipeline().Flow.Filters; len(f) != 2 {
t.Fatalf("Enter on the add row: %+v", f)
}
}
// c changes a source's or sink's type in its place and opens it; on a filter
// or the format it goes to the type row
func TestChangeTypeKeepsThePlace(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"null", "file"}))
a.col = sinksCol
press(a, "c", "tcp", terminal.KeyEnter)
sinks := a.pipeline().PluginSinks
if sinks[0].Type != "tcp" || sinks[0].ID != "tcp" || sinks[1].Type != "file" || !a.inspect || a.lines()[a.insp.cursor].key.Name != "port" {
t.Fatalf("%+v, cursor on %+v", sinks, a.lines()[a.insp.cursor])
}
press(a, terminal.KeyEscape)
a.choose(compose.Node{Role: "format"})
if press(a, "c"); !a.inspect || a.lines()[a.insp.cursor].key.Name != "type" {
t.Fatalf("c on format: %+v", a.lines()[a.insp.cursor])
}
}
// The pipeline bar, above the parts, switches pipelines, adds one under a free
// name, renames one under a name no other has, and deletes one, asking first
// when it has parts
func TestThePipelineBarAddsRenamesDeletes(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
press(a, "k", "a", "empty", terminal.KeyEnter, "k", "a", "empty", terminal.KeyEnter)
names := func() (out []string) {
for _, p := range a.comp.Pipelines {
out = append(out, p.Name)
}
return out
}
if got := names(); !slices.Equal(got, []string{"p", "default", "default_2"}) || a.pi != 2 {
t.Fatalf("added %q, showing %d", got, a.pi)
}
press(a, "k", terminal.KeyEnter, terminal.KeyCtrlU, "p", terminal.KeyEnter)
if _, open := a.dialog.(*form); !open {
t.Fatalf("renamed to a name another has: %q", names())
}
press(a, terminal.KeyCtrlU, "x", terminal.KeyEnter, "h", "h", "d")
if got := names(); !slices.Equal(got, []string{"p", "default", "x"}) || a.dialog == nil {
t.Fatalf("deleted without asking: %q", got)
}
press(a, "y", "l", "d")
if got := names(); !slices.Equal(got, []string{"default"}) || a.dialog != nil {
t.Fatalf("%q %T", got, a.dialog)
}
}