Commit 4099abc8b9

4099abc8b97661d47cf9762e7741a19274b85137

parent: 6cc1494e6b

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:13 UTC

control: screen model and renderer

Ref #317

Layout: unified · split

internal/control/screen.go added +203
@@ -0,0 +1,203 @@
1package control
2
3import (
4 "fmt"
5 "io"
6 "strconv"
7 "strings"
8
9 "gitbay.org/gitbay/internal/termtext"
10)
11
12// screen is a show or a list as a terminal draws it: a header block of
13// labelled fields, a body, counted sections, and the commands that
14// apply. Commands build one only at a terminal; plain and --json output
15// never reach it.
16type screen struct {
17 fields []field
18 body string // markup source
19 format string // the body's markup format; "text" is not rendered
20 sections []section
21 actions []action
22}
23
24// field is one header line: a label and the cells after it.
25type field struct {
26 label string
27 value []cell
28}
29
30// section is a titled run of rows. n is the total, shown as "(n)"; when
31// it is more than the rows shown, more is the command for the rest. An
32// empty section is left out unless empty says to draw "(0)".
33type section struct {
34 title string
35 n int
36 rows []row
37 more []string
38 empty bool
39}
40
41// row is a section row, and optionally the markup that follows it (a
42// comment's body).
43type row struct {
44 cells []cell
45 body string
46 format string
47}
48
49func rowOf(cs ...cell) row { return row{cells: cs} }
50
51// action is one command in the legend, under its group's name. argv is
52// the command as typed after "gitbay".
53type action struct {
54 group string
55 argv []string
56}
57
58// render draws s: the parts in order, one blank line between parts and
59// between sections.
60func (c *Ctx) render(w io.Writer, s screen) {
61 var blocks []string
62 if b := c.renderFields(s.fields); b != "" {
63 blocks = append(blocks, b)
64 }
65 if b := c.renderBody(s.body, s.format, ""); b != "" {
66 blocks = append(blocks, b)
67 }
68 for _, sec := range s.sections {
69 if b := c.renderSection(sec); b != "" {
70 blocks = append(blocks, b)
71 }
72 }
73 if b := c.renderLegend(s.actions); b != "" {
74 blocks = append(blocks, b)
75 }
76 io.WriteString(w, strings.Join(blocks, "\n"))
77}
78
79// renderFields aligns labels on the widest, dim, with a colon. Every
80// cell but the last is painted as a table paints it; the last wraps to
81// the width, continuation lines under its first column.
82func (c *Ctx) renderFields(fs []field) string {
83 t := c.Term
84 wide := 0
85 for _, f := range fs {
86 if len(f.value) > 0 {
87 wide = max(wide, cells(f.label)+1)
88 }
89 }
90 var b strings.Builder
91 for _, f := range fs {
92 if len(f.value) == 0 {
93 continue
94 }
95 prefix := t.paint(sgrDim, f.label+":") + strings.Repeat(" ", wide-cells(f.label)-1+2)
96 col := wide + 2
97 head := ""
98 for _, cl := range f.value[:len(f.value)-1] {
99 s := c.cellText(cl)
100 if s == "" {
101 continue
102 }
103 head += c.paintCell(cl, s) + " "
104 col += cells(s) + 2
105 }
106 last := f.value[len(f.value)-1]
107 indent := strings.Repeat(" ", col)
108 for i, l := range termtext.Wrap(c.cellText(last), max(8, t.Cols-col)) {
109 p := indent
110 if i == 0 {
111 p = prefix + head
112 }
113 b.WriteString(strings.TrimRight(p+c.paintCell(last, l), " ") + "\n")
114 }
115 }
116 return b.String()
117}
118
119// cellText is a cell's terminal text: made safe, ages relative, sizes
120// humanized, as table.row prepares it.
121func (c *Ctx) cellText(cl cell) string {
122 s := termSafe(cl.s)
123 switch cl.kind {
124 case kindAge:
125 return relAge(s, termNow())
126 case kindSize:
127 if n, err := strconv.ParseInt(s, 10, 64); err == nil {
128 return humanBytes(n)
129 }
130 }
131 return s
132}
133
134// paintCell paints text s as its cell's kind paints at a terminal.
135func (c *Ctx) paintCell(cl cell, s string) string {
136 t := c.Term
137 switch cl.kind {
138 case kindState:
139 return t.paintState(s)
140 case kindRef:
141 return t.link(cl.url, t.paint(sgrDim, s))
142 case kindMeta:
143 return t.paint(sgrDim, s)
144 case kindSwatch:
145 return t.swatch(s)
146 }
147 return t.paint(cl.sgr, s)
148}
149
150// renderBody renders markup to the width, each line prefixed by indent.
151// Format "text" is wrapped as it is, without markup.
152func (c *Ctx) renderBody(src, format, indent string) string {
153 if strings.TrimSpace(src) == "" {
154 return ""
155 }
156 width := max(0, c.Term.Cols-len(indent))
157 var out []string
158 if format == "text" {
159 for _, para := range strings.Split(strings.TrimRight(termSafe(src), "\n"), "\n") {
160 out = append(out, termtext.Wrap(para, width)...)
161 }
162 } else {
163 opts := termtext.Options{Width: width, Color: c.Term.Color, Base: c.Cfg.Server.SiteURL}
164 out = strings.Split(strings.TrimRight(termtext.Render(termSafe(src), format, opts), "\n"), "\n")
165 }
166 var b strings.Builder
167 for _, l := range out {
168 if l == "" {
169 b.WriteString("\n")
170 continue
171 }
172 b.WriteString(indent + l + "\n")
173 }
174 return b.String()
175}
176
177// renderSection is the heading, the rows laid out as one table with no
178// header, each row's body indented beneath it, and the "+n more" line.
179func (c *Ctx) renderSection(s section) string {
180 if len(s.rows) == 0 && !s.empty {
181 return ""
182 }
183 var b strings.Builder
184 b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)) + "\n")
185 if len(s.rows) > 0 {
186 tb := &table{term: c.Term, w: io.Discard}
187 for _, r := range s.rows {
188 tb.row(append([]cell(nil), r.cells...)...)
189 }
190 for i, l := range tb.lines() {
191 b.WriteString(l + "\n")
192 b.WriteString(c.renderBody(s.rows[i].body, s.rows[i].format, " "))
193 }
194 }
195 if rest := s.n - len(s.rows); rest > 0 && len(s.more) > 0 {
196 b.WriteString(c.Term.paint(sgrDim, fmt.Sprintf("+%d more %s", rest, c.cmdline(s.more))) + "\n")
197 }
198 return b.String()
199}
200
201func (c *Ctx) renderLegend(as []action) string { return "" }
202
203func (c *Ctx) cmdline(argv []string) string { return "gitbay " + strings.Join(argv, " ") }
internal/control/screen_test.go added +116
@@ -0,0 +1,116 @@
1package control
2
3import (
4 "strings"
5 "testing"
6)
7
8func screenCtx(cols int, color bool) *Ctx {
9 return &Ctx{Term: Term{Cols: cols, Color: color}}
10}
11
12func renderString(c *Ctx, s screen) string {
13 var b strings.Builder
14 c.render(&b, s)
15 return b.String()
16}
17
18func sampleScreen() screen {
19 return screen{
20 fields: []field{
21 {"Merge", []cell{cRef("!552"), cText("wire $PAGER through long views")}},
22 {"State", []cell{cState("open"), cMeta("cli-pager → main", "cmc")}},
23 {"Checks", []cell{cGlyph("running"), cText("1 running")}},
24 },
25 body: "Pages long views.",
26 format: "md",
27 sections: []section{
28 {title: "Commits", n: 2, rows: []row{
29 rowOf(cRef("8f3a1c2"), cFlex("cli: page long output")),
30 rowOf(cRef("2b77e90"), cFlex("control: mark views")),
31 }},
32 {title: "Discussion", n: 0, empty: true},
33 {title: "Hidden", n: 0},
34 },
35 }
36}
37
38func TestRenderPlainLayout(t *testing.T) {
39 got := renderString(screenCtx(80, false), sampleScreen())
40 want := `Merge: !552 wire $PAGER through long views
41State: open cli-pager → main · cmc
42Checks: ◐ 1 running
43
44Pages long views.
45
46Commits (2)
478f3a1c2 cli: page long output
482b77e90 control: mark views
49
50Discussion (0)
51`
52 if got != want {
53 t.Errorf("got:\n%s\nwant:\n%s", got, want)
54 }
55}
56
57func TestRenderColourIsOnlyPaint(t *testing.T) {
58 for _, cols := range []int{80, 120} {
59 plain := renderString(screenCtx(cols, false), sampleScreen())
60 colour := renderString(screenCtx(cols, true), sampleScreen())
61 if stripSGR(colour) != plain {
62 t.Errorf("cols %d: colour render differs beyond SGR:\n%s\n---\n%s", cols, stripSGR(colour), plain)
63 }
64 if !strings.Contains(colour, sgrBold+sgrBlue+"Commits (2)"+sgrReset) {
65 t.Errorf("cols %d: heading not bold blue: %q", cols, colour)
66 }
67 if !strings.Contains(colour, sgrDim+"Merge:"+sgrReset) {
68 t.Errorf("cols %d: label not dim: %q", cols, colour)
69 }
70 }
71}
72
73func TestRenderMoreLine(t *testing.T) {
74 s := screen{sections: []section{{title: "Builds", n: 14, more: []string{"build", "list", "krz/gitbay"},
75 rows: []row{rowOf(cRef("1779"), cFlex("test"))}}}}
76 got := renderString(screenCtx(80, false), s)
77 if !strings.HasSuffix(got, "1779 test\n+13 more gitbay build list krz/gitbay\n") {
78 t.Errorf("got %q", got)
79 }
80}
81
82func TestRenderRowBody(t *testing.T) {
83 s := screen{sections: []section{{title: "Discussion", n: 1, rows: []row{
84 {cells: []cell{cText("cmc"), cMeta("2h")}, body: "Looks good.", format: "md"},
85 }}}}
86 got := renderString(screenCtx(80, false), s)
87 want := "Discussion (1)\ncmc 2h\n Looks good.\n"
88 if got != want {
89 t.Errorf("got %q, want %q", got, want)
90 }
91}
92
93func TestRenderTextBodyIsNotMarkup(t *testing.T) {
94 s := screen{body: "a *b* c", format: "text"}
95 if got := renderString(screenCtx(80, false), s); got != "a *b* c\n" {
96 t.Errorf("got %q", got)
97 }
98}
99
100func TestRenderFieldWraps(t *testing.T) {
101 long := strings.Repeat("word ", 20)
102 s := screen{fields: []field{{"Merge", []cell{cRef("!1"), cText(strings.TrimSpace(long))}}}}
103 out := renderString(screenCtx(40, false), s)
104 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
105 if len(lines) < 2 {
106 t.Fatalf("did not wrap: %q", out)
107 }
108 for _, l := range lines {
109 if cells(l) > 40 {
110 t.Errorf("line wider than 40: %q", l)
111 }
112 }
113 if !strings.HasPrefix(lines[1], strings.Repeat(" ", len("Merge: !1 "))) {
114 t.Errorf("continuation not under the value: %q", lines[1])
115 }
116}