Files
logwisp/internal/tui/canvas.go
T
Lixen 6a168212de
Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s
v0.38.0 lwconf updates for deployment
lwconf: sparse stages, every problem of a part, pastes read by form
2026-10-22 17:34:20 -04:00

448 lines
12 KiB
Go

package tui
import (
"cmp"
"fmt"
"slices"
"strconv"
"strings"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
ui "github.com/lixenwraith/terminal/tui"
)
const (
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
)
// 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
)
// 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
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 {
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)
}
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
} else {
y := 0
for col := range columns {
l.title[col] = spot{y: y, w: l.w}
y++
if col == flowCol {
l.box = spot{y: y, w: l.w}
y += l.boxH
} else {
for i := range l.nodes[col] {
s := &l.nodes[col][i]
s.y, s.w = y, l.w
y += s.h + 1
}
y--
}
if col != sinksCol {
y++ // the arrow down to the next part
}
}
l.h = y
}
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 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, chosen.h)
r.Window(l.h, &a.scroll, a.th.Text, func(full ui.Region) { a.drawParts(full, l) })
}
func (a *app) drawParts(r ui.Region, l layout) {
th, f := a.th, a.font
tints := [3]ui.Style{a.look.Source, a.look.Flow, a.look.Sink}
for col, c := range columns {
t := l.title[col]
r.TextStyled(t.x, t.y, string(f.dot)+" "+c.title, tints[col].On(th.Text))
}
a.drawBox(r, l)
g := ui.NewWires(r.W, l.h)
for _, col := range []int{sourcesCol, sinksCol} {
nodes := a.nodes(col)
for i, s := range l.nodes[col] {
chosen := a.col == col && a.row[col] == i
if len(nodes) == 0 {
a.drawEmpty(r, s, columns[col].role, chosen)
continue
}
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)
}
}
if !l.wide {
for _, col := range []int{sourcesCol, flowCol} {
r.TextStyled(2, l.title[col+1].y-1, string(f.down), th.Border)
}
return
}
r.DrawWires(g, f.Line, th.Border)
if len(a.nodes(sourcesCol)) > 0 {
r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border)
}
if len(a.nodes(sinksCol)) > 0 {
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 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 {
ys[0] = l.exit
}
for _, s := range l.nodes[col] {
ys = append(ys, s.y)
}
if col == sourcesCol {
trunk := l.box.x - 1 - l.wire/2
for _, s := range l.nodes[col] {
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 + 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] {
g.H(s.y, trunk, s.x-1)
}
}
// drawNode draws a source or sink: a tinted bar, its type and whether it
// listens or dials, then 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 {
bg, bar = th.Text.On(th.Selected), a.font.barOn
}
if a.faulty(n) {
tint = th.Error
}
p, _ := a.plugin(n)
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 {
w = head
}
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 {
mark, style = string(a.th.Glyphs.Pointer), a.th.Muted.On(a.th.Selected)
}
row := r.Sub(s.x, s.y, s.w-1, 1)
if chosen {
row.FillStyle(a.th.Text.On(a.th.Selected))
}
row.TextStyled(0, 0, mark+" a adds a "+role, style)
}
// drawBox draws the flow box, its stages in the order entries pass them; a
// stage that is off is dimmed with the Off glyph
func (a *app) drawBox(r ui.Region, l layout) {
th, f := a.th, a.font
b := l.box
r.Sub(b.x, b.y, b.w, l.boxH).BoxStyle(f.box, th.Border)
for i, n := range a.nodes(flowCol) {
s := l.nodes[flowCol][i]
chosen := a.col == flowCol && a.row[flowCol] == i
bg := th.Text
if chosen {
bg = th.Text.On(th.Selected)
}
row := r.Sub(s.x, s.y, s.w, 1)
row.FillStyle(bg)
mark, tint, text, under := f.bar, a.look.Flow, th.Text, th.Text // a block keeps its tint on the bar
if chosen {
mark = f.barOn
}
if !a.on(n) {
mark, tint, text, under = f.Off, th.Muted, th.Muted, bg
if chosen {
mark = th.Glyphs.Pointer
}
}
if a.faulty(n) {
tint = th.Error
}
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(s.text[0], row.W-x), th.Muted.On(bg))
}
}
// on reports whether a node is on: a flow stage that is off has no options,
// or an enabled key that is false
func (a *app) on(n compose.Node) bool {
if n.Role == "filters" {
return len(a.pipeline().Flow.Filters) > 0
}
opts, on, _ := a.comp.Options(a.pi, n)
if slicesHasKey(a.keys(n), "enabled") {
return on && opts["enabled"] == true
}
return on
}
// summary is a node's options in a line: the values set, a table 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
for _, f := range a.pipeline().Flow.Filters {
sign := map[config.FilterType]string{"include": "+", "exclude": "-"}[filled(f).Type]
out = append(out, sign+strings.Join(f.Patterns, "|"))
}
return strings.Join(out, " ")
}
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, setting{key: k.Name})
}
case v == true:
out = append(out, setting{key: k.Name})
default:
out = append(out, setting{k.Name, show(v)})
}
}
return out
}
// show writes a value as a form does: a list joined by commas
func show(v any) string {
switch v := v.(type) {
case []any:
var out []string
for _, e := range v {
out = append(out, show(e))
}
return strings.Join(out, ",")
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
}
return fmt.Sprint(v)
}
// faulty reports whether a problem names this node, or a filter when n is
// the filters stage
func (a *app) faulty(n compose.Node) bool {
for _, p := range compose.Problems(a.problem) {
if pi, m, _, _, ok := compose.Locate(p); ok && pi == a.pi && m.Role == n.Role && m.ID == n.ID {
return true
}
}
return false
}
// where names a problem's part as the screen shows it: its pipeline when
// another is shown, the part, and its key
func (a *app) where(pi int, n compose.Node, key string) string {
var out []string
if pi != a.pi && pi < len(a.comp.Pipelines) {
out = append(out, "pipeline "+a.comp.Pipelines[pi].Name)
}
switch n.Role {
case "":
case "source", "sink":
out = append(out, n.Role+" "+n.ID)
case "filters":
out = append(out, fmt.Sprintf("filter %d", n.Index+1))
default:
out = append(out, n.Role)
}
if key != "" {
out = append(out, key)
}
return strings.Join(out, " · ")
}