v0.35.0 tui fixes
Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-11 04:11:57 -04:00
1 parent 1abffcec6c
commit f4f8cc0986
24 files changed
+965 -295

No files matched your search

+72 -20
View File
@@ -436,31 +436,15 @@ func (n *notice) draw(a *app, r ui.Region) {
w = max(w, ui.RuneLen(l))
}
in := a.frame(r, min(w, inside(r.W)), len(lines), n.title)
rows := in.H
if len(lines) > in.H { // the last row says where the view is
rows = max(1, in.H-1)
}
n.view.SetDimensions(len(lines), rows)
rows := a.window(in, &n.view, len(lines))
for y := range min(rows, len(lines)-n.view.Offset) {
in.TextStyled(0, y, lines[n.view.Offset+y], a.th.Text)
}
if rows < in.H {
where := fmt.Sprintf("j k: %d-%d of %d", n.view.Offset+1, n.view.Offset+rows, len(lines))
in.TextStyled(0, rows, ui.Truncate(where, in.W), a.th.Muted)
}
}
func (n *notice) handle(a *app, ev terminal.Event) {
switch {
case ev.Type != terminal.EventKey:
case is(ev, terminal.KeyUp, "k"):
n.view.ScrollBy(-1)
case is(ev, terminal.KeyDown, "j"):
n.view.ScrollBy(1)
case is(ev, terminal.KeyPageUp, ""):
n.view.PageUp()
case is(ev, terminal.KeyPageDown, ""):
n.view.PageDown()
case ev.Type != terminal.EventKey, scroll(&n.view, ev):
default:
if a.dialog = nil; n.closed != nil {
n.closed(a)
@@ -468,6 +452,73 @@ func (n *notice) handle(a *app, ev terminal.Event) {
}
}
// window sets v to n lines in r and returns the rows they take: all of r, or
// all but its last, which says where v is when they do not fit
func (a *app) window(r ui.Region, v *ui.ViewportScroll, n int) int {
rows := r.H
if n > r.H {
rows = max(1, r.H-1)
}
v.SetDimensions(n, rows)
if rows < r.H {
where := fmt.Sprintf("j k: %d-%d of %d", v.Offset+1, v.Offset+rows, n)
r.TextStyled(0, rows, ui.Truncate(where, r.W), a.th.Muted)
}
return rows
}
// scroll moves v on a key that scrolls, reporting whether ev was one
func scroll(v *ui.ViewportScroll, ev terminal.Event) bool {
switch {
case is(ev, terminal.KeyUp, "k"):
v.ScrollBy(-1)
case is(ev, terminal.KeyDown, "j"):
v.ScrollBy(1)
case is(ev, terminal.KeyPageUp, ""):
v.PageUp()
case is(ev, terminal.KeyPageDown, ""):
v.PageDown()
case is(ev, terminal.KeyHome, "g"):
v.Home()
case is(ev, terminal.KeyEnd, "G"):
v.End()
default:
return false
}
return true
}
// entry shows a row of the live view over the whole screen, every detail
// line, until Esc, i or q; e edits the stage that dropped it
type entry struct {
row row // a copy, which the ring's evictions leave whole
view ui.ViewportScroll
}
func (e *entry) draw(a *app, r ui.Region) {
r.FillStyle(a.th.Text)
hint := "esc back"
if e.row.dropped() {
hint = "e edit the stage that dropped it " + hint
}
r.Rule(0, "entry", hint, a.th)
body := r.Sub(0, 1, r.W, r.H-1)
lines := a.details(&e.row, r.W-2)
rows := a.window(body.Sub(1, 0, body.W-1, body.H), &e.view, len(lines))
a.drawLines(body.Sub(0, 0, body.W, rows), lines, e.view.Offset)
}
func (e *entry) handle(a *app, ev terminal.Event) {
switch {
case ev.Type != terminal.EventKey, scroll(&e.view, ev):
case is(ev, terminal.KeyNone, "e"):
a.dialog = nil
a.editDrop(&e.row)
case is(ev, terminal.KeyEscape, "iq"), is(ev, terminal.KeyCtrlC, ""):
a.dialog = nil
}
}
// 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 {
@@ -525,11 +576,12 @@ func help(f font, viewing bool) dialog {
{"arrows, jk", "move; up pauses following, down to the newest follows"},
{"g G", "the oldest row; the newest, following; f follows too"},
{f.enter, "the row's detail; J K scroll it"},
{"i", "the row full screen; esc, i or q back"},
{"/", "only the rows matching a pattern; lower case ignores case"},
{"1-5 0", "trace, debug, info, warn or error and up; all"},
{"v", "all that reaches the point, what passed it, what it dropped"},
{"v", "all that reaches the point, the dropped dimmed; what passed it; what it dropped"},
{"s", "look at a source, a flow stage, the sinks or lw's own log"},
{"i", "inspect the stage that dropped the row"},
{"e", "edit the stage that dropped the row"},
{"c", "clear the rows"},
{"t esc", "back to the diagram; the pipelines keep running"},
{"q", "stop the pipelines and quit"},
+87 -50
View File
@@ -3,6 +3,7 @@ package tui
import (
"cmp"
"fmt"
"maps"
"strconv"
"strings"
@@ -28,14 +29,12 @@ type line struct {
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)))
item int // a list element's index, the list's length on the row adding to it; -1 elsewhere
add bool // the row that adds an element or a filter
}
// foldKey names a group's fold; a filter's folds share its prefix, path ""
// its own
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)
}
@@ -54,11 +53,11 @@ func (a *app) lines() []line {
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))] {
if a.insp.open[a.foldKey(fn, "")] {
out = a.keyLines(out, fn, a.keys(n), "", 1)
}
}
return append(out, line{node: compose.Node{Role: "filters", Index: len(filters)}, item: len(filters)})
return append(out, line{node: compose.Node{Role: "filters", Index: len(filters)}, item: -1, add: true})
}
func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix string, depth int) []line {
@@ -76,7 +75,7 @@ func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix str
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})
out = append(out, line{node: n, key: k, depth: depth + 1, item: i, add: i == len(items)})
}
}
default:
@@ -145,23 +144,28 @@ func (a *app) drawInspector(r ui.Region) {
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
cols, perCol, second := 1, rows, 0
if r.W >= 80 {
second = split(lines, rows)
}
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
if second > 0 {
cols, perCol, a.insp.scroll = 2, max(second, len(lines)-second), 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)
for i := a.insp.scroll; i < len(lines); i++ {
x, y := 0, i-a.insp.scroll
if second > 0 && i >= second {
x, y = colW, i-second
}
if y >= rows {
break
}
a.drawLine(r.Sub(x, 1+y, colW-1, 1), lines[i], labelW, a.inspect && i == a.insp.cursor)
}
under := min(r.H-len(help), 2+min(perCol, len(lines)-a.insp.scroll)) // two rows under the last key
for i, h := range help {
@@ -169,6 +173,19 @@ func (a *app) drawInspector(r ui.Region) {
}
}
// split is the first row of a second column, the shorter the taller column
// the better, 0 for one: a column holds rows at most and starts on a top row,
// not inside an unfolded group nor on the row adding to what precedes it
func split(lines []line, rows int) int {
best, tall := 0, rows
for i := 1; i < len(lines); i++ {
if h := max(i, len(lines)-i); lines[i].depth == 0 && !lines[i].add && h <= tall {
best, tall = i, h
}
}
return best
}
func (a *app) nodeTitle(n compose.Node) string {
switch n.Role {
case "source", "sink":
@@ -180,29 +197,34 @@ func (a *app) nodeTitle(n compose.Node) string {
return n.Role + " · flow"
}
// help is the focused row's first problem, or its key's help and default
// help is the focused row's first problem, or its key's help and what
// unset means, or what d or Enter does on a list's row
func (a *app) help(l line) (string, ui.Style) {
for _, p := range compose.Problems(a.problem) {
if pi, n, key, text, ok := compose.Locate(p); ok && pi == a.pi && n == l.node && key == l.key.Name {
return text, a.th.Error
}
}
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 == "" {
switch d := placeholder(l.key); {
case l.group != "" && l.key.Name == "":
help = "J and K move this filter; c changes its type; d deletes it"
case l.add && l.key.Name == "":
help = "adds a filter: include passes only the entries that match, exclude drops them"
case l.add:
help += "; " + a.font.enter + " adds one"
case l.item >= 0:
help += "; d deletes it"
case (l.key.Default != "" || l.key.Zero != "") && l.group == "": // a hint is a kind, not what unset means
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
// label is a row's name: its key's last part, or a list element's number,
// which the row adding one shows while one is typed
func label(l line) string {
if l.item >= 0 {
if l.item >= 0 && l.key.Name != "" {
return strconv.Itoa(l.item + 1)
}
return l.key.Name[strings.LastIndex(l.key.Name, ".")+1:]
@@ -212,22 +234,29 @@ 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)]
typing := focused && a.insp.field != nil
switch {
case l.group != "":
r.Group(0, l.group, a.groupSummary(l), a.insp.open[a.foldKey(l.node, k.Name)], focused, th)
return
case l.add && !typing: // where its list's labels or the filters' titles start, no number
text, style := "+ add", th.Muted
if k.Name == "" {
open = a.insp.open[a.foldKey(compose.Node{Role: "filters"}, strconv.Itoa(l.node.Index))]
text += " filter"
}
r.Group(0, l.group, a.groupSummary(l), open, focused, th)
if focused {
style = th.Text
r.TextStyled(0, 0, string(th.Glyphs.Focus), th.Accent)
}
r.TextStyled(2, 0, text, style)
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:
case typing:
v.TextInput(a.insp.field, placeholder(k), true, th)
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:
@@ -307,13 +336,10 @@ 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 a.adds(l) && k.Name == "" && enter:
case l.add && 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] = (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])
@@ -337,10 +363,10 @@ func (a *app) inspectKey(ev terminal.Event) bool {
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 {
if l.item >= 0 {
text = ""
if l.item < len(items) {
text = items[l.item]
if !l.add {
text = a.items(l.node, k.Name)[l.item]
}
}
a.insp.field = ui.NewTextFieldState(text)
@@ -362,7 +388,7 @@ func (a *app) typeKey(ev terminal.Event, l line) {
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 != "" {
if l.add && value != "" {
a.insp.cursor++ // stays on the row that adds one
}
focus := ui.Focus{Index: a.insp.cursor, Len: len(a.lines())}
@@ -391,9 +417,9 @@ func (a *app) commit(l line, value string) bool {
}
items := a.items(l.node, k.Name)
switch {
case l.item == len(items) && value == "":
case l.add && value == "":
return true
case l.item == len(items):
case l.add:
items = append(items, value)
case value == "":
items = append(items[:l.item], items[l.item+1:]...)
@@ -410,7 +436,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.add:
case l.group != "" && l.key.Name == "":
a.edit(a.comp.Remove(a.pi, l.node))
case l.item >= 0:
@@ -420,14 +446,25 @@ func (a *app) clear(l line) {
}
}
// moveFilter moves a filter by one, its fold and the cursor with it
// moveFilter moves a filter by one, its folds 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]
from, to := a.foldKey(compose.Node{Role: "filters", Index: i}, ""), a.foldKey(compose.Node{Role: "filters", Index: i + by}, "")
moved := map[string]bool{}
for k, open := range a.insp.open {
switch {
case strings.HasPrefix(k, from):
moved[to+k[len(from):]] = open
case strings.HasPrefix(k, to):
moved[from+k[len(to):]] = open
default:
continue
}
delete(a.insp.open, k)
}
maps.Copy(a.insp.open, moved)
for j, l := range a.lines() {
if l.group != "" && l.key.Name == "" && l.node.Index == i+by {
a.insp.cursor = j
+1 -1
View File
@@ -465,7 +465,7 @@ func (a *app) inspectAt(at func(line) bool) {
// 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
a.insp.open[a.foldKey(n, "")] = true
n = compose.Node{Role: "filters"}
}
for col := range columns {
+116 -5
View File
@@ -373,6 +373,112 @@ func TestListElementsAreRows(t *testing.T) {
}
}
// A key's help says what unset means when that is a default or what zero
// means, never the kind of value the key takes
func TestHelpSaysWhatUnsetMeans(t *testing.T) {
c := pipeline(t, nil, []string{"null"})
n, err := c.Add(0, "source", "file")
if err == nil {
err = c.Set(0, n, "directory", "/srv/app")
}
if err != nil {
t.Fatal(err)
}
a := mono(c)
dir, _ := a.help(focusKey(t, a, "directory"))
pattern, _ := a.help(focusKey(t, a, "pattern"))
if strings.Contains(dir, "unset") || !strings.HasSuffix(pattern, "; unset: *") {
t.Errorf("directory %q, pattern %q", dir, pattern)
}
}
// filtered is an app inspecting a pipeline whose filters match patterns,
// each a filter, the first with its patterns unfolded
func filtered(t *testing.T, patterns ...string) *app {
t.Helper()
c := pipeline(t, []string{"null"}, []string{"null"})
for _, p := range patterns {
n, err := c.Add(0, "filters", "include")
if err == nil {
err = c.Set(0, n, "patterns", p)
}
if err != nil {
t.Fatal(err)
}
}
a := mono(c)
first := compose.Node{Role: "filters"}
a.choose(first)
a.insp.open[a.foldKey(first, "patterns")] = true
a.inspectAt(func(l line) bool { return l.item == 0 && !l.add })
return a
}
// inspectorRows are the inspector's rows on a screen w by h, under its rule
func inspectorRows(a *app, w, h int) []string {
rows := text(render(a, w, h), w)
return rows[1+slices.IndexFunc(rows, func(r string) bool { return strings.HasPrefix(r, "── filters") }):]
}
// A list reads as one: its elements numbered at its labels' column, then
// "+ add" there with no number, and the filters' "+ add filter" where their
// titles start; an element's help says d deletes it, its add row's, focused
// with its mark, that Enter adds one
func TestAddRowsAreNotListElements(t *testing.T) {
a := filtered(t, "Event")
rows := inspectorRows(a, 78, 30)
at := func(s string) (int, string) { // where the first row holding s starts, past the focus mark, and its text
for _, r := range rows {
if strings.Contains(r, s) {
text := strings.TrimLeft(r, " "+string(a.th.Glyphs.Focus))
return ui.RuneLen(r) - ui.RuneLen(text), strings.TrimSpace(text)
}
}
return -1, ""
}
element, _ := at("Event")
add, addText := at("+ add")
title, _ := at("1 include")
addFilter, addFilterText := at("+ add filter")
screen := strings.Join(rows, "\n")
if add != element || addText != "+ add" || addFilter != title || addFilterText != "+ add filter" ||
!strings.Contains(screen, "RE2 patterns; d deletes it") || strings.Contains(screen, "unset:") {
t.Fatalf("element at %d, its add row at %d %q, filter at %d, its add row at %d %q\n%s", element, add, addText, title, addFilter, addFilterText, screen)
}
press(a, "j")
rows = inspectorRows(a, 78, 30)
focused := strings.TrimSpace(rows[slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "+ add") })])
if focused != string(a.th.Glyphs.Focus)+" + add" || !strings.Contains(strings.Join(rows, "\n"), "RE2 patterns; "+a.font.enter+" adds one") {
t.Fatalf("the add row focused: %q\n%s", focused, strings.Join(rows, "\n"))
}
}
// The inspector's two columns never part an unfolded group, nor a list or
// the filters from the row adding to them, and hold no more rows than one
// column would
func TestTwoColumnsKeepGroupsWhole(t *testing.T) {
const w, h = 100, 30
right := func(rows []string) (out []string) {
for _, r := range rows {
if half := strings.TrimSpace(string([]rune(r)[w/2:])); half != "" {
out = append(out, half)
}
}
return out
}
a := filtered(t, "Event")
if got := right(inspectorRows(a, w, h)[:len(a.lines())]); len(got) > 0 {
t.Errorf("one filter, unfolded, split: %q", got)
}
a = filtered(t, "Event", "Start")
if got := right(inspectorRows(a, w, h)[:len(a.lines())]); len(got) != 2 || !strings.HasPrefix(got[0], "▸ 2 include") || got[1] != "+ add filter" {
t.Errorf("the second filter's column: %q", got)
}
if a.insp.cursor = len(a.lines()) - 1; !strings.Contains(strings.Join(inspectorRows(a, w, 16), "\n"), "+ add filter") {
t.Errorf("too short for two columns, the focused row went:\n%s", strings.Join(inspectorRows(a, w, 16), "\n"))
}
}
// 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) {
@@ -393,9 +499,10 @@ func TestSpaceSwitchesAStage(t *testing.T) {
}
}
// 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) {
// J and K move a filter, its folds and the cursor with it, the filter it
// passes taking its place with its own folds, and stop at either end as j and
// k do
func TestFiltersMoveWithTheirFolds(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 {
@@ -403,11 +510,15 @@ func TestFiltersMoveWithTheirFold(t *testing.T) {
}
}
a := mono(c)
a.choose(compose.Node{Role: "filters", Index: 0})
first, second := compose.Node{Role: "filters", Index: 0}, compose.Node{Role: "filters", Index: 1}
a.choose(first)
a.insp.open[a.foldKey(first, "patterns")] = true
a.insp.open[a.foldKey(second, "")] = true
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 != "" {
moved := a.insp.open[a.foldKey(second, "")] && a.insp.open[a.foldKey(second, "patterns")] && a.insp.open[a.foldKey(first, "")] && !a.insp.open[a.foldKey(first, "patterns")]
if !slices.Equal(types, []config.FilterType{"exclude", "include"}) || l.node.Index != 1 || !moved || a.status != "" {
t.Fatalf("types %v, cursor on %+v, open %v, status %q", types, l, a.insp.open, a.status)
}
}
+125 -65
View File
@@ -106,21 +106,21 @@ const (
// point is where on a pipeline the view looks, as the canvas cursor picks
// it: the verdicts reaching it rank from reach, those it drops from lo to
// hi; a source's and the heartbeat's take only their own entries. own is
// lw's own log alone, which s picks.
// lw's own log alone, which s picks. all says what mode all shows.
type point struct {
pipe, name string
one, own bool
source string
reach int
lo, hi int
pipe, name, all string
one, own bool
source string
reach int
lo, hi int
}
// lwShown is the lowest level of lw's own log a pipeline's point shows, so
// its debug records do not bury the entries
var lwShown = slices.IndexFunc(core.Levels, func(l core.Level) bool { return l.Level == "INFO" })
// its startup records do not bury the entries
var lwShown = slices.IndexFunc(core.Levels, func(l core.Level) bool { return l.Level == "WARN" })
// shows reports whether the point in mode shows r: lw's own log shows from
// info up at every point and whole at its own, which names no pipeline; a
// warn up at every point and whole at its own, which names no pipeline; a
// heartbeat only at the sinks' and its own
func (p point) shows(r *row, mode int) bool {
switch {
@@ -151,7 +151,7 @@ func (p point) needsDrops(mode int) bool {
// point is where the canvas cursor looks: a source, a flow stage or the
// filter the inspector is on, the heartbeat, or the sinks
func (a *app) point() point {
p := point{name: "sinks", reach: passed, hi: passed - 1}
p := point{name: "sinks", all: "all they got", reach: passed, hi: passed - 1}
switch {
case a.view.own:
return point{name: "lw's own log", own: true, reach: passed, lo: passed, hi: passed - 1}
@@ -163,17 +163,17 @@ func (a *app) point() point {
if l := a.lines(); a.inspect && ok && n.Role == "filters" && len(l) > 0 {
if m := l[min(a.insp.cursor, len(l)-1)].node; m.Index < len(a.pipeline().Flow.Filters) {
r := rank("filters", m.Index)
return point{pipe: p.pipe, name: fmt.Sprintf("filter %d", m.Index+1), reach: r, lo: r, hi: r}
return point{pipe: p.pipe, name: fmt.Sprintf("filter %d", m.Index+1), all: "all that reaches it", reach: r, lo: r, hi: r}
}
}
switch {
case !ok || n.Role == "sink":
case n.Role == "source":
p.name, p.one, p.source, p.reach = "source "+n.ID, true, n.ID, 0
p.name, p.all, p.one, p.source, p.reach = "source "+n.ID, "all it reads", true, n.ID, 0
case n.Role == "heartbeat":
p.name, p.one, p.lo, p.hi = "heartbeat", true, 1, 0
p.name, p.all, p.one, p.lo, p.hi = "heartbeat", "all it sends", true, 1, 0
default:
p.name, p.reach, p.lo, p.hi = n.Role, rank(n.Role, 0), rank(n.Role, 0), rank(n.Role, 1<<16-1)
p.name, p.all, p.reach, p.lo, p.hi = n.Role, "all that reaches it", rank(n.Role, 0), rank(n.Role, 0), rank(n.Role, 1<<16-1)
}
return p
}
@@ -224,6 +224,9 @@ func (r *row) own() int {
return n
}
// dropped reports whether the flow dropped the row
func (r *row) dropped() bool { return r.stage != "" }
// mark draws a rule across the rows, which every filter shows
func (v *view) mark(text string) { v.add(row{mark: text, level: -1, size: rowBytes}) }
@@ -315,6 +318,14 @@ func (v *view) focused() *row {
return &v.rows[v.shown[v.cursor]-v.rows[0].seq]
}
// focusedEntry is the cursor's row when it is an entry, nil on a rule or none
func (v *view) focusedEntry() *row {
if w := v.focused(); w != nil && w.mark == "" {
return w
}
return nil
}
func (v *view) clear() {
v.rows, v.shown, v.bytes, v.cursor, v.scroll, v.paused = nil, nil, 0, 0, 0, false
}
@@ -379,8 +390,10 @@ func (a *app) viewKey(ev terminal.Event) {
a.dialog = a.pointMenu()
case is(ev, terminal.KeyNone, "c"):
v.clear()
case is(ev, terminal.KeyNone, "i"):
a.inspectDrop()
case is(ev, terminal.KeyNone, "i") && v.focusedEntry() != nil:
a.dialog = &entry{row: *v.focusedEntry()}
case is(ev, terminal.KeyNone, "e"):
a.editDrop(v.focusedEntry())
case is(ev, terminal.KeyEscape, "") && v.detail:
v.detail = false
case is(ev, terminal.KeyEscape, "t"):
@@ -412,18 +425,17 @@ func (a *app) promptKey(ev terminal.Event) {
}
}
// inspectDrop opens the inspector on the stage that dropped the focused row
func (a *app) inspectDrop() {
w := a.view.focused()
// editDrop opens the inspector on the stage that dropped w
func (a *app) editDrop(w *row) {
pi := -1
if w != nil {
pi = slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == w.pipe })
pi = a.pipeIndex(w.pipe)
}
switch {
case w == nil || pi < 0:
a.note = "i inspects the stage that dropped an entry"
a.note = "e edits the stage that dropped an entry"
return
case w.stage == "":
case !w.dropped():
a.note = "it passed every stage"
return
}
@@ -436,6 +448,11 @@ func (a *app) inspectDrop() {
a.inspectAt(func(l line) bool { return l.node == n })
}
// pipeIndex is the pipeline named name, -1 when none is
func (a *app) pipeIndex(name string) int {
return slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == name })
}
// pointMenu picks where the view looks, moving the canvas cursor there, or
// lw's own log while it reaches the screen: s
func (a *app) pointMenu() dialog {
@@ -488,24 +505,41 @@ func (a *app) pointMenu() dialog {
func (a *app) drawView(r ui.Region) {
v, th := &a.view, a.th
v.settle()
title := v.built.at.name
if !v.built.at.own {
at := v.built.at
title, more, dimmed, rest := at.name, "", "", ""
switch {
case at.own:
case v.mode == modeAll:
title, more = title+" · all", strings.TrimPrefix(at.all, "all")
if at.needsDrops(modeAll) {
dimmed = ", dropped dimmed"
}
default:
title += " · " + verdicts[v.mode]
}
if len(a.comp.Pipelines) > 1 && v.built.at.pipe != "" {
title = v.built.at.pipe + " · " + title
if len(a.comp.Pipelines) > 1 && at.pipe != "" {
title = at.pipe + " · " + title
}
if v.floor > 0 {
title += " · " + core.Levels[v.floor-1].Level + " up"
rest += " · " + core.Levels[v.floor-1].Level + " up"
}
if v.pat.src != "" {
title += " · /" + v.pat.src
rest += " · /" + v.pat.src
}
hint := fmt.Sprintf("following · %d rows", len(v.shown))
if v.paused {
hint = fmt.Sprintf("paused · %d newer", len(v.shown)-1-v.cursor)
}
r.Rule(0, a.font.flat(title, r.W), hint, th)
// The hint keeps its room, the rule padding title and hint by 8 cells: the
// title gives up saying the dropped are dimmed, then what all holds, then
// its end
room, text := r.W-ui.RuneLen(hint)-8, title+more+dimmed+rest
for _, shorter := range []string{title + more + rest, title + rest} {
if ui.RuneLen(text) > room {
text = shorter
}
}
r.Rule(0, a.font.flat(text, room), hint, th)
if a.viewing {
r.TextStyled(0, 0, string(th.Glyphs.Focus), th.Accent)
}
@@ -563,17 +597,25 @@ func (v *view) widths(w int) (tw, lw, gw int) {
return tw, lw, gw
}
// drawRow draws a row: the cursor's pointer, a mark when the flow dropped
// it, its time, level and tag, then its message and, muted, its fields. On
// the cursor row each role keeps its color on the bar, but for a reverse
// bar, which ui.Style.On draws in one style.
// drawRow draws a row: the cursor's pointer, its time, level and tag, then
// its message and, muted, its fields; all muted when the flow dropped it and
// the view shows all. On the cursor row each role keeps its color on the
// bar, but for a reverse bar, which ui.Style.On draws in one style.
func (a *app) drawRow(r ui.Region, w *row, tw, lw, gw int, cursor bool) {
th, f := a.th, a.font
on := func(s ui.Style) ui.Style { return s }
dim := a.view.mode == modeAll && w.dropped()
on := func(s ui.Style) ui.Style {
if dim {
s = th.Muted
}
if cursor {
s = s.On(th.Selected)
}
return s
}
if cursor {
r.FillStyle(th.Text.On(th.Selected))
on = func(s ui.Style) ui.Style { return s.On(th.Selected) }
r.TextStyled(0, 0, string(th.Glyphs.Pointer), on(th.Accent))
r.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.On(th.Selected))
}
if w.mark != "" {
dash := "─"
@@ -584,10 +626,7 @@ func (a *app) drawRow(r ui.Region, w *row, tw, lw, gw int, cursor bool) {
r.TextStyled(2, 0, text+strings.Repeat(dash, max(0, r.W-2-ui.RuneLen(text))), on(th.Border))
return
}
if w.rank != passed {
r.TextStyled(1, 0, string([]rune(f.invalid)[0]), on(th.Error))
}
x := 3 // a space after the mark, so it is not read as the time's first letter
x := 1
if tw > 0 {
r.TextStyled(x, 0, w.at.Local().Format("15:04:05.000")[:tw], on(th.Muted))
x += tw + 1
@@ -617,30 +656,37 @@ type dline struct {
style ui.Style
}
// drawDetail draws the focused row whole, scrolled by J and K
// drawDetail draws the focused entry whole, scrolled by J and K
func (a *app) drawDetail(r ui.Region) {
v, th := &a.view, a.th
w := v.focused()
v := &a.view
w := v.focusedEntry()
hint := "J K scroll"
if w != nil && w.pipe != "" && w.stage != "" {
hint = "i inspects it " + hint
switch {
case w != nil && w.dropped():
hint = "i full screen e edit its stage " + hint
case w != nil:
hint = "i full screen " + hint
}
r.Rule(0, "detail", hint, th)
if w == nil {
return
}
lines := a.details(w, r.W-2)
v.dscroll = max(0, min(v.dscroll, len(lines)-(r.H-1)))
for y := 1; y < r.H && v.dscroll+y-1 < len(lines); y++ {
l := lines[v.dscroll+y-1]
r.TextStyled(1, y, l.key, th.Muted)
r.TextStyled(2+ui.RuneLen(l.key), y, l.text, l.style)
r.Rule(0, "detail", hint, a.th)
if w != nil {
v.dscroll = a.drawLines(r.Sub(0, 1, r.W, r.H-1), a.details(w, r.W-2), v.dscroll)
}
}
// drawLines draws detail lines from top, kept in range, which it returns
func (a *app) drawLines(r ui.Region, lines []dline, top int) int {
top = max(0, min(top, len(lines)-r.H))
for y := 0; y < r.H && top+y < len(lines); y++ {
l := lines[top+y]
r.TextStyled(1, y, l.key, a.th.Muted)
r.TextStyled(2+ui.RuneLen(l.key), y, l.text, l.style)
}
return top
}
// details are a row's detail lines w wide: its time, level, origin and
// verdict, the layout logline found, its message and fields, then its
// payload as the sinks receive it; what a cell cannot show is an escape
// flow, the layout logline found, its message and fields, then its payload
// as the sinks receive it; what a cell cannot show is an escape
func (a *app) details(w *row, width int) []dline {
th := a.th
var out []dline
@@ -659,7 +705,7 @@ func (a *app) details(w *row, width int) []dline {
put("level", 8, w.Level, th.Text)
put("from", 8, a.origin(w), th.Text)
if w.pipe != "" {
put("verdict", 8, a.verdict(w), th.Text)
put("flow", 8, a.verdict(w), th.Text)
}
put("layout", 8, w.Layout, th.Muted)
put("message", 8, w.Message, th.Text)
@@ -677,18 +723,32 @@ func (a *app) details(w *row, width int) []dline {
return out
}
// origin names where a row came from: lw's own log, or a pipeline's source
// or heartbeat, and the tag the line names when it is another
// origin names where a row came from: lw's own log, or a pipeline's
// heartbeat or source, by the type it has now and its id when that is
// another, then the tag the line names when it is not the source's id
func (a *app) origin(w *row) string {
switch {
case w.pipe == "":
return "lw's own log"
case w.source == "":
return "pipeline " + w.pipe + " · heartbeat"
case w.Tag != w.source:
return "pipeline " + w.pipe + " · source " + w.source + " · " + w.Tag
}
return "pipeline " + w.pipe + " · source " + w.source
from := "source " + w.source
if pi := a.pipeIndex(w.pipe); pi >= 0 {
for _, s := range a.comp.Pipelines[pi].PluginSources {
switch {
case s.ID != w.source:
case s.ID == s.Type:
from = s.Type + " source"
default:
from = s.Type + " source " + s.ID
}
}
}
if w.Tag != "" && w.Tag != w.source {
from += " · " + w.Tag
}
return "pipeline " + w.pipe + " · " + from
}
// verdict says what the flow did with a row: passed, or the stage, and for a
@@ -699,9 +759,9 @@ func (a *app) verdict(w *row) string {
return "passed every stage"
case "filters":
text := fmt.Sprintf("dropped by filter %d", w.index+1)
if pi := slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == w.pipe }); pi >= 0 {
if pi := a.pipeIndex(w.pipe); pi >= 0 {
if fs := a.comp.Pipelines[pi].Flow.Filters; w.index < len(fs) {
text += ", " + string(fs[w.index].Type) + " " + strings.Join(fs[w.index].Patterns, " | ")
text += " (" + string(fs[w.index].Type) + " " + strings.Join(fs[w.index].Patterns, " | ") + ")"
}
}
return text
+121 -16
View File
@@ -188,7 +188,7 @@ func TestARowCountsAllItHolds(t *testing.T) {
// The canvas cursor picks the point and v the verdicts at it: a source's
// entries, what reaches a stage or a filter, passes it or it drops, the
// heartbeats, what the sinks got and what the flow dropped on the way; lw's
// own log shows from info up at every point, and whole at its own
// own log shows from warn up at every point, and whole at its own
func TestThePointAndModePickTheRows(t *testing.T) {
c := pipeline(t, []string{"null", "null"}, []string{"null"})
for _, typ := range []string{"include", "exclude"} {
@@ -209,7 +209,7 @@ func TestThePointAndModePickTheRows(t *testing.T) {
for _, r := range []core.Tapped{
tapped("p", s1, "", 0, "a"), tapped("p", s2, "", 0, "b"), tapped("p", s1, "rate_limit", 0, "c"),
tapped("p", s1, "filters", 0, "d"), tapped("p", s2, "filters", 1, "e"), tapped("p", s1, "format", 0, "f"),
tapped("p", "", "", 0, "g"), tapped("q", s1, "", 0, "h"), lw("INFO", "i"), lw("DEBUG", "j"),
tapped("p", "", "", 0, "g"), tapped("q", s1, "", 0, "h"), lw("WARN", "i"), lw("INFO", "j"),
} {
a.view.take(r)
}
@@ -360,10 +360,12 @@ func TestTheDetailSitsBesideFrom160(t *testing.T) {
}
}
// The detail shows the entry whole: its verdict, the layout logline found,
// its fields, and the payload as the sinks receive it
// The detail shows the entry whole: where it came from by its source's type,
// its id when another, and its tag, what the flow did with it, the layout
// logline found, its fields, and the payload as the sinks receive it
func TestTheDetailShowsTheEntryWhole(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"})
c.Pipelines[0].PluginSources[0].ID = "in"
if _, err := c.Add(0, "filters", "exclude"); err != nil {
t.Fatal(err)
}
@@ -372,17 +374,20 @@ func TestTheDetailShowsTheEntryWhole(t *testing.T) {
}
a := watching(c, looks[terminal.ColorModeNone])
line := `{"time":"2026-10-10T12:00:00Z","level":"WARN","sub":"fsm","fields":{"msg":"transition","state":"idle"}}`
a.view.take(tapped("p", "null", "", 0, line))
a.view.take(tapped("p", "in", "", 0, line))
press(a, terminal.KeyEnter)
screen := strings.Join(text(render(a, 170, 30), 170), "\n")
for _, want := range []string{"verdict passed every stage", "layout json, message in fields.msg", "state idle", "as sinks receive it", line[:50]} {
for _, want := range []string{"from pipeline p · null source in · fsm", "flow passed every stage", "layout json, message in fields.msg",
"state idle", "as sinks receive it", line[:50]} {
if !strings.Contains(screen, want) {
t.Errorf("no %q\n%s", want, screen)
}
}
c.Pipelines[0].PluginSources[0].ID = "null"
a.view.take(tapped("p", "null", "filters", 0, "slow tick"))
if screen := strings.Join(text(render(a, 170, 30), 170), "\n"); !strings.Contains(screen, "dropped by filter 1, exclude slow") || strings.Contains(screen, "as sinks receive it") {
t.Errorf("a dropped row\n%s", screen)
if screen := strings.Join(text(render(a, 170, 30), 170), "\n"); a.origin(a.view.focused()) != "pipeline p · null source" ||
!strings.Contains(screen, "flow dropped by filter 1 (exclude slow)") || strings.Contains(screen, "as sinks receive it") {
t.Errorf("a dropped row from a source whose id is its type\n%s", screen)
}
}
@@ -422,7 +427,7 @@ func TestANarrowViewDropsColumns(t *testing.T) {
w int
want string
}{{100, stamp + ".000 WARN app.log hello world"}, {60, stamp + ".000 W app.log hello world"},
{45, stamp + " W hello world"}, {30, "▸ W hello world"}, {20, "▸ hello world"}} {
{45, stamp + " W hello world"}, {30, "▸W hello world"}, {20, "▸hello world"}} {
if row := text(render(a, c.w, 24), c.w)[2]; !strings.Contains(row, c.want) {
t.Errorf("%d columns: %q, want %q", c.w, row, c.want)
}
@@ -531,20 +536,120 @@ func TestARerunMarksTheRows(t *testing.T) {
}
}
// i inspects the filter that dropped the focused row, which the view then
// looks at
func TestIInspectsTheFilterThatDropped(t *testing.T) {
// e edits the filter that dropped the focused row, from the rows or the
// row full screen, and the view then looks at it
func TestEEditsTheStageThatDropped(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 := watching(c, looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "filters", 1, "dropped"))
for _, keys := range []string{"e", "ie"} {
a := watching(c, looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "filters", 1, "dropped"))
press(a, keys)
if l := a.lines()[a.insp.cursor]; a.dialog != nil || a.viewing || !a.inspect || l.node != (compose.Node{Role: "filters", Index: 1}) || a.point().name != "filter 2" {
t.Fatalf("%s: viewing %v, inspecting %v on %+v, at %s", keys, a.viewing, a.inspect, l.node, a.point().name)
}
}
}
// i shows the focused entry over the whole screen, every detail line, which
// j k g G scroll; Esc, i or q goes back to the rows, q quitting nothing. On no
// row or a rule across the rows it shows nothing.
func TestIShowsTheRowFullScreen(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for _, rule := range []string{"", "rerun 10:00:00"} {
if rule != "" {
a.view.mark(rule)
}
if press(a, "i"); a.dialog != nil {
t.Fatalf("i on %q: %T", rule, a.dialog)
}
}
fields := ""
for i := range 40 {
fields += fmt.Sprintf(`,"k%02d":%d`, i, i)
}
a.view.take(tapped("p", "null", "", 0, `{"msg":"hello"`+fields+`}`))
screen := func() string { return strings.Join(text(render(a, 80, 24), 80), "\n") }
press(a, "i")
if l := a.lines()[a.insp.cursor]; a.viewing || !a.inspect || l.node != (compose.Node{Role: "filters", Index: 1}) || a.point().name != "filter 2" {
t.Fatalf("viewing %v, inspecting %v on %+v, at %s", a.viewing, a.inspect, l.node, a.point().name)
if s := screen(); !strings.Contains(s, "─ entry ─") || !strings.Contains(s, "message hello") || strings.Contains(s, "following") || strings.Contains(s, "t back") ||
strings.Contains(s, "k39") || !strings.Contains(s, "j k: 1-22 of ") {
t.Fatalf("full screen:\n%s", s)
}
if press(a, "G"); !strings.Contains(screen(), "k39") {
t.Fatalf("G:\n%s", screen())
}
if press(a, "g", "j"); !strings.Contains(screen(), "j k: 2-23 of ") {
t.Fatalf("g j:\n%s", screen())
}
for _, back := range []any{terminal.KeyEscape, "i", "q"} {
press(a, back)
if a.dialog != nil || !a.viewing || a.done {
t.Fatalf("%v: dialog %T, viewing %v, done %v", back, a.dialog, a.viewing, a.done)
}
press(a, "i")
}
}
// Rows give the flow's drops no mark: where the view shows all, the rows the
// flow dropped are muted, which the rule says where some can show (not at the
// sinks); a mono screen shows them dim
func TestDroppedRowsAreMutedInAll(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for _, r := range []core.Tapped{tapped("p", "null", "", 0, "kept"), tapped("p", "null", "filters", 0, "gone"), tapped("p", "null", "filters", 0, "newest")} {
a.view.take(r)
}
a.choose(compose.Node{Role: "source", ID: "null"})
for mode, want := range map[int]string{modeAll: "source null · all it reads, dropped dimmed", modeDropped: "source null · dropped"} {
a.view.mode = mode
cells := render(a, 80, 24)
rows := text(cells, 80)
attr := func(msg string) terminal.Attr {
y := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, msg) })
if y < 0 || !strings.HasPrefix(string([]rune(rows[y])[1:]), time.Unix(1, 0).Format("15:04:05")) {
t.Fatalf("%s: no %q at column 1\n%s", verdicts[mode], msg, strings.Join(rows, "\n"))
}
return cells[y*80+ui.RuneLen(rows[y][:strings.Index(rows[y], msg)])].Attrs
}
if dim := attr("gone")&terminal.AttrDim != 0; !strings.Contains(rows[1], want) || dim != (mode == modeAll) || mode == modeAll && attr("kept")&terminal.AttrDim != 0 {
t.Errorf("%s: dropped row dim %v\n%s", verdicts[mode], dim, strings.Join(rows, "\n"))
}
}
a.view.mode = modeAll
if a.choose(compose.Node{Role: "sink", ID: "null"}); !strings.Contains(text(render(a, 80, 24), 80)[1], "sinks · all they got ─") {
t.Errorf("at the sinks: %q", text(render(a, 80, 24), 80)[1])
}
}
// The rule keeps its hint, following or paused, where the title would push it
// off: the title drops its note that the dropped are dimmed, then what all
// holds, then its end
func TestTheRuleKeepsItsHint(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"})
if err := c.AddPipeline("q"); err != nil {
t.Fatal(err)
}
a := watching(c, looks[terminal.ColorModeNone])
for _, msg := range []string{"tick 1", "tick 2"} {
a.view.take(tapped("p", "null", "", 0, `{"level":"WARN","msg":"`+msg+`"}`))
}
a.choose(compose.Node{Role: "source", ID: "null"})
press(a, "3", "/", "tick", terminal.KeyEnter, "k")
rule := func(w int) string { return text(render(a, w, 30), w)[1] }
if wide := rule(120); !strings.Contains(wide, "p · source null · all it reads, dropped dimmed · INFO up · /tick ─") {
t.Fatalf("120 columns: %q", wide)
}
if narrow := rule(78); !strings.Contains(narrow, "p · source null · all it reads · INFO up · /tick ─") || !strings.Contains(narrow, "paused · 1 newer") {
t.Errorf("78 columns: %q", narrow)
}
if narrower := rule(70); !strings.Contains(narrower, "p · source null · all · INFO up · /tick ─") || !strings.Contains(narrower, "paused · 1 newer") {
t.Errorf("70 columns: %q", narrower)
}
if narrowest := rule(40); !strings.Contains(narrowest, "p · source null…") || !strings.Contains(narrowest, "paused · 1 newer") {
t.Errorf("40 columns: %q", narrowest)
}
}