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
|
||||
|
||||
Reference in new issue
Block a user