v0.33.0 tui tap for live view and console enhancements

This commit is contained in:
lixen committed 2026-10-10 21:32:08 -04:00
1 parent a1e9d21d06
commit 87f5576ccf
41 files changed
+4606 -449

No files matched your search

+190 -71
View File
@@ -1,6 +1,7 @@
package tui
import (
"cmp"
"fmt"
"slices"
"strconv"
@@ -13,64 +14,129 @@ import (
)
const (
pitch = 3 // rows a source or sink takes, its gap included
gutter = 4 // columns of wire on either side of the flow box
wideMin = 64 // the narrowest canvas with the parts side by side
gutter = 4 // columns of wire a side when the content does not fit
wideMin = 64 // the narrowest canvas with the parts side by side
besideMin = 110 // the narrowest screen with the inspector beside the canvas
listMax = 4 // option rows a source or sink lists beside the inspector
)
// spot is where a node draws: its first row and the columns it may use
type spot struct{ x, y, w int }
// Widths beside the inspector: columns sized to their content within these,
// and wires taking the slack up to wireMax a side
const (
sideMin, sideMax = 24, 40
boxMin, boxMax = 18, 44
wireMax = 16
inspMin, inspMax = 60, 100 // the inspector beside the canvas
)
// layout places a pipeline's parts: side by side with wires from 64 columns,
// stacked below that
// spot is where a node draws: its first row, the columns it may use, its
// rows, the column its head's text ends at (a source's wire starts past it),
// and its text under or beside its name
type spot struct {
x, y, w, h, end int
text []string
}
// layout places the screen's parts: the inspector beside the canvas from 110
// columns, below it under that; on the canvas, a pipeline's parts side by
// side with wires from 64 columns, stacked below that. Beside the inspector
// the parts take their content's widths, centred; below it, a fixed split.
type layout struct {
wide bool
h int
w, h int // the canvas's
inspector int // the inspector's width beside the canvas, 0 below it
title [3]spot
nodes [3][]spot // a column's nodes, or its one empty slot
box spot // the flow box's top left and width
boxH int
entry, exit int // the rows wires enter and leave the flow box
wire int // columns between a side column and the flow box
}
// layout lays out a screen w wide; each node's text is read once, and sizes
// the columns
func (a *app) layout(w int) layout {
var l layout
counts := [3]int{}
for col := range columns {
counts[col] = max(1, len(a.nodes(col)))
}
stages := counts[flowCol]
l.boxH = stages + 2
if w >= wideMin {
l.wide = true
boxW := max(18, min(30, (w-2*gutter)/3))
sideW := (w - boxW - 2*gutter) / 2
xs := [3]int{0, sideW + gutter, sideW + 2*gutter + boxW}
ws := [3]int{sideW, boxW, w - xs[sinksCol]}
for col := range columns {
l.title[col] = spot{xs[col], 0, ws[col]}
}
l.box = spot{xs[flowCol], 1, boxW}
for _, col := range []int{sourcesCol, sinksCol} {
for i := range counts[col] {
l.nodes[col] = append(l.nodes[col], spot{xs[col], 1 + i*pitch, ws[col]})
l := layout{w: w}
beside := w >= besideMin
side, box := sideMin, boxMin
for _, col := range []int{sourcesCol, sinksCol} {
for _, n := range a.nodes(col) {
p, _ := a.plugin(n)
s := spot{end: 2 + ui.RuneLen(p.Type)}
if beside {
s.text = a.optionRows(n)
} else {
s.text = []string{a.summary(n)}
}
if verb := ways[p.Side]; verb != "" {
s.end += 2 + ui.RuneLen(verb)
}
s.h = 1 + len(s.text)
side = max(side, s.end+3)
for _, t := range s.text {
side = max(side, 3+ui.RuneLen(t))
}
l.nodes[col] = append(l.nodes[col], s)
}
l.entry = l.box.y + 1 + (stages-1)/2
if len(l.nodes[col]) == 0 {
l.nodes[col] = []spot{{h: 2}}
}
}
var stages []spot
for _, n := range a.nodes(flowCol) {
text := a.summary(n)
if !a.on(n) {
text = "off"
}
box = max(box, 7+ui.RuneLen(n.Role)+ui.RuneLen(text))
stages = append(stages, spot{h: 1, text: []string{text}})
}
if beside {
want := 2*min(side, sideMax) + min(box, boxMax) + 2*wireMax + 4 // with a margin
l.inspector = max(min(inspMin, w*2/5), min(inspMax, w-want-1))
l.w = w - l.inspector - 1
}
l.boxH = max(1, len(stages)) + 2
if l.w >= wideMin {
l.wide = true
side, box = min(side, sideMax), min(box, boxMax)
l.wire = min(wireMax, (l.w-2*side-box)/2)
if !beside || l.wire < gutter {
box = max(boxMin, min(30, (l.w-2*gutter)/3))
side, l.wire = (l.w-box-2*gutter)/2, gutter
}
x0 := (l.w - 2*side - box - 2*l.wire) / 2
xs := [3]int{x0, x0 + side + l.wire, x0 + side + 2*l.wire + box}
ws := [3]int{side, box, side}
for col := range columns {
l.title[col] = spot{x: xs[col], w: ws[col]}
}
l.box = spot{x: xs[flowCol], y: 1, w: box}
l.h = 1 + l.boxH
for _, col := range []int{sourcesCol, sinksCol} {
y := 1
for i := range l.nodes[col] {
s := &l.nodes[col][i]
s.x, s.y, s.w = xs[col], y, ws[col]
y += s.h + 1
}
l.h = max(l.h, y-1)
}
l.entry = l.box.y + 1 + (l.boxH-3)/2
l.exit = l.entry
l.h = 1 + max(counts[sourcesCol]*pitch-1, l.boxH, counts[sinksCol]*pitch-1)
} else {
y := 0
for col := range columns {
l.title[col] = spot{0, y, w}
l.title[col] = spot{y: y, w: l.w}
y++
if col == flowCol {
l.box = spot{0, y, w}
l.box = spot{y: y, w: l.w}
y += l.boxH
} else {
for range counts[col] {
l.nodes[col] = append(l.nodes[col], spot{0, y, w})
y += pitch
for i := range l.nodes[col] {
s := &l.nodes[col][i]
s.y, s.w = y, l.w
y += s.h + 1
}
y--
}
@@ -80,18 +146,18 @@ func (a *app) layout(w int) layout {
}
l.h = y
}
for i := range stages {
l.nodes[flowCol] = append(l.nodes[flowCol], spot{l.box.x + 2, l.box.y + 1 + i, l.box.w - 4})
for i, s := range stages {
s.x, s.y, s.w = l.box.x+2, l.box.y+1+i, l.box.w-4
l.nodes[flowCol] = append(l.nodes[flowCol], s)
}
return l
}
// drawCanvas draws the pipeline, scrolled to keep the chosen node in view
func (a *app) drawCanvas(r ui.Region) {
l := a.layout(r.W)
func (a *app) drawCanvas(r ui.Region, l layout) {
chosen := l.nodes[a.col][min(a.row[a.col], len(l.nodes[a.col])-1)]
a.scroll.SetDimensions(l.h, r.H)
a.scroll.EnsureRange(chosen.y, 2-a.col%2)
a.scroll.EnsureRange(chosen.y, chosen.h)
r.Window(l.h, &a.scroll, a.th.Text, func(full ui.Region) { a.drawParts(full, l) })
}
@@ -112,7 +178,11 @@ func (a *app) drawParts(r ui.Region, l layout) {
a.drawEmpty(r, s, columns[col].role, chosen)
continue
}
a.drawNode(r, s, nodes[i], tints[col], chosen)
head := s.w // a source's leader starts past its text, where its bar ends
if l.wide && col == sourcesCol {
head = min(s.w, s.end+1)
}
a.drawNode(r, s, head, nodes[i], tints[col], chosen)
}
if l.wide && len(nodes) > 0 {
join(g, l, col)
@@ -129,12 +199,14 @@ func (a *app) drawParts(r ui.Region, l layout) {
r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border)
}
if len(a.nodes(sinksCol)) > 0 {
r.TextStyled(l.box.x+l.box.w+1, l.exit, string(f.arrow), th.Border)
for _, s := range l.nodes[sinksCol] {
r.TextStyled(s.x-1, s.y, string(f.arrow), th.Border)
}
}
}
// join wires a column's nodes to a trunk beside the flow box, and the trunk
// to the box's side
// join wires a column's nodes to a trunk midway along the wire, and the
// trunk to the flow box's side: a source's wire starts past its text
func join(g ui.Wires, l layout, col int) {
ys := []int{l.entry}
if col == sinksCol {
@@ -144,16 +216,16 @@ func join(g ui.Wires, l layout, col int) {
ys = append(ys, s.y)
}
if col == sourcesCol {
trunk := l.box.x - gutter + 1
trunk := l.box.x - 1 - l.wire/2
for _, s := range l.nodes[col] {
g.H(s.y, trunk-1, trunk)
g.H(s.y, s.x+min(s.end+1, s.w), trunk)
}
g.V(trunk, slices.Min(ys), slices.Max(ys))
g.H(l.entry, trunk, l.box.x-1)
return
}
right := l.box.x + l.box.w
trunk := right + 2
trunk := right + 1 + (l.wire-1)/2
g.H(l.exit, right, trunk)
g.V(trunk, slices.Min(ys), slices.Max(ys))
for _, s := range l.nodes[col] {
@@ -162,9 +234,10 @@ func join(g ui.Wires, l layout, col int) {
}
// drawNode draws a source or sink: a tinted bar, its type and whether it
// listens or dials, then a summary of its options. The chosen node's bar is
// solid; a node a problem names has its bar in the error style.
func (a *app) drawNode(r ui.Region, s spot, n compose.Node, tint ui.Style, chosen bool) {
// listens or dials, then its text. The chosen node is a bar across its rows,
// head wide on the first, whose tint block keeps its color; a node a problem
// names has its tint in the error style.
func (a *app) drawNode(r ui.Region, s spot, head int, n compose.Node, tint ui.Style, chosen bool) {
th := a.th
bg, bar := th.Text, a.font.bar
if chosen {
@@ -174,20 +247,31 @@ func (a *app) drawNode(r ui.Region, s spot, n compose.Node, tint ui.Style, chose
tint = th.Error
}
p, _ := a.plugin(n)
way := map[config.Side]string{config.Listener: "listens", config.ChainListener: "listens", config.Dialer: "dials"}[p.Side]
for dy, text := range []string{p.Type, a.summary(n)} {
row := r.Sub(s.x, s.y+dy, s.w, 1)
row.FillStyle(bg)
row.TextStyled(0, 0, string(bar), tint.On(bg))
style := th.Muted.On(bg)
verb, room := ways[p.Side], s.w-3
if verb != "" {
room -= 2 + ui.RuneLen(verb)
}
for dy := range s.h {
w := s.w
if dy == 0 {
style = th.Text.On(bg)
row.TextStyled(row.W-ui.RuneLen(way), 0, way, th.Muted.On(bg))
w = head
}
row.TextStyled(2, 0, ui.Truncate(text, row.W-ui.RuneLen(way)-3), style)
row := r.Sub(s.x, s.y+dy, w, 1)
row.FillStyle(bg)
row.TextStyled(0, 0, string(bar), tint.On(th.Text))
if dy > 0 {
row.TextStyled(2, 0, ui.Truncate(s.text[dy-1], row.W-3), th.Muted.On(bg))
continue
}
typ := ui.Truncate(p.Type, room)
row.TextStyled(2, 0, typ, th.Text.On(bg))
row.TextStyled(4+ui.RuneLen(typ), 0, verb, th.Muted.On(bg))
}
}
// ways say whether a source or sink listens or dials, by its side
var ways = map[config.Side]string{config.Listener: "listens", config.ChainListener: "listens", config.Dialer: "dials"}
func (a *app) drawEmpty(r ui.Region, s spot, role string, chosen bool) {
mark, style := " ", a.th.Muted
if chosen {
@@ -215,13 +299,12 @@ func (a *app) drawBox(r ui.Region, l layout) {
}
row := r.Sub(s.x, s.y, s.w, 1)
row.FillStyle(bg)
mark, tint, text := f.bar, a.look.Flow, th.Text
mark, tint, text, under := f.bar, a.look.Flow, th.Text, th.Text // a block keeps its tint on the bar
if chosen {
mark = f.barOn
}
summary := a.summary(n)
if !a.on(n) {
mark, tint, text, summary = f.Off, th.Muted, th.Muted, "off"
mark, tint, text, under = f.Off, th.Muted, th.Muted, bg
if chosen {
mark = th.Glyphs.Pointer
}
@@ -229,10 +312,10 @@ func (a *app) drawBox(r ui.Region, l layout) {
if a.faulty(n) {
tint = th.Error
}
row.TextStyled(0, 0, string(mark), tint.On(bg))
row.TextStyled(0, 0, string(mark), tint.On(under))
row.TextStyled(2, 0, n.Role, text.On(bg))
x := 3 + ui.RuneLen(n.Role)
row.TextStyled(x, 0, ui.Truncate(summary, row.W-x), th.Muted.On(bg))
row.TextStyled(x, 0, ui.Truncate(s.text[0], row.W-x), th.Muted.On(bg))
}
}
@@ -249,8 +332,8 @@ func (a *app) on(n compose.Node) bool {
return on
}
// summary is a node's options in a line: the values set, a table by its
// name, a switch by its key; the filters as +include and -exclude patterns
// summary is a node's options in a line: the values set, a table or a switch
// by its name; the filters as +include and -exclude patterns
func (a *app) summary(n compose.Node) string {
if n.Role == "filters" {
var out []string
@@ -260,23 +343,59 @@ func (a *app) summary(n compose.Node) string {
}
return strings.Join(out, " ")
}
opts, _, _ := a.comp.Options(a.pi, n)
var out []string
for _, s := range a.settings(n) {
out = append(out, cmp.Or(s.value, s.key))
}
return strings.Join(out, " ")
}
// optionRows are a source's or sink's options set, a row each with the
// values aligned, at most listMax; "defaults" when none is set
func (a *app) optionRows(n compose.Node) []string {
set := a.settings(n)
if len(set) == 0 {
return []string{"defaults"}
}
set = set[:min(len(set), listMax)]
keyW := 0
for _, s := range set {
keyW = max(keyW, ui.RuneLen(s.key))
}
var out []string
for _, s := range set {
row := s.key
if s.value != "" {
row = ui.PadRight(s.key, keyW) + " " + s.value
}
out = append(out, row)
}
return out
}
// setting is an option set: its key, and its value as shown, none for a
// table or a switch
type setting struct{ key, value string }
// settings are a node's options set, in its keys' order
func (a *app) settings(n compose.Node) []setting {
opts, _, _ := a.comp.Options(a.pi, n)
var out []setting
for _, k := range a.keys(n) {
v, ok := opts[k.Name]
switch {
case !ok || k.Name == "enabled" || v == false:
case k.Kind == "table":
if t, _ := v.(map[string]any); t["enabled"] != false {
out = append(out, k.Name)
out = append(out, setting{key: k.Name})
}
case v == true:
out = append(out, k.Name)
out = append(out, setting{key: k.Name})
default:
out = append(out, show(v))
out = append(out, setting{k.Name, show(v)})
}
}
return strings.Join(out, " ")
return out
}
// show writes a value as a form does: a list joined by commas
+89 -14
View File
@@ -1,6 +1,8 @@
package tui
import (
"crypto/rand"
"errors"
"fmt"
"strings"
@@ -239,6 +241,7 @@ type form struct {
focus ui.Focus
scroll ui.ViewportScroll // the fields' rows
err string
mask bool // draws each rune typed as '*'
apply func(a *app, values map[string]string) error
back func(a *app) // on Esc, after the form closes
}
@@ -278,7 +281,15 @@ func (f *form) draw(a *app, r ui.Region) {
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)
field := f.fields[i]
if !f.mask {
v.TextInput(field, p.Default, i == f.focus.Index, th)
continue
}
shown := *field
shown.Text = []rune(strings.Repeat("*", len(field.Text)))
v.TextInput(&shown, p.Default, i == f.focus.Index, th)
field.Scroll = shown.Scroll
}
})
note, style := f.params[f.focus.Index].Usage(), th.Muted
@@ -337,36 +348,81 @@ func (f *form) submit(a *app) {
}
}
// outputMenu is what o offers: run, or print one of the forms
// outputMenu is what o offers: run, tap, or print one of the forms
func (a *app) outputMenu() dialog {
forms := compose.Forms()
options := []ui.Option{{Name: "run", Key: 'r', 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"},
{Name: "tap", Key: 't', Hint: "run here and watch what flows; it runs for real"}}
for _, f := range forms {
options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint})
}
list := ui.NewOptionListState(options)
list.Menu = true
return &chooser{title: "Output, once the screen closes", list: list,
return &chooser{title: "Run, or print once the screen closes", list: list,
pick: func(a *app, i int) {
if i == 0 {
switch i {
case 0:
a.run()
return
case 1:
a.tap()
return
}
text, err := forms[i-1].Write(a.comp)
text, err := forms[i-2].Write(a.comp)
if err != nil {
a.status = err.Error()
return
}
a.result, a.done = Result{Exit: Print, Output: text}, true
a.leave(Result{Exit: Print, Output: text})
}}
}
// askPassword asks in a masked form for a password a terminal would be asked
// for; with generate, Enter alone draws one and shows it once
func (a *app) askPassword(user string, generate bool, reply chan<- []byte) {
answer := func(pw []byte) {
select {
case reply <- pw:
default:
}
}
if a.live.leaving {
answer(nil)
return
}
help := "the password lw would ask on the terminal"
if generate {
help += "; Enter alone generates one, shown once"
}
f := &form{title: "Password for " + user, params: []config.PresetParam{{Name: "password", Required: !generate, Help: help}}, mask: true,
apply: func(a *app, v map[string]string) error {
pw := v["password"]
switch {
case pw == "" && !generate:
return errors.New("type the password, or esc to give none")
case pw == "":
// answered once read: the start goes on only then, so no
// later ask or error replaces the notice unread
pw = rand.Text()
n := textNotice("Password for "+user, "generated, shown this once: "+pw)
n.closed, a.dialog = func(*app) { answer([]byte(pw)) }, n
return nil
}
answer([]byte(pw))
return nil
},
back: func(*app) { answer(nil) }}
f.fill(nil)
a.dialog = f
}
// notice shows text, wrapped to the screen, until a key other than one
// that scrolls it
type notice struct {
title string
body func(w int) []string // the lines at a width
view ui.ViewportScroll
title string
body func(w int) []string // the lines at a width
view ui.ViewportScroll
closed func(a *app) // after a key closed it
}
// textNotice is a notice of plain text, its paragraphs split at '\n'
@@ -406,7 +462,9 @@ func (n *notice) handle(a *app, ev terminal.Event) {
case is(ev, terminal.KeyPageDown, ""):
n.view.PageDown()
default:
a.dialog = nil
if a.dialog = nil; n.closed != nil {
n.closed(a)
}
}
}
@@ -440,8 +498,9 @@ func (c *confirm) handle(a *app, ev terminal.Event) {
}
}
// help lists the keys, each beside its description or, narrow, above it
func help(f font) dialog {
// help lists the keys of the diagram or, viewing, the view's, each beside
// its description or, narrow, above it
func help(f font, viewing bool) dialog {
keys := [][2]string{
{"arrows, hjkl", "move; up from the top row to the pipeline bar"},
{f.enter, "inspect a part; edit a value; open a list; add"},
@@ -455,11 +514,27 @@ func help(f font) dialog {
{"[ ]", "previous or next pipeline"},
{"p", "start over from a preset"},
{"r", "run the pipelines"},
{"o", "run, or print a command line, environment or file"},
{"o", "run, tap, or print a command line, environment or file"},
{"t", "tap: run here and watch what flows; it runs for real: binds, dials, writes files"},
{"!", "every problem, moving to the first"},
{"paste", "an lw command line replaces the pipelines"},
{"q", "quit"},
}
if viewing {
keys = [][2]string{
{"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"},
{"/", "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"},
{"s", "look at a source, a flow stage, the sinks or lw's own log"},
{"i", "inspect 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"},
}
}
return &notice{title: "Keys", body: func(w int) []string {
var lines []string
for _, k := range keys {
+2 -1
View File
@@ -163,8 +163,9 @@ 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)
}
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 {
r.TextStyled(2, r.H-len(help)+i, h, style)
r.TextStyled(2, under+i, h, style)
}
}
+316
View File
@@ -0,0 +1,316 @@
package tui
import (
"errors"
"fmt"
"maps"
"math"
"os"
"os/signal"
"slices"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
ui "github.com/lixenwraith/terminal/tui"
)
// Runner runs pipelines in lw's process while the screen stays. Its calls
// block: the screen makes Start and Stop one at a time on a goroutine of its
// own, and reads State from another.
type Runner interface {
// Start runs specs in place of what runs, as a reload does, and returns
// the tap of what runs then: on an error the earlier run's when the new
// pipelines did not build, nil when nothing runs
Start(specs []config.Spec, ask Ask) (*core.Tap, error)
Stop()
State() RunState
}
// Ask answers a password prompt a terminal would show; with generate, an
// empty answer draws a password, shown once
type Ask func(user string, generate bool) ([]byte, error)
// RunState is what a run reports beside its tap
type RunState struct {
Counts map[string]Counts // by pipeline
Ended bool // every pipeline's input ended
Log *core.Tap // lw's own log, nil while it does not reach the terminal
}
// Counts are a pipeline's entries: in from its sources, dropped by its flow,
// and dropped by sinks that could not take them
type Counts struct{ In, Filtered, SinksDropped uint64 }
func (s RunState) same(o RunState) bool {
return s.Ended == o.Ended && s.Log == o.Log && maps.Equal(s.Counts, o.Counts)
}
// run is the screen's side of a Runner. A worker makes the calls; what they
// bring back waits in later for a tick, which wake posts once until handled.
type run struct {
runner Runner
post func() // posts a tick to the screen
jobs chan func()
worker sync.Once
busy sync.WaitGroup
mu sync.Mutex
later []func(a *app)
posted atomic.Bool
gone chan struct{} // closed when the screen leaves: asks fail
ended sync.Once
fed atomic.Pointer[core.Tap] // the run's tap, for the watch
// the screen's goroutine only
op string // "starting" or "stopping" while a job runs
specs []config.Spec // what runs, nil while nothing does
tap *core.Tap
state RunState
leaving bool
}
func newRun(runner Runner, post func()) *run {
return &run{runner: runner, post: post, jobs: make(chan func(), 4), gone: make(chan struct{})}
}
// do runs job on the worker, after the jobs before it
func (r *run) do(job func()) {
r.worker.Do(func() {
go func() {
for job := range r.jobs {
job()
r.busy.Done()
}
}()
})
r.busy.Add(1)
r.jobs <- job
}
// send has f run on the screen's goroutine at the next tick
func (r *run) send(f func(a *app)) {
r.mu.Lock()
r.later = append(r.later, f)
r.mu.Unlock()
r.wake()
}
// wake posts a tick unless one is waiting
func (r *run) wake() {
if r.posted.CompareAndSwap(false, true) {
r.post()
}
}
// watch wakes the screen while a tap holds records, every drainEvery at
// most, and reads the run's state every so often, waking the screen only
// when it changed; until the screen leaves
func (r *run) watch(every time.Duration) {
pace, state := time.NewTicker(drainEvery), time.NewTicker(every)
defer pace.Stop()
defer state.Stop()
var last RunState
for {
select {
case <-r.gone:
return
case <-pace.C:
if holds(r.fed.Load()) || holds(last.Log) {
r.wake()
}
case <-state.C:
if st := r.runner.State(); !st.same(last) {
last = st
r.send(func(a *app) { a.live.state = st })
}
}
}
}
func holds(t *core.Tap) bool { return t != nil && len(t.Records()) > 0 }
// onSignals leaves on SIGTERM and SIGHUP (an ssh session's end), stopping
// what runs, so sinks flush and the terminal is restored. A closed stdout or
// stderr fails the write with EPIPE, which the run logs, instead of killing lw
// on screen; stop gives SIGPIPE back to the runtime.
func (r *run) onSignals() (stop func()) {
sigs, pipe := make(chan os.Signal, 1), make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM, syscall.SIGHUP)
signal.Notify(pipe, syscall.SIGPIPE)
go func() {
select {
case <-sigs:
r.send(func(a *app) { a.leave(Result{}) })
case <-r.gone:
}
}()
return func() { signal.Stop(sigs); signal.Stop(pipe) }
}
func (r *run) endAsks() { r.ended.Do(func() { close(r.gone) }) }
// close fails the asks, waits for the worker and stops what still runs
func (r *run) close() {
r.endAsks()
r.busy.Wait()
if r.runner != nil && !r.leaving {
r.runner.Stop()
}
close(r.jobs)
}
// active reports a run, or one starting
func (r *run) active() bool { return r.specs != nil || r.op != "" }
// ask is the Ask Start gets: the worker waits while the screen asks in a
// masked form, until it is answered or the screen leaves
func (r *run) ask(user string, generate bool) ([]byte, error) {
reply := make(chan []byte, 1)
r.send(func(a *app) { a.askPassword(user, generate, reply) })
select {
case pw := <-reply:
if pw == nil {
return nil, errors.New("no password given")
}
return pw, nil
case <-r.gone:
return nil, errors.New("the screen closed")
}
}
// tick runs what the worker and the state watch brought, then reads the
// taps into the view; before any key handling, so it neither clears the
// status nor reaches a text field
func (a *app) tick() {
r := a.live
r.posted.Store(false)
r.mu.Lock()
later := r.later
r.later = nil
r.mu.Unlock()
for _, f := range later {
f(a)
}
a.drain(drainBytes, r.state.Log, r.tap)
}
// tap runs the pipelines here, the screen staying, and gives the view the
// keys: t. After an edit it runs them again in place of the earlier ones, as
// a reload does.
func (a *app) tap() {
r := a.live
switch {
case r.runner == nil:
a.note = "t runs the pipelines here only from lw --tui"
return
case a.problem != nil:
a.status = "not valid yet: ! shows the problem"
return
case r.op != "":
a.note = r.op + "…"
return
}
specs := config.PipelineSpecs(a.comp.Pipelines)
if r.specs != nil && slices.Equal(specs, r.specs) {
a.viewing = true
return
}
r.op = "starting"
r.do(func() {
tap, err := r.runner.Start(specs, r.ask)
r.send(func(a *app) { a.started(specs, tap, err) })
})
}
// started applies Start's outcome: what runs, and the view, or why the new
// pipelines do not, on the part that failed. An earlier run that stopped is
// read whole first, so a rule in the view marks where the next began.
func (a *app) started(specs []config.Spec, tap *core.Tap, err error) {
r := a.live
if r.leaving {
return
}
if old := r.tap; old != nil && old != tap {
a.drain(math.MaxInt, old)
mark := "rerun "
if tap == nil {
mark = "stopped "
}
a.view.mark(mark + time.Now().Format("15:04:05"))
}
r.op, r.tap = "", tap
r.fed.Store(tap)
switch {
case err == nil:
r.specs, a.viewing = specs, true
case tap != nil:
a.fault(err, "not rerun: %s; the earlier pipelines still run")
default:
r.specs = nil
a.fault(err, "%s")
}
}
// fault chooses the part a start error names, placed as a problem is, and
// shows the error in format
func (a *app) fault(err error, format string) {
text := err.Error()
if pi, n, key, why, ok := compose.Locate(err); ok && pi >= 0 && pi < len(a.comp.Pipelines) {
if pi != a.pi {
a.pi, a.row = pi, [3]int{}
}
a.bar, a.inspect = false, false
a.choose(n)
text = why
if where := a.where(pi, n, key); where != "" {
text = where + ": " + why
}
}
a.dialog = textNotice("Not started", fmt.Sprintf(format, text))
}
// leave ends the screen with res once nothing runs: q, r and o's choices
func (a *app) leave(res Result) {
r := a.live
a.result, a.dialog = res, nil
if !r.active() {
a.done = true
return
}
r.leaving, r.op, a.note = true, "stopping", "stopping the pipelines…"
r.endAsks()
r.do(func() {
r.runner.Stop()
r.send(func(a *app) { a.done = true })
})
}
// runMark says on the bar what runs: a call underway, or the counts of the
// pipeline shown
func (a *app) runMark() (string, ui.Style) {
r := a.live
switch {
case r.op != "":
return r.op + "…", a.th.Muted
case r.specs == nil:
return "", a.th.Muted
}
c := r.state.Counts[a.pipeline().Name]
mark, style := string(a.font.arrow)+" running", a.th.Accent
if r.state.Ended {
mark, style = "input ended", a.th.Muted
}
mark += fmt.Sprintf(" · %d in · %d filtered", c.In, c.Filtered)
if c.SinksDropped > 0 {
mark += fmt.Sprintf(" · %d sinks dropped", c.SinksDropped)
}
if r.tap != nil && r.tap.Dropped() > 0 {
mark += fmt.Sprintf(" · screen missed %d", r.tap.Dropped())
}
return mark, style
}
+363
View File
@@ -0,0 +1,363 @@
package tui
import (
"errors"
"fmt"
"os"
"os/exec"
"slices"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/terminal"
)
// fakeRunner keeps the screen's calls; start, when set, answers Start
type fakeRunner struct {
mu sync.Mutex
specs [][]config.Spec
stops int
state RunState
start func(ask Ask) (*core.Tap, error)
}
func (f *fakeRunner) Start(specs []config.Spec, ask Ask) (*core.Tap, error) {
f.mu.Lock()
f.specs = append(f.specs, specs)
start := f.start
f.mu.Unlock()
if start != nil {
return start(ask)
}
return core.NewTap(1), nil
}
func (f *fakeRunner) Stop() {
f.mu.Lock()
defer f.mu.Unlock()
f.stops++
}
func (f *fakeRunner) State() RunState {
f.mu.Lock()
defer f.mu.Unlock()
return f.state
}
func (f *fakeRunner) calls() (starts, stops int) {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.specs), f.stops
}
// running is a screen on c whose runner is f, already running c, back on
// the diagram from the view t opened
func running(t *testing.T, c *compose.Composition, f *fakeRunner) *app {
t.Helper()
a := mono(c)
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
if a.live.specs == nil || !a.viewing {
t.Fatalf("t did not run, viewing %v: %T %s", a.viewing, a.dialog, a.status)
}
press(a, "t")
return a
}
// await ticks once the runner's side brought something back, as the woken
// screen would
func await(t *testing.T, a *app) {
t.Helper()
for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(time.Millisecond) {
a.live.mu.Lock()
n := len(a.live.later)
a.live.mu.Unlock()
if n > 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("nothing came back from the runner")
}
}
press(a, terminal.KeyNone)
}
// The tick is handled before any key: it clears no status and types nothing
// into the field being edited
func TestATickKeepsTheStatusAndTheField(t *testing.T) {
a := mono(pipeline(t, []string{"file"}, []string{"null"}))
press(a, terminal.KeyEnter, terminal.KeyEnter, "ab")
a.status = "refused"
press(a, terminal.KeyNone)
if a.status != "refused" || a.insp.field == nil || a.insp.field.Value() != "ab" {
t.Fatalf("status %q, field %v", a.status, a.insp.field)
}
}
// Wakes coalesce: one tick waits at a time, and handling it allows the next
func TestWakesCoalesce(t *testing.T) {
posts := 0
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live = newRun(nil, func() { posts++ })
a.live.wake()
a.live.wake()
if press(a, terminal.KeyNone); posts != 1 {
t.Fatalf("%d ticks posted for two wakes", posts)
}
if a.live.wake(); posts != 2 {
t.Fatal("a handled tick kept the next wake from posting")
}
}
// The state is read on, but the screen is woken only by a change
func TestTheStateWakesTheScreenOnlyWhenItChanges(t *testing.T) {
var posts sync.WaitGroup
f := &fakeRunner{state: RunState{Counts: map[string]Counts{"p": {In: 1}}}}
r := newRun(f, func() { posts.Done() })
posts.Add(1)
go r.watch(time.Millisecond)
defer r.endAsks()
posts.Wait()
time.Sleep(20 * time.Millisecond)
r.posted.Store(false)
time.Sleep(20 * time.Millisecond)
if r.posted.Load() {
t.Fatal("an unchanged state woke the screen")
}
posts.Add(1)
f.mu.Lock()
f.state = RunState{Counts: map[string]Counts{"p": {In: 2}}}
f.mu.Unlock()
posts.Wait()
}
// t runs only a valid composition
func TestTapRefusesAnInvalidComposition(t *testing.T) {
f := &fakeRunner{}
a := mono(pipeline(t, []string{"null"}, []string{"tcp"}))
a.live = newRun(f, func() {})
if press(a, "t"); a.status != "not valid yet: ! shows the problem" || a.live.op != "" {
t.Fatalf("status %q, op %q", a.status, a.live.op)
}
if starts, _ := f.calls(); starts != 0 {
t.Fatal("started an invalid composition")
}
}
// t runs the pipelines and marks the bar; unchanged, t only opens the view,
// and after an edit it runs the edited pipelines
func TestTapRerunsAfterAnEdit(t *testing.T) {
f := &fakeRunner{state: RunState{Counts: map[string]Counts{"p": {In: 7, Filtered: 2}}}}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
a.live.state = f.State()
first := config.PipelineSpecs(a.comp.Pipelines)
if top := text(render(a, 100, 24), 100)[0]; !strings.Contains(top, "running · 7 in · 2 filtered") || !slices.Equal(f.specs[0], first) {
t.Fatalf("%q, specs %v", top, f.specs)
}
if press(a, "t"); a.live.op != "" || !a.viewing {
t.Fatalf("t on what runs: op %q, viewing %v", a.live.op, a.viewing)
}
press(a, "t")
_, err := a.comp.Add(0, "sink", "console")
a.edit(err)
press(a, "t")
await(t, a)
if starts, _ := f.calls(); starts != 2 || slices.Equal(f.specs[1], first) || !slices.Equal(a.live.specs, f.specs[1]) {
t.Fatalf("rerun: %d starts, running %v", starts, a.live.specs)
}
}
// A rerun that does not build leaves the earlier pipelines running, and says
// so; one that fails once they stopped leaves nothing running
func TestAFailedRerunSaysWhatRuns(t *testing.T) {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
earlier, tap := a.live.specs, a.live.tap
notice := func() string { return strings.Join(text(render(a, 120, 30), 120), "\n") }
f.start = func(Ask) (*core.Tap, error) { return tap, errors.New("bad sink") }
a.edit(a.comp.Set(0, compose.Node{Role: "format"}, "type", "txt"))
press(a, "t")
await(t, a)
if !slices.Equal(a.live.specs, earlier) || a.live.tap != tap || !strings.Contains(notice(), "not rerun: bad sink; the earlier pipelines still run") {
t.Fatalf("running %v\n%s", a.live.specs, notice())
}
f.start = func(Ask) (*core.Tap, error) { return nil, errors.New("port taken") }
press(a, terminal.KeyEscape, "t")
await(t, a)
if a.live.specs != nil || a.live.tap != nil || strings.Contains(notice(), "still run") {
t.Fatalf("running %v\n%s", a.live.specs, notice())
}
}
// A start error chooses the part its path names, as a problem's does, and
// shows its text
func TestAStartErrorChoosesItsPart(t *testing.T) {
why := config.At(compose.Node{Role: "sink", ID: "console"}.Path(0), errors.New("failed to start sink console: write: broken pipe"))
f := &fakeRunner{start: func(Ask) (*core.Tap, error) { return nil, why }}
a := mono(pipeline(t, []string{"null"}, []string{"null", "console"}))
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
n, _ := a.node()
if screen := strings.Join(text(render(a, 120, 30), 120), " "); n != (compose.Node{Role: "sink", ID: "console"}) || !strings.Contains(screen, "sink console: write") {
t.Fatalf("on %+v\n%s", n, screen)
}
}
// q asks before it stops what runs, and quits once it stopped
func TestQuitAsksWhileRunning(t *testing.T) {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
press(a, "q")
if c, asks := a.dialog.(*confirm); !asks || !strings.Contains(c.question, "Stop the pipelines") {
t.Fatalf("q while running: %T", a.dialog)
}
press(a, "y")
await(t, a)
if _, stops := f.calls(); !a.done || stops != 1 {
t.Fatalf("done %v after %d stops", a.done, stops)
}
}
// r and o's choices stop the run before the screen leaves, and take no key
// meanwhile
func TestLeavingStopsTheRunFirst(t *testing.T) {
for _, keys := range []string{"r", "oc"} {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
if press(a, keys, "q"); a.done || a.dialog != nil {
t.Fatalf("%s left before stopping: %T", keys, a.dialog)
}
await(t, a)
if _, stops := f.calls(); !a.done || stops != 1 || a.result.Exit == Quit {
t.Fatalf("%s: done %v after %d stops, %+v", keys, a.done, stops, a.result)
}
}
}
// A password a terminal would be asked for is asked in a masked form; with
// generate, Enter alone draws one, shown once, and that is the answer
func TestPasswordsAreAskedMasked(t *testing.T) {
for _, generate := range []bool{false, true} {
var got []byte
f := &fakeRunner{start: func(ask Ask) (*core.Tap, error) {
pw, err := ask("alice", generate)
got = pw
return core.NewTap(1), err
}}
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
typed := "s3cret"
if generate {
typed = ""
}
press(a, typed)
if screen := strings.Join(text(render(a, 100, 30), 100), "\n"); !strings.Contains(screen, "Password for alice") ||
strings.Contains(screen, "s3cret") || !strings.Contains(screen, strings.Repeat("*", len(typed))) {
t.Fatalf("the form:\n%s", screen)
}
press(a, terminal.KeyEnter)
shown := strings.Join(text(render(a, 100, 30), 100), " ")
if generate {
press(a, terminal.KeyEnter) // read
}
await(t, a)
if a.live.specs == nil || len(got) == 0 || !generate && string(got) != typed || generate && !strings.Contains(shown, "shown this once: "+string(got)) {
t.Fatalf("generate %v: answered %q\n%s", generate, got, shown)
}
}
}
// A generated password stays on screen until a key closes its notice: the
// start goes on only then, so neither a start error nor the next ask hides it
func TestAGeneratedPasswordStaysUntilRead(t *testing.T) {
for name, then := range map[string]func(Ask) error{
"a start error": func(Ask) error { return errors.New("port taken") },
"the next ask": func(ask Ask) error { _, err := ask("bob", true); return err },
} {
answered := make(chan struct{})
f := &fakeRunner{start: func(ask Ask) (*core.Tap, error) {
if _, err := ask("alice", true); err != nil {
return nil, err
}
close(answered)
return core.NewTap(1), then(ask)
}}
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
press(a, terminal.KeyEnter)
select {
case <-answered:
t.Fatalf("%s: answered before the password was read", name)
case <-time.After(50 * time.Millisecond):
}
if press(a, terminal.KeyNone); !strings.Contains(strings.Join(text(render(a, 100, 30), 100), " "), "shown this once") {
t.Fatalf("%s: the password left the screen unread: %T", name, a.dialog)
}
press(a, "x")
<-answered
await(t, a)
if screen := strings.Join(text(render(a, 100, 30), 100), " "); strings.Contains(screen, "shown this once") {
t.Fatalf("%s: the notice outlived its key\n%s", name, screen)
}
a.live.close()
}
}
// On screen a closed stdout fails the write with EPIPE instead of killing lw
// with the terminal raw; leaving gives SIGPIPE back to the runtime
func TestAClosedStdoutFailsWritesOnScreen(t *testing.T) {
if os.Getenv("LW_TEST_CLOSED_STDOUT") != "" {
stop := newRun(nil, func() {}).onSignals()
_, err := os.Stdout.Write([]byte("x\n"))
fmt.Fprintln(os.Stderr, "on screen:", err)
stop()
os.Stdout.Write([]byte("x\n"))
fmt.Fprintln(os.Stderr, "lived on")
os.Exit(0)
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
r.Close()
defer w.Close()
var stderr strings.Builder
cmd := exec.Command(os.Args[0], "-test.run=^TestAClosedStdoutFailsWritesOnScreen$")
cmd.Env, cmd.Stdout, cmd.Stderr = append(os.Environ(), "LW_TEST_CLOSED_STDOUT=1"), w, &stderr
cmd.Run()
ws, _ := cmd.ProcessState.Sys().(syscall.WaitStatus)
if !strings.Contains(stderr.String(), "on screen: write /dev/stdout: broken pipe") || !ws.Signaled() || ws.Signal() != syscall.SIGPIPE {
t.Fatalf("%v\n%s", ws, stderr.String())
}
}
// SIGTERM and SIGHUP stop what runs and leave
func TestSignalsStopTheRunAndLeave(t *testing.T) {
for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGHUP} {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
stop := a.live.onSignals()
if err := syscall.Kill(os.Getpid(), sig); err != nil {
t.Fatal(err)
}
await(t, a)
await(t, a)
stop()
if _, stops := f.calls(); !a.done || stops != 1 {
t.Fatalf("%v: done %v after %d stops", sig, a.done, stops)
}
}
}
+52 -23
View File
@@ -10,49 +10,76 @@ import (
ui "github.com/lixenwraith/terminal/tui"
)
// look is a color tier's styles: the widgets' theme and each column's tint
// look is a color tier's styles: the widgets' theme, each column's tint, and
// the live view's levels by name, any other drawn as text
type look struct {
ui.Theme
Source, Flow, Sink ui.Style
Levels map[string]ui.Style
}
// The site's dark tokens; true color and 256 colors paint its background
// The website's dark tokens; true color and 256 colors paint its background
var (
siteBg = color.RGB{R: 0x14, G: 0x17, B: 0x1d}
sitePanel = color.RGB{R: 0x1c, G: 0x20, B: 0x28}
siteFg = color.RGB{R: 0xe3, G: 0xe7, B: 0xef}
siteMuted = color.RGB{R: 0x9a, G: 0xa3, B: 0xb5}
siteAccent = color.RGB{R: 0x6e, G: 0xa0, B: 0xff}
siteBg = color.RGB{R: 0x11, G: 0x11, B: 0x1b} // --dark-bg
siteFg = color.RGB{R: 0xff, G: 0xff, B: 0xff} // --text-color
)
var siteLook = look{
Theme: ui.Theme{
Text: ui.Style{Fg: siteFg, Bg: siteBg},
Muted: ui.Style{Fg: siteMuted},
Accent: ui.Style{Fg: siteAccent, Attr: terminal.AttrBold},
Selected: ui.Style{Bg: color.RGB{R: 0x26, G: 0x2c, B: 0x38}},
Input: ui.Style{Bg: sitePanel},
Muted: ui.Style{Fg: color.RGB{R: 0x94, G: 0xa3, B: 0xb8}}, // --muted-color
Accent: ui.Style{Fg: color.RGB{R: 0x8e, G: 0xa2, B: 0xff}, Attr: terminal.AttrBold}, // --accent-color
Selected: ui.Style{Bg: color.RGB{R: 0x1d, G: 0x1c, B: 0x3e}}, // --card-background-color
Input: ui.Style{Bg: color.RGB{R: 0x1a, G: 0x1b, B: 0x26}}, // --dark-surface
Cursor: ui.Style{Fg: siteBg, Bg: siteFg},
Error: ui.Style{Fg: color.RGB{R: 0xff, G: 0x6b, B: 0x6b}},
Border: ui.Style{Fg: color.RGB{R: 0x5a, G: 0x63, B: 0x75}},
Error: ui.Style{Fg: color.RGB{R: 0xf8, G: 0x71, B: 0x71}}, // --danger-color
Border: ui.Style{Fg: color.RGB{R: 0x7c, G: 0x7c, B: 0x82}}, // the diagram's --wire
},
Source: ui.Style{Fg: color.RGB{R: 0x56, G: 0xc8, B: 0xd8}},
Flow: ui.Style{Fg: color.RGB{R: 0xb1, G: 0x8c, B: 0xff}},
Sink: ui.Style{Fg: color.RGB{R: 0x6f, G: 0xcf, B: 0x8f}},
Source: ui.Style{Fg: color.RGB{R: 0x22, G: 0xd3, B: 0xee}}, // --hue-cyan
Flow: ui.Style{Fg: color.RGB{R: 0xa7, G: 0x8b, B: 0xfa}}, // --hue-violet
Sink: ui.Style{Fg: color.RGB{R: 0x34, G: 0xd3, B: 0x99}}, // --hue-green
Levels: map[string]ui.Style{
"TRACE": {Fg: color.RGB{R: 0x94, G: 0xa3, B: 0xb8}}, // --hue-slate
"DEBUG": {Fg: color.RGB{R: 0x60, G: 0xa5, B: 0xfa}}, // --hue-blue
"INFO": {Fg: color.RGB{R: 0x34, G: 0xd3, B: 0x99}}, // --ok-color
"WARN": {Fg: color.RGB{R: 0xfb, G: 0xbf, B: 0x24}}, // --warn-color
"ERROR": {Fg: color.RGB{R: 0xf8, G: 0x71, B: 0x71}, Attr: terminal.AttrBold}, // --danger-color
},
}
// console16 is the 16-color look, a text console's, where bold is bright:
// text is palette white rather than bold, which On would spread to every role
// drawn on it; muted the console's own grey; the cursor row a blue bar under
// which every role stays readable
var console16 = ui.Theme{
Text: ui.Style{Fg: color.RGB{R: color.ANSIBrightWhite}, Attr: terminal.AttrFg256 | terminal.AttrBgDefault},
Muted: ui.Style{Attr: terminal.AttrFgDefault},
Accent: ansi(color.ANSIBrightYellow),
Selected: ui.Style{Bg: color.RGB{R: color.ANSIBlue}, Attr: terminal.AttrBg256},
Input: ui.Theme16.Input,
Cursor: ui.Theme16.Cursor,
Error: ui.Theme16.Error,
Border: ui.Style{Attr: terminal.AttrFgDefault},
}
// ansi is a palette index the terminal shows in its own shade
func ansi(i uint8) ui.Style { return ui.Style{Fg: color.RGB{R: i}, Attr: terminal.AttrFg256} }
// looks holds one look per color tier: 16 colors and none keep the
// terminal's background
// terminal's background. Levels in 16 colors are picked by hand, as the
// quantizer turns the site's pastels grey; without color, bold and dim only
// add to the level's name.
var looks = map[terminal.ColorMode]look{
terminal.ColorModeTrueColor: siteLook,
terminal.ColorMode256: siteLook,
terminal.ColorMode16: {Theme: ui.Theme16,
Source: ansi(color.ANSICyan), Flow: ansi(color.ANSIMagenta), Sink: ansi(color.ANSIGreen)},
terminal.ColorMode16: {Theme: console16,
Source: ansi(color.ANSICyan), Flow: ansi(color.ANSIBrightMagenta), Sink: ansi(color.ANSIGreen),
Levels: map[string]ui.Style{"TRACE": console16.Muted, "DEBUG": ansi(color.ANSICyan), "INFO": ansi(color.ANSIGreen),
"WARN": ansi(color.ANSIBrightYellow), "ERROR": ansi(color.ANSIBrightRed)}},
terminal.ColorModeNone: {Theme: ui.MonoTheme,
Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text},
Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text,
Levels: map[string]ui.Style{"TRACE": ui.MonoTheme.Muted, "DEBUG": ui.MonoTheme.Muted, "INFO": ui.MonoTheme.Text,
"WARN": ui.MonoTheme.Accent, "ERROR": ui.MonoTheme.Accent}},
}
// font is the marks one character set draws: the widgets' and the canvas's;
@@ -63,6 +90,8 @@ type font struct {
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
newline rune // a line break in a row of the live view
odd rune // in the live view, a rune a cell cannot show
valid string
invalid string
enter string
@@ -86,12 +115,12 @@ 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, box: ui.LineDashed,
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"}
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', newline: '↵', odd: '\uFFFD', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"}
cp437Font = font{Glyphs: ui.GlyphsCP437, box: ui.LineSingle,
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', valid: "√", invalid: "x", enter: "enter", space: "space",
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', newline: '¶', odd: '?', valid: "√", invalid: "x", enter: "enter", space: "space",
folds: map[rune]rune{'…': '.'}}
asciiFont = font{Glyphs: ui.GlyphsASCII, box: ui.LineASCII,
arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', valid: "ok", invalid: "x", enter: "enter", space: "space",
arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', newline: '|', odd: '?', valid: "ok", invalid: "x", enter: "enter", space: "space",
folds: map[rune]rune{'…': '.', '·': '-'}, ascii: true}
)
+107 -34
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"slices"
"strings"
"time"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
@@ -41,8 +42,9 @@ var modes = map[string][]terminal.ColorMode{
}
// Run composes c on the terminal until the user leaves; an empty c opens the
// preset menu. color is lw's color setting.
func Run(c *compose.Composition, color string) (Result, error) {
// preset menu. color is lw's color setting; runner runs the pipelines on t,
// and nothing runs once Run returns.
func Run(c *compose.Composition, color string, runner Runner) (Result, error) {
t := terminal.New(modes[color]...)
if err := t.Init(); err != nil {
return Result{}, err
@@ -52,14 +54,21 @@ func Run(c *compose.Composition, color string) (Result, error) {
return Result{}, err
}
a := newApp(c, looks[t.ColorMode()], fontFor(terminal.DetectColorMode()))
a.live = newRun(runner, func() { t.PostEvent(terminal.Event{Type: terminal.EventKey, Key: terminal.KeyNone}) })
defer a.live.close()
defer a.live.onSignals()()
if runner != nil {
go a.live.watch(time.Second)
}
w, h := t.Size()
cells := make([]terminal.Cell, w*h)
for !a.done {
cells := make([]terminal.Cell, w*h)
a.paint(cells, w, h)
t.Flush(cells, w, h)
switch ev := t.PollEvent(); ev.Type {
case terminal.EventResize:
w, h = ev.Width, ev.Height
cells = make([]terminal.Cell, w*h)
case terminal.EventClosed:
return Result{}, errors.New("the terminal closed")
default:
@@ -93,6 +102,9 @@ type app struct {
inspect bool // keys go to the inspector
insp inspector
dialog dialog // takes every key while open
live *run // what t runs
view view // what runs shows
viewing bool // keys go to the view
status string // the last edit's refusal
note string // what the last action did
problem error // the composition's problems, joined
@@ -102,7 +114,7 @@ type app struct {
}
func newApp(c *compose.Composition, l look, f font) *app {
a := &app{comp: c, look: l, th: l.Theme, font: f}
a := &app{comp: c, look: l, th: l.Theme, font: f, live: newRun(nil, func() {})}
a.th.Glyphs = f.Glyphs
a.insp.open = map[string]bool{}
if len(c.Pipelines) == 0 {
@@ -192,15 +204,27 @@ func (a *app) edit(err error) bool {
func (a *app) check() { a.problem = a.comp.Validate() }
// handle routes an event: an open dialog takes all, then the inspector, then
// the canvas
// handle routes an event: the tick first, which the input never sends (the
// terminal library swallows unknown keys), then an open dialog takes all,
// then the view, the inspector, the canvas; leaving, nothing more is taken
func (a *app) handle(ev terminal.Event) {
if ev.Type == terminal.EventKey || ev.Type == terminal.EventPaste {
switch {
case ev.Type == terminal.EventKey && ev.Key == terminal.KeyNone:
a.tick()
return
case a.live.leaving:
return
case ev.Type == terminal.EventKey || ev.Type == terminal.EventPaste:
a.status, a.note = "", ""
}
if a.dialog == nil && !a.viewing {
a.view.own = false // a key on the diagram gives the point back to its cursor
}
switch {
case a.dialog != nil:
a.dialog.handle(a, ev)
case a.viewing:
a.viewKey(ev)
case ev.Type == terminal.EventPaste && a.insp.field != nil:
a.insp.field.Paste(ev.Text)
case ev.Type == terminal.EventPaste:
@@ -263,13 +287,15 @@ func (a *app) globalKey(ev terminal.Event) {
case is(ev, terminal.KeyNone, "o"):
a.dialog = a.outputMenu()
case is(ev, terminal.KeyNone, "?"):
a.dialog = help(a.font)
a.dialog = help(a.font, a.viewing)
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, "t"):
a.tap()
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:
@@ -346,7 +372,7 @@ func (a *app) run() {
a.status = "not valid yet: ! shows the problem"
return
}
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
a.leave(Result{Exit: Start, Pipelines: a.comp.Pipelines})
}
// switchStage turns a flow stage on with its defaults, or off; a stage with
@@ -398,11 +424,15 @@ func (a *app) paste(text string) {
}
func (a *app) quit() {
if !a.changed {
a.done = true
return
leave := func(a *app) { a.leave(Result{}) }
switch {
case a.live.active():
a.dialog = &confirm{question: "Stop the pipelines and quit?", yes: leave}
case a.changed:
a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: leave}
default:
leave(a)
}
a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: func(a *app) { a.done = true }}
}
// showProblem chooses the part the first problem names and shows them all
@@ -447,8 +477,10 @@ func (a *app) choose(n compose.Node) {
}
}
// draw draws the screen: a title row, the canvas with the inspector beside
// it from 110 columns or below it, a status row, and any dialog over them
// draw draws the screen: a title row, the diagram, a status row, and any
// dialog over them. Once something ran, the live view takes the rows under
// the diagram from 40 rows, unless the inspector below it is in use, and the
// whole screen under that while it takes the keys.
func (a *app) draw(r ui.Region) {
r.FillStyle(a.th.Text)
status, style := a.bottom(r.W)
@@ -456,38 +488,67 @@ func (a *app) draw(r ui.Region) {
a.pi = min(a.pi, len(a.comp.Pipelines)-1)
a.node() // a delete can leave the row past its column's end
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))
a.drawInspector(body.Sub(r.W-iw, 0, iw, body.H))
} else {
ch := min(a.layout(r.W).h, body.H-min(10, body.H/2))
a.drawCanvas(body.Sub(0, 0, r.W, ch))
a.drawInspector(body.Sub(0, ch+1, r.W, body.H-ch-1))
body, l := r.Sub(0, 1, r.W, r.H-1-len(status)), a.layout(r.W)
a.view.sync(a.point(), a.live.tap)
switch watched := a.viewing || a.live.tap != nil || len(a.view.rows) > 0; {
case watched && r.H >= splitMin && (!a.inspect || l.inspector > 0):
top := l.h
if a.inspect {
top = max(top, body.H/2)
}
top = min(top, body.H*2/3)
a.drawDiagram(body.Sub(0, 0, r.W, top), l, false)
a.drawView(body.Sub(0, top, r.W, body.H-top))
case a.viewing:
a.drawView(body)
default:
a.drawDiagram(body, l, true)
}
}
for i, l := range status {
r.TextStyled(1, r.H-len(status)+i, l, style)
for i, row := range status {
r.TextStyled(1, r.H-len(status)+i, row, 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)
x := 4 + ui.RuneLen(status[0])
r.TextStyled(x, r.H-1, a.hints(r.W-x-1), a.th.Muted)
}
if p := a.view.prompt; a.viewing && p != nil {
row := r.Sub(0, r.H-1, r.W, 1)
row.FillStyle(a.th.Text)
row.TextStyled(1, 0, "/", a.th.Accent)
row.Sub(3, 0, r.W-4, 1).TextInput(p, "a pattern: lower case ignores case; empty shows all", true, a.th)
}
if a.dialog != nil {
a.dialog.draw(a, r)
}
}
// drawDiagram draws the canvas with the inspector beside it from 110
// columns, else, with below, under it
func (a *app) drawDiagram(r ui.Region, l layout, below bool) {
switch {
case l.inspector > 0:
a.drawCanvas(r.Sub(0, 0, l.w, r.H), l)
a.drawInspector(r.Sub(r.W-l.inspector, 0, l.inspector, r.H))
case !below:
a.drawCanvas(r, l)
default:
ch := min(l.h, r.H-min(10, r.H/2))
a.drawCanvas(r.Sub(0, 0, r.W, ch), l)
a.drawInspector(r.Sub(0, ch+1, r.W, r.H-ch-1))
}
}
// 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
// after them
func (a *app) drawBar(r ui.Region) {
th, f := a.th, a.font
full, hint := r, ""
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)
hint = "a add " + f.enter + " rename d delete"
r = r.Sub(0, 0, r.W-ui.RuneLen(hint)-2, 1)
}
}
@@ -513,8 +574,16 @@ func (a *app) drawBar(r ui.Region) {
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)
place := fmt.Sprintf("%d/%d", a.pi+1, len(a.comp.Pipelines))
r.TextStyled(x, 0, place, th.Muted)
x += ui.RuneLen(place) + 2
}
if mark, style := a.runMark(); mark != "" {
mark = ui.Truncate(mark, max(0, r.W-x))
r.TextStyled(x, 0, mark, style)
x += ui.RuneLen(mark) + 2
}
full.TextStyled(min(x+1, full.W-ui.RuneLen(hint)-1), 0, hint, th.Muted)
}
// paint draws the screen into cells, each rune one the font's terminal shows
@@ -552,10 +621,14 @@ func (a *app) bottom(w int) ([]string, ui.Style) {
return clip(ui.WrapText(text, w-2), 3), style
}
// hints are the status row's keys, shown while nothing else needs the row
// hints are the status row's keys in w columns, the view's while it takes
// them, shown after the status 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 := "a add d delete " + a.font.space + " on/off t tap p preset o output ? help"
if a.viewing {
keys = "/ filter 0-5 level v mode s point " + a.font.enter + " detail t back ? help"
}
if ui.RuneLen(keys) > w {
keys = "? help"
}
return keys
+186 -15
View File
@@ -8,6 +8,7 @@ import (
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
ui "github.com/lixenwraith/terminal/tui"
)
@@ -80,19 +81,20 @@ func focusKey(t *testing.T, a *app, name string) line {
}
// Each wire glyph's arms meet the cells beside it, whatever the count of
// sources and sinks and the width, and every source and sink is wired to
// the flow box's arrow
// sources and sinks and the width, and every source is wired from past its
// text to the flow box's arrow, and from the box to an arrow into each sink
func TestWiresJoinWhereTheyMeet(t *testing.T) {
arms := map[rune]uint8{'│': 5, '─': 10, '└': 3, '┌': 6, '├': 7, '┘': 9, '┴': 11, '┐': 12, '┤': 13, '┬': 14, '┼': 15, '►': 10}
steps := map[uint8][2]int{1: {0, -1}, 2: {1, 0}, 4: {0, 1}, 8: {-1, 0}}
opposite := map[uint8]uint8{1: 4, 2: 8, 4: 1, 8: 2}
for n := range 16 {
sources, sinks := slices.Repeat([]string{"null"}, n%4), slices.Repeat([]string{"null"}, n/4)
for _, w := range []int{64, 80, 109, 130} {
for _, screen := range []int{64, 80, 109, 130, 160, 240} {
a := mono(pipeline(t, sources, sinks))
l := a.layout(w)
l := a.layout(screen)
w := l.w
cells := make([]terminal.Cell, w*l.h)
a.drawCanvas(ui.NewRegion(cells, w, 0, 0, w, l.h))
a.drawCanvas(ui.NewRegion(cells, w, 0, 0, w, l.h), l)
glyph := func(x, y int) rune {
if x < 0 || x >= w || y < 0 || y >= l.h {
return ' '
@@ -124,13 +126,13 @@ func TestWiresJoinWhereTheyMeet(t *testing.T) {
walk(l.box.x-1, l.entry)
walk(l.box.x+l.box.w+1, l.exit)
for i := range sources {
if s := l.nodes[sourcesCol][i]; !reached[[2]int{s.x + s.w, s.y}] {
t.Errorf("%d sources, %d sinks, %d columns: source %d not wired", len(sources), len(sinks), w, i)
if s := l.nodes[sourcesCol][i]; !reached[[2]int{s.x + s.end + 1, s.y}] {
t.Errorf("%d sources, %d sinks, %d columns: source %d not wired from its text", len(sources), len(sinks), w, i)
}
}
for i := range sinks {
if s := l.nodes[sinksCol][i]; !reached[[2]int{s.x - 1, s.y}] {
t.Errorf("%d sources, %d sinks, %d columns: sink %d not wired", len(sources), len(sinks), w, i)
if s := l.nodes[sinksCol][i]; !reached[[2]int{s.x - 1, s.y}] || glyph(s.x-1, s.y) != '►' {
t.Errorf("%d sources, %d sinks, %d columns: sink %d not wired by an arrow", len(sources), len(sinks), w, i)
}
}
}
@@ -138,10 +140,17 @@ func TestWiresJoinWhereTheyMeet(t *testing.T) {
}
// The parts stack under 64 columns; from 64 they sit side by side with the
// inspector below, and from 110 the inspector sits beside them
// inspector below, split as ever, and from 110 the inspector sits beside
// them, taking what they leave up to 100 columns
func TestLayoutFollowsTheWidth(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
for w, want := range map[int]string{63: "stacked", 64: "below", 109: "below", 110: "beside"} {
for w, box := range map[int]int{64: 18, 80: 24, 109: 30} {
if l := a.layout(w); l.box.w != box || l.nodes[sourcesCol][0].x != 0 || l.wire != gutter {
t.Errorf("%d columns: the box %d wide, the sources from %d, wires %d", w, l.box.w, l.nodes[sourcesCol][0].x, l.wire)
}
}
beside := map[int]int{110: 44, 240: 100} // the inspector's width
for w, want := range map[int]string{63: "stacked", 64: "below", 109: "below", 110: "beside", 240: "beside"} {
rows := text(render(a, w, 40), w)
at := func(s string) (int, int) {
for y, r := range rows {
@@ -163,12 +172,94 @@ func TestLayoutFollowsTheWidth(t *testing.T) {
case ruleX != 0 || ruleY <= flowY:
got = "lost"
}
if got != want {
t.Errorf("%d columns: %s, want %s\n%s", w, got, want, strings.Join(rows, "\n"))
if got != want || got == "beside" && w-ruleX != beside[w] {
t.Errorf("%d columns: %s, want %s, inspector %d wide\n%s", w, got, want, w-ruleX, strings.Join(rows, "\n"))
}
}
}
// From 110 columns the parts take the widths their content asks, nothing cut
// short and a source's wire after its last word, with at most 16 columns of
// wire a side, and sit centred
func TestWideCanvasSizesToItsContent(t *testing.T) {
c := pipeline(t, []string{"file", "http_chain"}, []string{"null"})
if err := c.Set(0, compose.Node{Role: "source", ID: "file"}, "directory", "/var/log/app/nginx"); err != nil {
t.Fatal(err)
}
for _, w := range []int{160, 240, 320} {
a := mono(c)
l := a.layout(w)
src, sink := l.nodes[sourcesCol][0], l.nodes[sinksCol][0]
wire, left, right := l.box.x-src.x-src.w, src.x, l.w-sink.x-sink.w
var canvas []string
for _, row := range text(render(a, w, 40), w)[1 : 1+l.h] {
canvas = append(canvas, string([]rune(row)[:l.w]))
}
if screen := strings.Join(canvas, "\n"); strings.Contains(screen, "…") || !strings.Contains(screen, "http_chain listens ─") || wire > 16 || left-right > 1 || right-left > 1 {
t.Errorf("%d columns: wire %d, margins %d and %d\n%s", w, wire, left, right, screen)
}
}
}
// Beside the inspector a source or sink lists its options set, a row each and
// four at most, or "defaults"; below it, their values in a line
func TestNodesListTheirOptionsBesideTheInspector(t *testing.T) {
c := pipeline(t, []string{"file"}, []string{"null"})
for k, v := range map[string]string{"directory": "/srv", "pattern": "*.log", "check_interval_ms": "50", "from": "end", "raw": "true"} {
if err := c.Set(0, compose.Node{Role: "source", ID: "file"}, k, v); err != nil {
t.Fatal(err)
}
}
a := mono(c)
for _, tc := range []struct {
w, rows int
show []string
}{
{240, 5, []string{"█ directory ", "▌ defaults"}},
{100, 2, []string{"/srv *.log"}},
} {
rows := text(render(a, tc.w, 30), tc.w)
x, blocks := a.layout(tc.w).nodes[sourcesCol][0].x, 0
for _, row := range rows {
if []rune(row)[x] == a.font.barOn {
blocks++
}
}
screen := strings.Join(rows, "\n")
for _, want := range tc.show {
if blocks != tc.rows || !strings.Contains(screen, want) {
t.Errorf("%d columns: %d rows, want %d and %q\n%s", tc.w, blocks, tc.rows, want, screen)
}
}
}
}
// The inspector's help sits two rows under its last key, not at the foot
func TestInspectorHelpSitsUnderItsKeys(t *testing.T) {
a, w := mono(pipeline(t, []string{"file"}, []string{"null"})), 240
press(a, terminal.KeyEnter)
var rows []string
for _, row := range text(render(a, w, 67), w) {
rows = append(rows, string([]rune(row)[a.layout(w).w+1:]))
}
help, _ := a.help(a.lines()[a.insp.cursor])
y := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, help) })
if y < 3 || strings.TrimSpace(rows[y-1]) != "" || strings.TrimSpace(rows[y-2]) == "" {
t.Errorf("help on row %d\n%s", y, strings.Join(rows[:20], "\n"))
}
}
// Hints follow their text, three columns on, not the screen's far edge: the
// keys after the status, the bar's verbs after the pipelines
func TestHintsFollowTheirText(t *testing.T) {
a, w := mono(pipeline(t, []string{"null"}, []string{"null"})), 320
foot := text(render(a, w, 24), w)[23]
press(a, terminal.KeyUp)
if top := text(render(a, w, 24), w)[0]; !strings.HasPrefix(foot, " "+a.font.valid+" valid a add") || !strings.Contains(top, "● p a add") {
t.Errorf("%q\n%q", top, foot)
}
}
// Without color, the chosen part still changes a glyph: a source's or a
// sink's bar, a stage's on or off mark, an empty column's slot
func TestSelectionChangesAGlyph(t *testing.T) {
@@ -181,7 +272,7 @@ func TestSelectionChangesAGlyph(t *testing.T) {
l := a.layout(80)
at := func(col, row int) rune {
cells := make([]terminal.Cell, 80*l.h)
a.drawCanvas(ui.NewRegion(cells, 80, 0, 0, 80, l.h))
a.drawCanvas(ui.NewRegion(cells, 80, 0, 0, 80, l.h), l)
s := l.nodes[col][row]
return cells[s.y*80+s.x].Rune
}
@@ -448,6 +539,72 @@ func TestOnlyFullColorPaintsTheBackground(t *testing.T) {
}
}
// The cursor row is one background under every role, or one reverse bar
// without color: each cell of the chosen part's rows, but its tint block,
// takes the Selected style, and a source's bar ends where its leader starts
func TestTheCursorRowIsOneBackground(t *testing.T) {
c := pipeline(t, []string{"file"}, []string{"null"})
if err := c.Set(0, compose.Node{Role: "source", ID: "file"}, "directory", "/srv"); err != nil {
t.Fatal(err)
}
for _, mode := range []terminal.ColorMode{terminal.ColorModeNone, terminal.ColorMode16, terminal.ColorModeTrueColor} {
a := newApp(c, looks[mode], unicodeFont)
bar := func(cell terminal.Cell) bool {
if mode == terminal.ColorModeNone {
return cell.Attrs&terminal.AttrReverse != 0
}
return cell.Bg == a.th.Selected.Bg && cell.Attrs&(terminal.AttrBgColor|terminal.AttrReverse) == a.th.Selected.Attr&terminal.AttrBgColor
}
for _, w := range []int{80, 240} {
for _, at := range [][2]int{{sourcesCol, 0}, {flowCol, 1}} {
a.col, a.row[at[0]] = at[0], at[1]
cells, s := render(a, w, 30), a.layout(w).nodes[at[0]][at[1]]
for y := 1 + s.y; y < 1+s.y+s.h; y++ {
end := s.x + s.w
if at[0] == sourcesCol && y == 1+s.y {
end = s.x + s.end + 1
}
for x := s.x + 1; x < s.x+s.w; x++ {
if cell := cells[y*w+x]; bar(cell) != (x < end) {
t.Fatalf("mode %v, %d columns: %q at %d,%d is %+v", mode, w, cell.Rune, x, y, cell)
}
}
}
}
}
}
}
// The chosen part's tint block keeps its color on the cursor bar in every
// tier: drawn in its tint, never reversed
func TestTheChosenBlockKeepsItsTint(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"})
if err := c.Set(0, compose.Node{Role: "format"}, "type", "txt"); err != nil {
t.Fatal(err)
}
for mode, l := range looks {
a := newApp(c, l, unicodeFont)
for col, tint := range []ui.Style{l.Source, l.Flow, l.Sink} {
a.col, a.row[flowCol] = col, 2
cells, s := render(a, 80, 24), a.layout(80).nodes[col][a.row[col]]
if cell := cells[(1+s.y)*80+s.x]; cell.Rune != a.font.barOn || cell.Fg != tint.Fg || cell.Attrs&(terminal.AttrFgColor|terminal.AttrReverse) != tint.Attr&terminal.AttrFgColor {
t.Errorf("mode %v, column %d: %+v", mode, col, cell)
}
}
}
}
// 256 colors keep the site look's panel, edited field and cursor bar apart
func TestSiteShadesStayApartIn256Colors(t *testing.T) {
l, shades := looks[terminal.ColorMode256], map[uint8]bool{}
for _, c := range []color.RGB{l.Text.Bg, l.Input.Bg, l.Selected.Bg} {
shades[terminal.RGBTo256(c)] = true
}
if len(shades) != 3 {
t.Errorf("%v", shades)
}
}
// ASCII outside a UTF-8 locale, CP437 on a text console, else Unicode
func TestFontFollowsTheLocale(t *testing.T) {
for _, c := range []struct {
@@ -596,13 +753,27 @@ func TestConfirmAnswersOnEnter(t *testing.T) {
}
}
// The canvas scrolls to keep the chosen part in view, its bar solid
// The canvas scrolls to keep the chosen part in view, its bar solid on every
// row it takes
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)
}
c := pipeline(t, slices.Repeat([]string{"file"}, 4), []string{"null"})
a = mono(c)
for _, n := range a.nodes(sourcesCol) {
for k, v := range map[string]string{"directory": "/srv", "pattern": "*.log", "check_interval_ms": "50"} {
if err := c.Set(0, n, k, v); err != nil {
t.Fatal(err)
}
}
}
a.row[sourcesCol] = 3
if screen := strings.Join(text(render(a, 240, 14), 240), "\n"); strings.Count(screen, string(a.font.barOn)) != 4 {
t.Fatalf("the fourth source is not whole in view:\n%s", screen)
}
}
// A notice taller than the screen scrolls with j and k and says where it is
+859
View File
@@ -0,0 +1,859 @@
package tui
import (
"fmt"
"regexp"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/logline"
"github.com/lixenwraith/terminal"
ui "github.com/lixenwraith/terminal/tui"
)
const (
splitMin = 40 // the shortest screen with the diagram over the live view
ringBytes = 32 << 20 // what the view's rows may hold
drainBytes = 1 << 20 // what a tick reads from the taps; the rest waits for the next
drainEvery = 100 * time.Millisecond
detailBeside = 160 // the narrowest view with the detail beside its rows
rowBytes = 400 // a row's own fields, counted beside its text
fieldBytes = 32 // a field's two string headers
)
// passed ranks the verdict of an entry no stage dropped: past every stage
const passed = 1 << 30
// stages are the flow's stages in the order entries pass them
var stages = func() (out []string) {
for _, s := range config.FlowStages() {
out = append(out, s.Key)
}
return out
}()
// rank orders a verdict along the flow: the stage that dropped the entry, a
// filter by its index, or passed
func rank(stage string, index int) int {
if stage == "" {
return passed
}
return slices.Index(stages, stage)<<16 | min(max(index, 0), 1<<16-1)
}
// row is an entry the view holds, read once through logline on arrival into
// text of its own, or a rule across the rows where a run ended
type row struct {
logline.Line // Level as written when outside core.Levels (PROC)
seq uint64
at time.Time
level int // in core.Levels, -1 for none or one outside them
fields string // Fields as k=v, as the list shows them
pipe, source string // pipe is "" for lw's own log and rules
stage string
index, rank int
payload []byte
size int // what it holds: its text, payload and rowBytes
mark string // a rule's text
}
// view is the live view: rows bounded by what they hold, and the seqs of
// those the filters pass, oldest first. The screen's goroutine alone uses it.
type view struct {
rows []row
bytes int
next uint64
shown []uint64
cursor int // in shown
scroll int
paused bool // the cursor keeps its row; else it is on the newest
detail bool
dscroll int
tagW int
listH int
mode int
floor int // 0 shows every row, n those of core.Levels[n-1] and above
own bool // the point is lw's own log until the canvas cursor moves
pat pattern
prompt *ui.TextFieldState // the pattern being typed
built query // what shown holds
}
// query is what picks the rows shown
type query struct {
at point
mode, floor int
pattern string
}
// verdicts are what the view shows at its point: all that reaches it, what
// passed it, what it dropped
var verdicts = [...]string{"all", "passed", "dropped"}
const (
modeAll = iota
modePassed
modeDropped
)
// 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.
type point struct {
pipe, name 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" })
// 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
// heartbeat only at the sinks' and its own
func (p point) shows(r *row, mode int) bool {
switch {
case r.pipe == "":
return p.own || r.level >= lwShown
case r.pipe != p.pipe || p.one && r.source != p.source, r.source == "" && p.reach != passed:
return false
case mode == modeDropped:
return p.lo <= r.rank && r.rank <= p.hi
case mode == modePassed:
return r.rank > p.hi
}
return r.rank >= p.reach
}
// needsDrops reports whether the point in mode shows entries the flow
// dropped, which the tap then takes too
func (p point) needsDrops(mode int) bool {
switch mode {
case modeDropped:
return p.lo <= p.hi
case modePassed:
return p.hi+1 < passed
}
return p.reach < passed
}
// 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}
switch {
case a.view.own:
return point{name: "lw's own log", own: true, reach: passed, lo: passed, hi: passed - 1}
case len(a.comp.Pipelines) == 0:
return p
}
p.pipe = a.pipeline().Name
n, ok := a.node()
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}
}
}
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
case n.Role == "heartbeat":
p.name, p.one, p.lo, p.hi = "heartbeat", 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)
}
return p
}
// take adds a tapped record as a row and returns what the row holds
func (v *view) take(t core.Tapped) int {
e := t.Event.Entry
r := row{Line: logline.Read(e), pipe: t.Pipeline, source: t.Source,
stage: t.Stage, index: t.Index, rank: rank(t.Stage, t.Index), payload: t.Event.Payload}
r.at = r.Time
for _, at := range []time.Time{e.Time, t.Event.Time, time.Now()} {
if r.at.IsZero() {
r.at = at
}
}
r.level = slices.IndexFunc(core.Levels, func(l core.Level) bool { return l.Level == r.Level })
var b strings.Builder
for _, f := range r.Fields {
if b.Len() > 0 {
b.WriteByte(' ')
}
if f.Key != "" {
b.WriteString(f.Key + "=")
}
b.WriteString(f.Value)
}
r.fields = b.String()
r.size = rowBytes + r.own() + len(r.fields) + len(r.payload)
v.tagW = max(v.tagW, ui.RuneLen(r.Tag))
v.add(r)
return r.size
}
// own copies the line's text, which logline cuts from the entry's, so a row
// pins no entry and its size is what it holds; it returns those bytes
func (r *row) own() int {
l := &r.Line
for _, s := range []*string{&l.Stamp, &l.Level, &l.Tag, &l.Message, &l.Layout} {
*s = strings.Clone(*s)
}
n := len(l.Stamp) + len(l.Level) + len(l.Tag) + len(l.Message) + len(l.Layout)
fields := make([]logline.Field, len(l.Fields))
for i, f := range l.Fields {
fields[i] = logline.Field{Key: strings.Clone(f.Key), Value: strings.Clone(f.Value)}
n += fieldBytes + len(f.Key) + len(f.Value)
}
l.Fields = fields
return n
}
// 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}) }
// add appends r, then evicts the oldest rows past ringBytes; the cursor
// keeps its row, or takes the oldest kept when its own went
func (v *view) add(r row) {
r.seq = v.next
v.next++
v.rows = append(v.rows, r)
v.bytes += r.size
if v.passes(&v.rows[len(v.rows)-1]) {
v.shown = append(v.shown, r.seq)
}
for v.bytes > ringBytes && len(v.rows) > 1 {
old := v.rows[0].seq
v.bytes -= v.rows[0].size
v.rows[0] = row{} // the array keeps no evicted text
v.rows = v.rows[1:]
if len(v.shown) > 0 && v.shown[0] == old {
v.shown = v.shown[1:]
v.cursor, v.scroll = max(0, v.cursor-1), max(0, v.scroll-1)
}
}
}
// passes reports whether the query shown was built for shows r; a rule shows
// always
func (v *view) passes(r *row) bool {
q := v.built
switch {
case r.mark != "":
return true
case !q.at.shows(r, q.mode), q.floor > 0 && r.level < q.floor-1:
return false
}
return v.pat.match(r.Message) || v.pat.match(r.fields) || v.pat.match(r.Tag)
}
// sync rebuilds the rows shown when the point or a filter changed, and has
// the tap take drops while they show. A paused cursor stays on its row, and
// that on its screen row; so does a following one when a filter changed, which
// pauses it unless its row is still the newest.
func (v *view) sync(at point, tap *core.Tap) {
if tap != nil {
tap.All.Store(at.needsDrops(v.mode))
}
q, was := query{at, v.mode, v.floor, v.pat.src}, v.built
if q == was {
return
}
v.built = q
f := v.focused()
line := v.cursor - v.scroll
v.shown = v.shown[:0]
for i := range v.rows {
if v.passes(&v.rows[i]) {
v.shown = append(v.shown, v.rows[i].seq)
}
}
if f != nil && (v.paused || q.pattern != was.pattern || q.floor != was.floor) {
v.cursor, _ = slices.BinarySearch(v.shown, f.seq)
v.scroll = v.cursor - line
v.paused = v.paused || v.cursor < len(v.shown)-1
}
v.settle()
}
// settle puts the cursor on the newest row while following, and in range
func (v *view) settle() {
if !v.paused {
v.cursor = len(v.shown) - 1
}
v.cursor = max(0, min(v.cursor, len(v.shown)-1))
}
// move moves the cursor d rows: off the newest pauses, onto it follows
func (v *view) move(d int) {
v.settle()
v.cursor = max(0, min(v.cursor+d, len(v.shown)-1))
v.paused, v.dscroll = v.cursor < len(v.shown)-1, 0
}
// focused is the cursor's row, nil while none shows
func (v *view) focused() *row {
if len(v.shown) == 0 {
return nil
}
v.settle()
return &v.rows[v.shown[v.cursor]-v.rows[0].seq]
}
func (v *view) clear() {
v.rows, v.shown, v.bytes, v.cursor, v.scroll, v.paused = nil, nil, 0, 0, 0, false
}
// drain reads taps into the view until they are empty or it read budget
// bytes; what is left waits for the next tick
func (a *app) drain(budget int, taps ...*core.Tap) {
for budget > 0 {
took := false
for _, t := range taps {
if t == nil {
continue
}
select {
case rec := <-t.Records():
budget -= a.view.take(rec)
took = true
default:
}
}
if !took {
return
}
}
}
// viewKey takes the keys of the live view; the composer's stay inert but for
// quitting, help, running, printing and the pipelines
func (a *app) viewKey(ev terminal.Event) {
v := &a.view
if v.prompt != nil {
a.promptKey(ev)
return
}
switch {
case ev.Type != terminal.EventKey:
case is(ev, terminal.KeyUp, "k"):
v.move(-1)
case is(ev, terminal.KeyDown, "j"):
v.move(1)
case is(ev, terminal.KeyPageUp, ""):
v.move(-ui.PageDelta(v.listH))
case is(ev, terminal.KeyPageDown, ""):
v.move(ui.PageDelta(v.listH))
case is(ev, terminal.KeyHome, "g"):
v.move(-len(v.shown))
case is(ev, terminal.KeyEnd, "Gf"):
v.paused = false
case is(ev, terminal.KeyEnter, ""):
v.detail, v.dscroll = !v.detail, 0
case is(ev, terminal.KeyNone, "J"):
v.dscroll++
case is(ev, terminal.KeyNone, "K"):
v.dscroll = max(0, v.dscroll-1)
case is(ev, terminal.KeyNone, "/"):
v.prompt = ui.NewTextFieldState(v.pat.src)
case is(ev, terminal.KeyNone, "012345"):
v.floor = int(ev.Rune - '0')
case is(ev, terminal.KeyNone, "v"):
v.mode = (v.mode + 1) % len(verdicts)
case is(ev, terminal.KeyNone, "s"):
a.dialog = a.pointMenu()
case is(ev, terminal.KeyNone, "c"):
v.clear()
case is(ev, terminal.KeyNone, "i"):
a.inspectDrop()
case is(ev, terminal.KeyEscape, "") && v.detail:
v.detail = false
case is(ev, terminal.KeyEscape, "t"):
a.viewing = false
case is(ev, terminal.KeyCtrlC, "q?ro[]"):
a.globalKey(ev)
}
}
// promptKey types the pattern; Enter shows the rows matching it, all when it
// is empty
func (a *app) promptKey(ev terminal.Event) {
v := &a.view
switch {
case ev.Type == terminal.EventPaste:
v.prompt.Paste(ev.Text)
case ev.Type != terminal.EventKey:
case is(ev, terminal.KeyEscape, ""):
v.prompt = nil
case is(ev, terminal.KeyEnter, ""):
p, err := newPattern(v.prompt.Value())
if v.prompt = nil; err != nil {
a.status = "pattern: " + err.Error()
return
}
v.pat = p
default:
v.prompt.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
}
}
// inspectDrop opens the inspector on the stage that dropped the focused row
func (a *app) inspectDrop() {
w := a.view.focused()
pi := -1
if w != nil {
pi = slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == w.pipe })
}
switch {
case w == nil || pi < 0:
a.note = "i inspects the stage that dropped an entry"
return
case w.stage == "":
a.note = "it passed every stage"
return
}
n := compose.Node{Role: w.stage}
if w.stage == "filters" {
n.Index = w.index
}
a.pi, a.viewing = pi, false
a.choose(n)
a.inspectAt(func(l line) bool { return l.node == n })
}
// 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 {
type at struct {
pi, col int
n compose.Node
own bool
}
var options []ui.Option
var ats []at
for pi, p := range a.comp.Pipelines {
add := func(name, hint string, col int, n compose.Node) {
if len(a.comp.Pipelines) > 1 {
name = p.Name + " · " + name
}
options = append(options, ui.Option{Name: name, Hint: hint})
ats = append(ats, at{pi: pi, col: col, n: n})
}
for _, s := range p.PluginSources {
add("source "+s.ID, "its entries", sourcesCol, compose.Node{Role: "source", ID: s.ID})
}
for _, s := range stages {
hint := "what reaches it, passes it, or it drops"
if s == "heartbeat" {
hint = "its heartbeats"
}
add(s, hint, flowCol, compose.Node{Role: s})
}
add("sinks", "what the sinks got", sinksCol, compose.Node{})
}
if a.live.state.Log != nil {
options = append(options, ui.Option{Name: "lw's own log", Hint: "its records alone, debug too"})
ats = append(ats, at{own: true})
}
return &chooser{title: "Look at", list: ui.NewOptionListState(options), pick: func(a *app, i int) {
t := ats[i]
if a.view.own = t.own; t.own {
return
}
a.pi, a.col, a.row, a.inspect, a.bar = t.pi, t.col, [3]int{}, false, false
if t.n.Role != "" {
a.choose(t.n)
}
}}
}
// drawView draws the live view: a rule naming its point and filters, the
// rows, and the focused row's detail beside them from 160 columns, else
// under them
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 {
title += " · " + verdicts[v.mode]
}
if len(a.comp.Pipelines) > 1 && v.built.at.pipe != "" {
title = v.built.at.pipe + " · " + title
}
if v.floor > 0 {
title += " · " + core.Levels[v.floor-1].Level + " up"
}
if v.pat.src != "" {
title += " · /" + 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)
if a.viewing {
r.TextStyled(0, 0, string(th.Glyphs.Focus), th.Accent)
}
list := r.Sub(0, 1, r.W, r.H-1)
if !v.detail {
a.drawRows(list)
return
}
if r.W >= detailBeside {
w := r.W * 62 / 100
a.drawRows(list.Sub(0, 0, w, list.H))
a.drawDetail(list.Sub(w+1, 0, list.W-w-1, list.H))
return
}
h := list.H - list.H*2/5
a.drawRows(list.Sub(0, 0, list.W, h))
a.drawDetail(list.Sub(0, h, list.W, list.H-h))
}
func (a *app) drawRows(r ui.Region) {
v := &a.view
v.listH = r.H
if len(v.shown) == 0 {
r.TextStyled(2, 0, "nothing here yet", a.th.Muted)
return
}
v.settle()
v.scroll = ui.AdjustScroll(v.cursor, ui.ClampScroll(v.scroll, r.H, len(v.shown)), r.H, len(v.shown))
tw, lw, gw := v.widths(r.W)
first := v.rows[0].seq
for y := 0; y < r.H && v.scroll+y < len(v.shown); y++ {
i := v.scroll + y
a.drawRow(r.Sub(0, y, r.W, 1), &v.rows[v.shown[i]-first], tw, lw, gw, i == v.cursor)
}
}
// widths are a row's time, level and tag columns w wide; each drops as w
// narrows, so none overlaps the message
func (v *view) widths(w int) (tw, lw, gw int) {
switch {
case w >= 60:
tw = 12
case w >= 40:
tw = 8
}
switch {
case w >= 100:
lw = 5
case w >= 24:
lw = 1
}
if w >= 50 {
gw = max(6, min(16, v.tagW))
}
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.
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 }
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))
}
if w.mark != "" {
dash := "─"
if f.ascii {
dash = "-"
}
text := dash + dash + " " + w.mark + " "
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
if tw > 0 {
r.TextStyled(x, 0, w.at.Local().Format("15:04:05.000")[:tw], on(th.Muted))
x += tw + 1
}
if lw > 0 {
style, ok := a.look.Levels[w.Level]
if !ok {
style = th.Text
}
r.TextStyled(x, 0, f.flat(w.Level, lw), on(style))
x += lw + 1
}
if gw > 0 {
r.TextStyled(x, 0, f.flat(w.Tag, gw), on(a.look.Source))
x += gw + 1
}
msg := f.flat(w.Message, r.W-x)
r.TextStyled(x, 0, msg, on(th.Text))
if x += ui.RuneLen(msg) + 2; x < r.W {
r.TextStyled(x, 0, f.flat(w.fields, r.W-x), on(th.Muted))
}
}
// dline is a line of the detail: a key, muted, beside its text
type dline struct {
key, text string
style ui.Style
}
// drawDetail draws the focused row whole, scrolled by J and K
func (a *app) drawDetail(r ui.Region) {
v, th := &a.view, a.th
w := v.focused()
hint := "J K scroll"
if w != nil && w.pipe != "" && w.stage != "" {
hint = "i inspects it " + 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)
}
}
// 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
func (a *app) details(w *row, width int) []dline {
th := a.th
var out []dline
put := func(key string, keyW int, value string, style ui.Style) {
for i, s := range ui.WrapText(escape(value), max(1, width-keyW-1)) {
if i > 0 {
key = ""
}
out = append(out, dline{ui.PadRight(ui.Truncate(escape(key), keyW), keyW), s, style})
}
}
put("time", 8, w.at.Local().Format("2006-01-02 15:04:05.000 MST"), th.Text)
if w.Stamp != "" {
put("written", 8, w.Stamp, th.Muted)
}
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("layout", 8, w.Layout, th.Muted)
put("message", 8, w.Message, th.Text)
keyW := 8
for _, f := range w.Fields {
keyW = max(keyW, ui.RuneLen(f.Key))
}
for _, f := range w.Fields {
put(f.Key, min(keyW, width/3), f.Value, th.Text)
}
if w.payload != nil {
out = append(out, dline{text: "as sinks receive it", style: th.Accent})
put("", 0, strings.TrimSuffix(string(w.payload), "\n"), th.Text)
}
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
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
}
// verdict says what the flow did with a row: passed, or the stage, and for a
// filter the patterns it has now, that dropped it
func (a *app) verdict(w *row) string {
switch w.stage {
case "":
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 fs := a.comp.Pipelines[pi].Flow.Filters; w.index < len(fs) {
text += ", " + string(fs[w.index].Type) + " " + strings.Join(fs[w.index].Patterns, " | ")
}
}
return text
}
return "dropped by " + w.stage
}
// oneCell reports whether a cell shows the rune r, decoded from size bytes,
// as itself: not one a terminal acts on (logline.Control), and one taking a
// column of its own
func oneCell(r rune, size int) bool {
if r >= ' ' && r < 0x7f {
return true
}
return !logline.Control(r, size) && !unicode.In(r, unicode.Cc, unicode.Cf, unicode.Mn, unicode.Me, unicode.Mc, wideOrJoining)
}
// flat is s on one row of at most w cells: a line break as the font's mark,
// a tab as a space, what a cell cannot show as the font's placeholder, and
// '…' where it is cut
func (f font) flat(s string, w int) string {
if w <= 0 {
return ""
}
out := make([]rune, 0, min(w, len(s)))
i := 0
for i < len(s) && len(out) < w {
r, n := utf8.DecodeRuneInString(s[i:])
i += n
switch {
case r == '\n':
r = f.newline
case r == '\t':
r = ' '
case !oneCell(r, n):
r = f.odd
}
out = append(out, r)
}
if i < len(s) && w > 1 {
out[w-1] = '…'
}
return string(out)
}
// escape writes each rune a cell cannot show as a \u escape and each invalid
// byte as a \x one; line breaks stay, for the detail to keep them
func escape(s string) string {
var b strings.Builder
for i := 0; i < len(s); {
r, n := utf8.DecodeRuneInString(s[i:])
switch {
case r == '\n' || oneCell(r, n):
b.WriteString(s[i : i+n])
case r == utf8.RuneError && n == 1:
fmt.Fprintf(&b, `\x%02x`, s[i])
case r > 0xffff:
fmt.Fprintf(&b, `\U%08X`, r)
default:
fmt.Fprintf(&b, `\u%04X`, r)
}
i += n
}
return b.String()
}
// pattern is a smart-case match: all lower case ignores case. Without
// regexp metacharacters an ASCII pattern is a substring, found without
// allocating; any other is RE2.
type pattern struct {
src, lit string
fold bool
re *regexp.Regexp
}
func newPattern(s string) (pattern, error) {
p := pattern{src: s, lit: s, fold: s == strings.ToLower(s)}
if regexp.QuoteMeta(s) == s && !strings.ContainsFunc(s, func(r rune) bool { return r > unicode.MaxASCII }) {
return p, nil
}
expr := s
if p.fold {
expr = "(?i)" + s
}
re, err := regexp.Compile(expr)
p.re = re
return p, err
}
func (p pattern) match(s string) bool {
switch {
case p.re != nil:
return p.re.MatchString(s)
case p.fold:
return foldContains(s, p.lit)
}
return strings.Contains(s, p.lit)
}
// foldContains reports whether s holds the lower-case ASCII needle, in any
// case
func foldContains(s, needle string) bool {
for i := 0; i+len(needle) <= len(s); i++ {
j := 0
for j < len(needle) && lower(s[i+j]) == needle[j] {
j++
}
if j == len(needle) {
return true
}
}
return false
}
func lower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + 'a' - 'A'
}
return c
}
// wideOrJoining is what a terminal draws in two columns, East Asian wide and
// fullwidth and emoji presentation as of Unicode 17, and the letters a
// grapheme cluster joins to a neighbour (Hangul jamo, Prepend, SpacingMark and
// Extend ones); a range spans the unassigned code points between its runes
var wideOrJoining = &unicode.RangeTable{
R16: []unicode.Range16{
{0x0D4E, 0x0D4E, 1}, {0x0E33, 0x0E33, 1}, {0x0EB3, 0x0EB3, 1},
{0x1100, 0x11FF, 1}, {0x231A, 0x231B, 1}, {0x2329, 0x232A, 1}, {0x23E9, 0x23EC, 1},
{0x23F0, 0x23F0, 1}, {0x23F3, 0x23F3, 1}, {0x25FD, 0x25FE, 1}, {0x2614, 0x2615, 1},
{0x2630, 0x2637, 1}, {0x2648, 0x2653, 1}, {0x267F, 0x267F, 1}, {0x268A, 0x268F, 1},
{0x2693, 0x2693, 1}, {0x26A1, 0x26A1, 1}, {0x26AA, 0x26AB, 1}, {0x26BD, 0x26BE, 1},
{0x26C4, 0x26C5, 1}, {0x26CE, 0x26CE, 1}, {0x26D4, 0x26D4, 1}, {0x26EA, 0x26EA, 1},
{0x26F2, 0x26F3, 1}, {0x26F5, 0x26F5, 1}, {0x26FA, 0x26FA, 1}, {0x26FD, 0x26FD, 1},
{0x2705, 0x2705, 1}, {0x270A, 0x270B, 1}, {0x2728, 0x2728, 1}, {0x274C, 0x274C, 1},
{0x274E, 0x274E, 1}, {0x2753, 0x2755, 1}, {0x2757, 0x2757, 1}, {0x2795, 0x2797, 1},
{0x27B0, 0x27B0, 1}, {0x27BF, 0x27BF, 1}, {0x2B1B, 0x2B1C, 1}, {0x2B50, 0x2B50, 1},
{0x2B55, 0x2B55, 1}, {0x2E80, 0x303E, 1}, {0x3041, 0x3247, 1}, {0x3250, 0xA4C6, 1},
{0xA960, 0xA97C, 1}, {0xAC00, 0xD7FF, 1}, {0xF900, 0xFAFF, 1}, {0xFE10, 0xFE19, 1},
{0xFE30, 0xFE6B, 1}, {0xFF01, 0xFF60, 1}, {0xFF9E, 0xFF9F, 1}, {0xFFE0, 0xFFE6, 1},
},
R32: []unicode.Range32{
{0x111C2, 0x111C3, 1}, {0x113D1, 0x113D1, 1}, {0x1193F, 0x1193F, 1}, {0x11941, 0x11941, 1},
{0x11A84, 0x11A89, 1}, {0x11D46, 0x11D46, 1}, {0x11F02, 0x11F02, 1},
{0x16FE0, 0x1B2FB, 1}, {0x1D300, 0x1D376, 1}, {0x1F004, 0x1F004, 1}, {0x1F0CF, 0x1F0CF, 1},
{0x1F18E, 0x1F18E, 1}, {0x1F191, 0x1F19A, 1}, {0x1F1E6, 0x1F320, 1}, {0x1F32D, 0x1F335, 1},
{0x1F337, 0x1F37C, 1}, {0x1F37E, 0x1F393, 1}, {0x1F3A0, 0x1F3CA, 1}, {0x1F3CF, 0x1F3D3, 1},
{0x1F3E0, 0x1F3F0, 1}, {0x1F3F4, 0x1F3F4, 1}, {0x1F3F8, 0x1F43E, 1}, {0x1F440, 0x1F440, 1},
{0x1F442, 0x1F4FC, 1}, {0x1F4FF, 0x1F53D, 1}, {0x1F54B, 0x1F54E, 1}, {0x1F550, 0x1F567, 1},
{0x1F57A, 0x1F57A, 1}, {0x1F595, 0x1F596, 1}, {0x1F5A4, 0x1F5A4, 1}, {0x1F5FB, 0x1F64F, 1},
{0x1F680, 0x1F6C5, 1}, {0x1F6CC, 0x1F6CC, 1}, {0x1F6D0, 0x1F6D2, 1}, {0x1F6D5, 0x1F6DF, 1},
{0x1F6EB, 0x1F6EC, 1}, {0x1F6F4, 0x1F6FC, 1}, {0x1F7E0, 0x1F7F0, 1}, {0x1F90C, 0x1F93A, 1},
{0x1F93C, 0x1F945, 1}, {0x1F947, 0x1F9FF, 1}, {0x1FA70, 0x1FAF8, 1}, {0x20000, 0x3FFFF, 1},
},
}
+569
View File
@@ -0,0 +1,569 @@
package tui
import (
"encoding/json"
"fmt"
"math"
"slices"
"strings"
"testing"
"time"
"unsafe"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/terminal"
ui "github.com/lixenwraith/terminal/tui"
)
// tapped is an entry from a pipeline's source as its flow left it: dropped at
// stage and index, or passed with its payload when stage is ""
func tapped(pipe, source, stage string, index int, msg string) core.Tapped {
r := core.Tapped{Pipeline: pipe, Source: source, Stage: stage, Index: index,
Event: core.TransportEvent{Entry: core.LogEntry{Time: time.Unix(1, 0), Source: source, Message: msg}}}
if stage == "" {
r.Event.Payload = []byte(msg + "\n")
}
return r
}
// watching is a screen on c in look whose view takes the keys, drawn once so
// its rows are built for where the canvas cursor is
func watching(c *compose.Composition, l look) *app {
a := newApp(c, l, unicodeFont)
a.viewing = true
render(a, 80, 24)
return a
}
// shown is the messages of the rows the view shows
func shown(a *app) []string {
var out []string
for _, seq := range a.view.shown {
out = append(out, a.view.rows[seq-a.view.rows[0].seq].Message)
}
return out
}
// cursorRow is the screen row the view's pointer is on
func cursorRow(rows []string) (int, string) {
for y, r := range rows {
if strings.HasPrefix(r, string(unicodeFont.Glyphs.Pointer)) {
return y, r
}
}
return -1, ""
}
// The view follows the newest row; a move up pauses it on its row while rows
// arrive, and G, or moving down onto the newest, follows again
func TestTheViewFollowsUntilMovedUp(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
add := func(from, to int) {
for i := from; i < to; i++ {
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("row %03d", i)))
}
}
at := func() string {
_, r := cursorRow(text(render(a, 80, 24), 80))
return r
}
if add(0, 50); !strings.Contains(at(), "row 049") {
t.Fatalf("following: %q", at())
}
press(a, "k")
if add(50, 60); !strings.Contains(at(), "row 048") || !strings.Contains(text(render(a, 80, 24), 80)[1], "paused · 11 newer") {
t.Fatalf("paused: %q\n%s", at(), text(render(a, 80, 24), 80)[1])
}
if press(a, "G"); !strings.Contains(at(), "row 059") {
t.Fatalf("G: %q", at())
}
press(a, "k", "j")
if add(60, 61); !strings.Contains(at(), "row 060") {
t.Fatalf("down onto the newest: %q", at())
}
}
// The rows hold no more than ringBytes, the oldest going first; a paused
// cursor keeps its row, or takes the oldest kept when its own went
func TestTheRowsKeepWithinTheirBytes(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
big := strings.Repeat("x", 1<<20)
for i := range 20 {
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big)))
}
render(a, 80, 24)
press(a, "k", "k")
focused := a.view.focused().Message[:2]
for i := 20; i < 25; i++ {
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big)))
}
if got := a.view.focused().Message[:2]; a.view.bytes > ringBytes || got != focused {
t.Fatalf("%d bytes held; cursor on %s, was on %s", a.view.bytes, got, focused)
}
for i := 25; i < 55; i++ {
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big)))
}
if got, oldest := a.view.focused().Message[:2], a.view.rows[0].Message[:2]; a.view.bytes > ringBytes || got != oldest || oldest == "00" {
t.Fatalf("%d bytes held; cursor on %s, the oldest kept %s", a.view.bytes, got, oldest)
}
}
// A pattern shows only the rows matching it; setting or clearing it keeps
// the focused row on its screen row, the rows around it back on clearing,
// whether the view was paused or following
func TestAFilterChangeKeepsTheFocusedRowInPlace(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for i := range 300 {
msg := fmt.Sprintf("row %03d", i)
if i%3 == 2 {
msg += " needle"
}
a.view.take(tapped("p", "null", "", 0, msg))
}
screen := func() []string { return text(render(a, 80, 24), 80) }
press(a, "g")
screen()
press(a, strings.Repeat("j", 150))
screen()
press(a, strings.Repeat("k", 10))
y, r := cursorRow(screen())
if !strings.Contains(r, "row 140 needle") {
t.Fatalf("on %q", r)
}
press(a, "/", "needle", terminal.KeyEnter)
rows := screen()
if y2, r2 := cursorRow(rows); y2 != y || r2 != r || strings.Contains(strings.Join(rows, "\n"), "row 139") {
t.Fatalf("with the pattern, on row %d %q, was %d\n%s", y2, r2, y, strings.Join(rows, "\n"))
}
press(a, "/", terminal.KeyCtrlU, terminal.KeyEnter)
rows = screen()
if y2, r2 := cursorRow(rows); y2 != y || r2 != r || !strings.Contains(rows[y2-1], "row 139") || !strings.Contains(rows[y2+1], "row 141") {
t.Fatalf("cleared, on row %d %q, was %d\n%s", y2, r2, y, strings.Join(rows, "\n"))
}
following := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for i := range 40 {
msg := fmt.Sprintf("row %03d", i)
if i == 10 {
msg += " needle"
}
following.view.take(tapped("p", "null", "", 0, msg))
}
press(following, "/", "needle", terminal.KeyEnter)
render(following, 80, 24)
press(following, "/", terminal.KeyCtrlU, terminal.KeyEnter)
if render(following, 80, 24); following.view.focused().Message != "row 010 needle" || !following.view.paused {
t.Fatalf("cleared while following, on %q", following.view.focused().Message)
}
}
// A row keeps copies of its text, none of the entry's, and its size counts
// every string it keeps, so the ring's bound holds for any line
func TestARowCountsAllItHolds(t *testing.T) {
long := strings.Repeat("y", 4096)
rec := tapped("p", "null", "", 0, `{"msg":"`+long+`","sub":"`+long+`","k":"`+long+`","t":"2026-10-10T12:00:00Z"}`)
rec.Event.Entry.Level = long
var v view
size := v.take(rec)
r := v.rows[0]
held := rowBytes + len(r.Stamp) + len(r.Level) + len(r.Tag) + len(r.Message) + len(r.Layout) + len(r.fields) + len(r.payload)
strs := []string{r.Stamp, r.Level, r.Tag, r.Message, r.Layout}
for _, f := range r.Fields {
held += int(unsafe.Sizeof(f)) + len(f.Key) + len(f.Value)
strs = append(strs, f.Key, f.Value)
}
if size < held || v.bytes != size {
t.Fatalf("size %d, ring %d, for %d held", size, v.bytes, held)
}
for _, s := range strs {
for _, from := range []string{rec.Event.Entry.Message, rec.Event.Entry.Level} {
if p, start := uintptr(unsafe.Pointer(unsafe.StringData(s))), uintptr(unsafe.Pointer(unsafe.StringData(from))); s != "" && p >= start && p < start+uintptr(len(from)) {
t.Fatalf("%.20q is the entry's", s)
}
}
}
}
// 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
func TestThePointAndModePickTheRows(t *testing.T) {
c := pipeline(t, []string{"null", "null"}, []string{"null"})
for _, typ := range []string{"include", "exclude"} {
if _, err := c.Add(0, "filters", typ); err != nil {
t.Fatal(err)
}
}
if err := c.AddPipeline("q"); err != nil {
t.Fatal(err)
}
s1, s2 := c.Pipelines[0].PluginSources[0].ID, c.Pipelines[0].PluginSources[1].ID
a := watching(c, looks[terminal.ColorModeNone])
lw := func(level, msg string) core.Tapped {
r := tapped("", "lw", "", 0, msg)
r.Event.Entry.Level = level
return r
}
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"),
} {
a.view.take(r)
}
for _, c := range []struct {
pi int
at compose.Node
mode int
want string
own bool
}{
{0, compose.Node{Role: "sink", ID: "null"}, modeAll, "abgi", false},
{0, compose.Node{Role: "sink", ID: "null"}, modePassed, "abgi", false},
{0, compose.Node{Role: "sink", ID: "null"}, modeDropped, "cdefi", false},
{0, compose.Node{Role: "source", ID: s1}, modeAll, "acdfi", false},
{0, compose.Node{Role: "source", ID: s1}, modePassed, "ai", false},
{0, compose.Node{Role: "source", ID: s1}, modeDropped, "cdfi", false},
{0, compose.Node{Role: "rate_limit"}, modeAll, "abcdefi", false},
{0, compose.Node{Role: "rate_limit"}, modePassed, "abdefi", false},
{0, compose.Node{Role: "rate_limit"}, modeDropped, "ci", false},
{0, compose.Node{Role: "filters"}, modeAll, "abdefi", false},
{0, compose.Node{Role: "filters"}, modePassed, "abfi", false},
{0, compose.Node{Role: "filters"}, modeDropped, "dei", false},
{0, compose.Node{Role: "filters", Index: 1}, modeAll, "abefi", false},
{0, compose.Node{Role: "filters", Index: 1}, modeDropped, "ei", false},
{0, compose.Node{Role: "format"}, modeDropped, "fi", false},
{0, compose.Node{Role: "heartbeat"}, modeAll, "gi", false},
{0, compose.Node{Role: "heartbeat"}, modeDropped, "i", false},
{1, compose.Node{}, modeAll, "hi", false},
{0, compose.Node{Role: "filters"}, modeDropped, "ij", true},
} {
a.pi, a.inspect, a.view.mode, a.col, a.view.own = c.pi, false, c.mode, sinksCol, c.own
a.choose(c.at)
if c.at.Role == "filters" && c.at.Index > 0 {
a.inspectAt(func(l line) bool { return l.node == c.at })
}
render(a, 80, 24)
if got := strings.Join(shown(a), ""); got != c.want {
t.Errorf("at %+v in pipeline %d, %s: %q, want %q", c.at, c.pi, verdicts[c.mode], got, c.want)
}
}
}
// The tap takes the entries the flow dropped only while the view's point and
// mode show some
func TestTheTapTakesDropsOnlyWhileShown(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
a.live.tap = core.NewTap(1)
for _, c := range []struct {
at compose.Node
mode int
all bool
}{
{compose.Node{Role: "sink", ID: "null"}, modeAll, false},
{compose.Node{Role: "sink", ID: "null"}, modeDropped, true},
{compose.Node{Role: "source", ID: "null"}, modePassed, false},
{compose.Node{Role: "source", ID: "null"}, modeAll, true},
{compose.Node{Role: "filters"}, modePassed, true},
} {
a.view.mode = c.mode
a.choose(c.at)
if render(a, 80, 24); a.live.tap.All.Load() != c.all {
t.Errorf("at %+v, %s: the tap takes drops %v", c.at, verdicts[c.mode], !c.all)
}
}
}
// A digit is a level floor: rows under it hide, and those without a level;
// 0 shows them all
func TestTheLevelFloorHidesRowsUnderIt(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for _, l := range []string{"DEBUG", "INFO", "WARN", "ERROR", ""} {
r := tapped("p", "null", "", 0, "at "+l)
r.Event.Entry.Level = l
a.view.take(r)
}
for _, c := range []struct{ key, want string }{{"4", "at WARN|at ERROR"}, {"2", "at DEBUG|at INFO|at WARN|at ERROR"}, {"0", "at DEBUG|at INFO|at WARN|at ERROR|at "}} {
press(a, c.key)
if render(a, 80, 24); strings.Join(shown(a), "|") != c.want {
t.Errorf("%s: %q", c.key, shown(a))
}
}
}
// A pattern in lower case ignores case, one with an upper-case letter keeps
// it, in substrings and regular expressions alike
func TestPatternsAreSmartCase(t *testing.T) {
for _, c := range []struct {
pattern, text string
match bool
}{
{"disk", "DISK full", true}, {"Disk", "disk full", false}, {"Disk", "Disk full", true},
{"d.sk", "DISK full", true}, {"D.sk", "disk full", false}, {"é", "CAFÉ", true}, {"x", "DISK", false},
} {
if p, err := newPattern(c.pattern); err != nil || p.match(c.text) != c.match {
t.Errorf("%q in %q: %v, %v", c.pattern, c.text, !c.match, err)
}
}
if _, err := newPattern("("); err == nil {
t.Error("( compiled")
}
}
// From 40 rows the view sits under the diagram, whichever takes the keys;
// shorter, it takes the screen while it takes the keys, t going back
func TestTheViewSitsUnderTheDiagramWhenTall(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "", 0, "hello"))
layout := func(h int) string {
screen := strings.Join(text(render(a, 80, h), 80), "\n")
diagram, view := strings.Index(screen, "● SOURCES"), strings.Index(screen, "hello")
switch {
case diagram >= 0 && view > diagram:
return "split"
case view >= 0 && diagram < 0:
return "view"
case diagram >= 0 && view < 0:
return "diagram"
}
return screen
}
if got := layout(40); got != "split" {
t.Errorf("40 rows: %s", got)
}
if got := layout(39); got != "view" {
t.Errorf("39 rows: %s", got)
}
press(a, "t")
if got := layout(39); got != "diagram" {
t.Errorf("39 rows, back: %s", got)
}
if got := layout(40); got != "split" {
t.Errorf("40 rows, back: %s", got)
}
}
// Enter shows the focused row's detail beside the rows from 160 columns,
// under them below that
func TestTheDetailSitsBesideFrom160(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "", 0, "hello"))
press(a, terminal.KeyEnter)
for w, beside := range map[int]bool{160: true, 159: false} {
rows := text(render(a, w, 30), w)
y := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "─ detail ─") })
if got := y == 2 && strings.Contains(rows[2], "hello"); got != beside {
t.Errorf("%d columns: beside %v, detail on row %d\n%s", w, got, y, strings.Join(rows, "\n"))
}
}
}
// The detail shows the entry whole: its verdict, 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"})
if _, err := c.Add(0, "filters", "exclude"); err != nil {
t.Fatal(err)
}
if err := c.Set(0, compose.Node{Role: "filters"}, "patterns", "slow"); err != nil {
t.Fatal(err)
}
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))
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]} {
if !strings.Contains(screen, want) {
t.Errorf("no %q\n%s", want, screen)
}
}
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)
}
}
// A row draws one cell per rune: a wide, combining or control rune is the
// font's placeholder and a line break its mark, which the detail writes as
// escapes and a break
func TestRowsDrawOneCellPerRune(t *testing.T) {
msg := "a\u4f60\u0301\x1b[2Jb\tc\nd"
for _, f := range []font{unicodeFont, cp437Font, asciiFont} {
a := newApp(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone], f)
a.viewing = true
render(a, 80, 24)
a.view.take(tapped("p", "null", "", 0, msg))
odd, br := string(f.fold(f.odd)), string(f.fold(f.newline))
want := "a" + odd + odd + odd + "[2Jb c" + br + "d"
if row := text(render(a, 80, 24), 80)[2]; !strings.Contains(row, want) {
t.Errorf("%c: %q, want %q", f.bar, row, want)
}
}
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "", 0, msg))
press(a, terminal.KeyEnter)
if screen := strings.Join(text(render(a, 80, 30), 80), "\n"); !strings.Contains(screen, `message a\u4F60\u0301\u001B[2Jb\u0009c`+strings.Repeat(" ", 10)) {
t.Errorf("the detail\n%s", screen)
}
}
// A narrow view drops the time, then the level's name, the tag and the
// level, and never draws one over the message
func TestANarrowViewDropsColumns(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
r := tapped("p", "null", "", 0, "hello world")
r.Event.Entry.Level, r.Event.Entry.Source = "WARN", "app.log"
a.view.take(r)
stamp := time.Unix(1, 0).Format("15:04:05")
for _, c := range []struct {
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"}} {
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)
}
}
}
// A row's level shows in its level's color; a level lw does not name, in the
// text's
func TestALevelShowsInItsColor(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeTrueColor])
for _, level := range []string{"WARN", "AUDIT"} {
r := tapped("p", "null", "", 0, "hello")
r.Event.Entry.Level = level
a.view.take(r)
}
cells := render(a, 100, 24)
for y, c := range []struct {
level string
want ui.Style
}{{"WARN", a.look.Levels["WARN"]}, {"AUDIT", a.th.Text}} {
row := []rune(text(cells, 100)[2+y])
x := slices.Index(row, rune(c.level[0]))
if got := cells[(2+y)*100+x]; x < 0 || got.Fg != c.want.Fg {
t.Errorf("%s at %d is %+v, want %+v", c.level, x, got, c.want)
}
}
}
// The cursor row is one bar: without color every cell takes one style, so a
// console draws no split bar; in color each keeps its role on the bar's
// background
func TestTheViewsCursorRowIsOneBar(t *testing.T) {
for _, mode := range []terminal.ColorMode{terminal.ColorModeNone, terminal.ColorMode16, terminal.ColorModeTrueColor} {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[mode])
r := tapped("p", "null", "filters", 0, "hello world")
r.Event.Entry.Level, r.Event.Entry.Fields = "ERROR", json.RawMessage(`{"k":1}`)
a.view.take(r)
a.view.mode = modeDropped
a.choose(compose.Node{Role: "sink", ID: "null"})
cells := render(a, 100, 24)
row, bar := cells[2*100:3*100], a.th.Text.On(a.th.Selected)
for x, c := range row {
one := c.Fg == bar.Fg && c.Bg == bar.Bg && c.Attrs == bar.Attr
if mode == terminal.ColorModeNone && !one || c.Bg != bar.Bg || c.Attrs&terminal.AttrBgColor != bar.Attr&terminal.AttrBgColor {
t.Fatalf("mode %v: %q at %d is %+v, the bar %+v", mode, c.Rune, x, c, bar)
}
}
}
}
// A tick reads the taps up to drainBytes, the rest waiting for the next
func TestADrainKeepsToItsBudget(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live.tap = core.NewTap(100)
for range 100 {
a.live.tap.Offer(tapped("p", "null", "", 0, strings.Repeat("x", 64<<10)))
}
press(a, terminal.KeyNone)
size := a.view.rows[0].size
if n, want := len(a.view.rows), int(math.Ceil(float64(drainBytes)/float64(size))); n != want || len(a.live.tap.Records()) != 100-n {
t.Fatalf("read %d rows of %d bytes, want %d; %d left", n, size, want, len(a.live.tap.Records()))
}
}
// While a tap holds records the watch wakes the screen; an empty one does
// not
func TestATapHoldingRecordsWakesTheScreen(t *testing.T) {
posts := make(chan struct{}, 8)
r := newRun(&fakeRunner{}, func() { posts <- struct{}{} })
tap := core.NewTap(1)
r.fed.Store(tap)
go r.watch(time.Hour)
defer r.endAsks()
select {
case <-posts:
t.Fatal("an empty tap woke the screen")
case <-time.After(3 * drainEvery):
}
tap.Offer(tapped("p", "null", "", 0, "x"))
select {
case <-posts:
case <-time.After(time.Second):
t.Fatal("a tap holding a record did not wake the screen")
}
}
// A rerun reads the earlier run's tap whole, then a rule marks where the new
// run's rows begin
func TestARerunMarksTheRows(t *testing.T) {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
a.live.tap = core.NewTap(4)
a.live.tap.Offer(tapped("p", "null", "", 0, "before"))
next := core.NewTap(4)
next.Offer(tapped("p", "null", "", 0, "after"))
f.start = func(Ask) (*core.Tap, error) { return next, nil }
a.edit(a.comp.Set(0, compose.Node{Role: "format"}, "type", "txt"))
press(a, "t")
await(t, a)
var got []string
for _, r := range a.view.rows {
got = append(got, r.Message+strings.SplitN(r.mark, " ", 2)[0])
}
if strings.Join(got, ",") != "before,rerun,after" {
t.Fatalf("%q", got)
}
}
// i inspects the filter that dropped the focused row, which the view then
// looks at
func TestIInspectsTheFilterThatDropped(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"))
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)
}
}
// s picks the point from a list, moving the canvas cursor there, or lw's own
// log while it reaches the screen, until s picks another or a key on the
// diagram moves the cursor
func TestTheChooserPicksThePoint(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
press(a, "s", "format", terminal.KeyEnter)
if n, _ := a.node(); n.Role != "format" || a.point().name != "format" || !a.viewing {
t.Fatalf("on %+v, at %s, viewing %v", n, a.point().name, a.viewing)
}
a.live.state.Log = core.NewTap(1)
for _, leave := range [][]any{{"s", "format", terminal.KeyEnter}, {"t", "j"}} {
if press(a, "s", "own log", terminal.KeyEnter); !a.point().own {
t.Fatalf("at %s", a.point().name)
}
if press(a, leave...); a.point().own {
t.Fatalf("%v left the point on lw's own log", leave)
}
}
}