v0.33.0 tui tap for live view and console enhancements
This commit is contained in:
1 parent
a1e9d21d06
commit
87f5576ccf
41 files changed
+4606
-449
No files matched your search
+190
-71
@@ -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
@@ -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 ¬ice{title: "Keys", body: func(w int) []string {
|
||||
var lines []string
|
||||
for _, k := range keys {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user