package tui import ( "fmt" "regexp" "slices" "strconv" "strings" "github.com/lixenwraith/logwisp/internal/compose" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/terminal" ui "github.com/lixenwraith/terminal/tui" ) 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 ) // spot is where a node draws: its first row and the columns it may use type spot struct{ x, y, w int } // layout places a pipeline's parts: side by side with wires from 64 columns, // stacked below that type layout struct { wide bool h int 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 } 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.entry = l.box.y + 1 + (stages-1)/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} y++ if col == flowCol { l.box = spot{0, y, w} y += l.boxH } else { for range counts[col] { l.nodes[col] = append(l.nodes[col], spot{0, y, w}) y += pitch } y-- } if col != sinksCol { y++ // the arrow down to the next part } } 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}) } 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) chosen := l.nodes[a.col][min(a.row[a.col], len(l.nodes[a.col])-1)] if chosen.y < a.scroll { a.scroll = chosen.y } if tall := 2 - a.col%2; chosen.y+tall > a.scroll+r.H { a.scroll = chosen.y + tall - r.H } a.scroll = max(0, min(a.scroll, l.h-r.H)) // Drawn whole off screen, then the rows in view copied cells := make([]terminal.Cell, r.W*l.h) full := ui.NewRegion(cells, r.W, 0, 0, r.W, l.h) full.FillStyle(a.th.Text) a.drawParts(full, l) for y := range min(r.H, l.h-a.scroll) { copy(r.Cells[(r.Y+y)*r.TotalW+r.X:][:r.W], cells[(a.scroll+y)*r.W:][:r.W]) } } 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 := wires{w: r.W, arms: make([]uint8, 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 } a.drawNode(r, s, nodes[i], tints[col], chosen) } if l.wide && len(nodes) > 0 { g.join(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 } for i, arms := range g.arms { if arms != 0 { r.TextStyled(i%r.W, i/r.W, string(f.wire[arms]), 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 { r.TextStyled(l.box.x+l.box.w+1, l.exit, string(f.arrow), th.Border) } } // wires are the canvas's cells by the arms they have: up 1, right 2, down 4, // left 8, so every junction draws from what meets there type wires struct { w int arms []uint8 } func (g wires) h(y, x0, x1 int) { for x := x0; x < x1; x++ { g.arms[y*g.w+x] |= 2 g.arms[y*g.w+x+1] |= 8 } } func (g wires) v(x, y0, y1 int) { for y := y0; y < y1; y++ { g.arms[y*g.w+x] |= 4 g.arms[(y+1)*g.w+x] |= 1 } } // join wires a column's nodes to a trunk beside the flow box, and the trunk // to the box's side func (g wires) join(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 - gutter + 1 for _, s := range l.nodes[col] { g.h(s.y, trunk-1, 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 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 a summary of its options. The chosen node's bar is // solid; a node the first 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) { 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) 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) if dy == 0 { style = th.Text.On(bg) row.TextStyled(row.W-ui.RuneLen(way), 0, way, th.Muted.On(bg)) } row.TextStyled(2, 0, ui.Truncate(text, row.W-ui.RuneLen(way)-3), style) } } 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 edge := []rune(f.box) across := strings.Repeat(string(edge[4]), max(0, b.w-2)) r.TextStyled(b.x, b.y, string(edge[0])+across+string(edge[1]), th.Border) r.TextStyled(b.x, b.y+l.boxH-1, string(edge[2])+across+string(edge[3]), th.Border) for y := b.y + 1; y < b.y+l.boxH-1; y++ { r.TextStyled(b.x, y, string(edge[5]), th.Border) r.TextStyled(b.x+b.w-1, y, string(edge[5]), 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 := f.bar, a.look.Flow, th.Text if chosen { mark = f.barOn } summary := a.summary(n) if !a.on(n) { mark, tint, text, summary = f.Off, th.Muted, th.Muted, "off" if chosen { mark = th.Glyphs.Pointer } } if a.faulty(n) { tint = th.Error } row.TextStyled(0, 0, string(mark), tint.On(bg)) 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)) } } // 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 by its // name, a switch by its key; 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": "-"}[f.Type] out = append(out, sign+strings.Join(f.Patterns, "|")) } return strings.Join(out, " ") } opts, _, _ := a.comp.Options(a.pi, n) var out []string 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) } case v == true: out = append(out, k.Name) default: out = append(out, show(v)) } } return strings.Join(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) } // problemPath reads the part a validation error names: its pipeline, node and // key, as config's key paths write them var problemPath = regexp.MustCompile(`^pipelines?\[(\d+)\](?:\.(plugin_sources|plugin_sinks)\[([^\]]*)\](?:\.config\.?([\w.]*))?|\.flow\.(\w+)(?:\[(\d+)\])?\.?([\w.]*))?`) func locate(err error) (pi int, n compose.Node, key string, ok bool) { m := problemPath.FindStringSubmatch(err.Error()) if m == nil { return 0, n, "", false } pi, _ = strconv.Atoi(m[1]) switch { case m[2] != "": n = compose.Node{Role: map[string]string{"plugin_sources": "source", "plugin_sinks": "sink"}[m[2]], ID: m[3]} key = m[4] case m[5] != "": n = compose.Node{Role: m[5]} if m[6] != "" { n.Index, _ = strconv.Atoi(m[6]) } key = m[7] } return pi, n, key, true } // faulty reports whether the first problem names this node, or a filter // when n is the filters stage func (a *app) faulty(n compose.Node) bool { if a.problem == nil { return false } pi, m, _, ok := locate(a.problem) return ok && pi == a.pi && m.Role == n.Role && m.ID == n.ID }