Commit 0c0ae8e08b

0c0ae8e08be75f20a3bf1c9463fdb0efef38042e

parent: a14e94fa35

Verified · cmc

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

control: glyph and meta cells, refs dim

Ref #317

Layout: unified · split

internal/control/table.go +29 −2
@@ -18,6 +18,8 @@ const (
1818 kindNum
1919 kindSize
2020 kindSwatch
21 kindGlyph
22 kindMeta
2123)
2224
2325// cell is one column of a table row. The kind decides colour, time
@@ -49,6 +51,27 @@ func cSwatch(hex string) cell { return cell{kind: kindSwatch, s: hex} }
4951// its word: "2 failed" red, "review requested" yellow.
5052func cMark(s, sgr string) cell { return cell{kind: kindText, s: s, sgr: sgr} }
5153
54// cGlyph is a state's mark, coloured for the state.
55func cGlyph(state string) cell {
56 g, sgr := glyph(state)
57 return cell{kind: kindGlyph, s: g, sgr: sgr}
58}
59
60// cYou is the mark for a row that waits on the viewer.
61func cYou() cell { return cell{kind: kindGlyph, s: "●", sgr: sgrYellow} }
62
63// cMeta is a row's trailing facts, dim and joined by " · ". Empty parts
64// are skipped.
65func cMeta(parts ...string) cell {
66 var keep []string
67 for _, p := range parts {
68 if p != "" {
69 keep = append(keep, p)
70 }
71 }
72 return cell{kind: kindMeta, s: strings.Join(keep, " · ")}
73}
74
5275// table is a list command's rows. Plain, each row is written as it
5376// comes, tab-separated with no header. At a terminal rows are held
5477// until flush, then written under a header, padded, and fitted to the
@@ -233,7 +256,7 @@ func (t *table) fit(widths []int) {
233256 }
234257 }
235258 for i := len(kinds) - 1; i >= 0; i-- {
236 if kinds[i] == kindText {
259 if kinds[i] == kindText || kinds[i] == kindMeta {
237260 shrink(i)
238261 }
239262 }
@@ -272,7 +295,11 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string {
272295 case kindState:
273296 s = t.term.paintState(s)
274297 case kindRef:
275 s = t.term.link(r[i].url, t.term.paint(sgrCyan, s))
298 s = t.term.link(r[i].url, t.term.paint(sgrDim, s))
299 case kindGlyph:
300 s = t.term.paint(r[i].sgr, s)
301 case kindMeta:
302 s = t.term.paint(sgrDim, s)
276303 case kindSwatch:
277304 s = t.term.swatch(s)
278305 default:
internal/control/table_test.go +27
@@ -211,3 +211,30 @@ func TestTableSwatchAligned(t *testing.T) {
211211 t.Errorf("plain = %q", plain.String())
212212 }
213213}
214
215func TestRefsDimNotCyan(t *testing.T) {
216 var b bytes.Buffer
217 tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b, "#", "TITLE")
218 tb.row(cRef("#12"), cFlex("Android app"))
219 tb.flush()
220 if strings.Contains(b.String(), sgrCyan) {
221 t.Errorf("ref painted cyan: %q", b.String())
222 }
223 if !strings.Contains(b.String(), sgrDim+"#12"+sgrReset) {
224 t.Errorf("ref not dim: %q", b.String())
225 }
226}
227
228func TestGlyphAndMetaCells(t *testing.T) {
229 var b bytes.Buffer
230 tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b)
231 tb.row(cRef("1779"), cGlyph("failure"), cFlex("test"), cMeta("gitbay", "2h"))
232 tb.row(cRef("1780"), cYou(), cFlex("review"), cMeta("", "3h"))
233 tb.flush()
234 out := b.String()
235 for _, want := range []string{sgrRed + "✗" + sgrReset, sgrYellow + "●" + sgrReset, sgrDim + "gitbay · 2h" + sgrReset, sgrDim + "3h" + sgrReset} {
236 if !strings.Contains(out, want) {
237 t.Errorf("missing %q in %q", want, out)
238 }
239 }
240}
internal/control/term.go +21
@@ -56,6 +56,7 @@ const (
5656 sgrRed = "\x1b[31m"
5757 sgrGreen = "\x1b[32m"
5858 sgrYellow = "\x1b[33m"
59 sgrBlue = "\x1b[34m"
5960 sgrMagenta = "\x1b[35m"
6061 sgrCyan = "\x1b[36m"
6162)
@@ -121,6 +122,26 @@ func stateColor(s string) string {
121122 return ""
122123}
123124
125// glyph is the mark a screen puts first in a row or field for a state
126// word, and its colour: ✓ passed, ✗ failed or blocked, ◐ still going,
127// ○ closed or draft. A state with no mark ("open") returns "".
128func glyph(state string) (g, sgr string) {
129 switch state {
130 case "merged":
131 return "✓", ""
132 case "success", "ok", "approved", "verified", "passed":
133 return "✓", sgrGreen
134 case "failure", "failed", "error", "changes requested",
135 "bad_signature", "signed_email_mismatch", "signed_key_expired", "signed_key_revoked":
136 return "✗", sgrRed
137 case "pending", "running", "queued":
138 return "◐", ""
139 case "closed", "draft", "canceled", "cancelled", "skipped":
140 return "○", ""
141 }
142 return "", ""
143}
144
124145// link makes s a hyperlink to url when the terminal shows them (OSC 8).
125146// A url with a control byte in it is left out rather than sent.
126147func (t Term) link(url, s string) string {
internal/control/term_test.go +22
@@ -219,3 +219,25 @@ func TestLinkAndSwatch(t *testing.T) {
219219 t.Errorf("bad hex = %q", got)
220220 }
221221}
222
223func TestGlyph(t *testing.T) {
224 for _, c := range []struct{ state, g, sgr string }{
225 {"success", "✓", sgrGreen},
226 {"approved", "✓", sgrGreen},
227 {"merged", "✓", ""},
228 {"failure", "✗", sgrRed},
229 {"changes requested", "✗", sgrRed},
230 {"signed_key_revoked", "✗", sgrRed},
231 {"running", "◐", ""},
232 {"pending", "◐", ""},
233 {"closed", "○", ""},
234 {"draft", "○", ""},
235 {"open", "", ""},
236 {"", "", ""},
237 } {
238 g, sgr := glyph(c.state)
239 if g != c.g || sgr != c.sgr {
240 t.Errorf("glyph(%q) = %q %q, want %q %q", c.state, g, sgr, c.g, c.sgr)
241 }
242 }
243}
internal/control/view.go +2 −2
@@ -96,14 +96,14 @@ func (v *view) title(ref, title, state string) {
9696 rest += state
9797 }
9898 if rest == "" {
99 io.WriteString(v.w, t.paint(sgrCyan, ref)+"\n")
99 io.WriteString(v.w, t.paint(sgrDim, ref)+"\n")
100100 return
101101 }
102102 lines := termtext.Wrap(rest, t.Cols-cells(prefix))
103103 for i, line := range lines {
104104 p := indent
105105 if i == 0 {
106 p = t.paint(sgrCyan, ref) + " "
106 p = t.paint(sgrDim, ref) + " "
107107 }
108108 if i < len(lines)-1 || state == "" {
109109 io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n")