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 ( |
| 18 | 18 | kindNum |
| 19 | 19 | kindSize |
| 20 | 20 | kindSwatch |
| 21 | kindGlyph |
| 22 | kindMeta |
| 21 | 23 | ) |
| 22 | 24 | |
| 23 | 25 | // 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} } |
| 49 | 51 | // its word: "2 failed" red, "review requested" yellow. |
| 50 | 52 | func cMark(s, sgr string) cell { return cell{kind: kindText, s: s, sgr: sgr} } |
| 51 | 53 | |
| 54 | // cGlyph is a state's mark, coloured for the state. |
| 55 | func 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. |
| 61 | func 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. |
| 65 | func 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 | |
| 52 | 75 | // table is a list command's rows. Plain, each row is written as it |
| 53 | 76 | // comes, tab-separated with no header. At a terminal rows are held |
| 54 | 77 | // until flush, then written under a header, padded, and fitted to the |
| @@ -233,7 +256,7 @@ func (t *table) fit(widths []int) { |
| 233 | 256 | } |
| 234 | 257 | } |
| 235 | 258 | for i := len(kinds) - 1; i >= 0; i-- { |
| 236 | | if kinds[i] == kindText { |
| 259 | if kinds[i] == kindText || kinds[i] == kindMeta { |
| 237 | 260 | shrink(i) |
| 238 | 261 | } |
| 239 | 262 | } |
| @@ -272,7 +295,11 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string { |
| 272 | 295 | case kindState: |
| 273 | 296 | s = t.term.paintState(s) |
| 274 | 297 | 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) |
| 276 | 303 | case kindSwatch: |
| 277 | 304 | s = t.term.swatch(s) |
| 278 | 305 | default: |
internal/control/table_test.go
+27
| @@ -211,3 +211,30 @@ func TestTableSwatchAligned(t *testing.T) { |
| 211 | 211 | t.Errorf("plain = %q", plain.String()) |
| 212 | 212 | } |
| 213 | 213 | } |
| 214 | |
| 215 | func 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 | |
| 228 | func 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 ( |
| 56 | 56 | sgrRed = "\x1b[31m" |
| 57 | 57 | sgrGreen = "\x1b[32m" |
| 58 | 58 | sgrYellow = "\x1b[33m" |
| 59 | sgrBlue = "\x1b[34m" |
| 59 | 60 | sgrMagenta = "\x1b[35m" |
| 60 | 61 | sgrCyan = "\x1b[36m" |
| 61 | 62 | ) |
| @@ -121,6 +122,26 @@ func stateColor(s string) string { |
| 121 | 122 | return "" |
| 122 | 123 | } |
| 123 | 124 | |
| 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 "". |
| 128 | func 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 | |
| 124 | 145 | // link makes s a hyperlink to url when the terminal shows them (OSC 8). |
| 125 | 146 | // A url with a control byte in it is left out rather than sent. |
| 126 | 147 | func (t Term) link(url, s string) string { |
internal/control/term_test.go
+22
| @@ -219,3 +219,25 @@ func TestLinkAndSwatch(t *testing.T) { |
| 219 | 219 | t.Errorf("bad hex = %q", got) |
| 220 | 220 | } |
| 221 | 221 | } |
| 222 | |
| 223 | func 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) { |
| 96 | 96 | rest += state |
| 97 | 97 | } |
| 98 | 98 | if rest == "" { |
| 99 | | io.WriteString(v.w, t.paint(sgrCyan, ref)+"\n") |
| 99 | io.WriteString(v.w, t.paint(sgrDim, ref)+"\n") |
| 100 | 100 | return |
| 101 | 101 | } |
| 102 | 102 | lines := termtext.Wrap(rest, t.Cols-cells(prefix)) |
| 103 | 103 | for i, line := range lines { |
| 104 | 104 | p := indent |
| 105 | 105 | if i == 0 { |
| 106 | | p = t.paint(sgrCyan, ref) + " " |
| 106 | p = t.paint(sgrDim, ref) + " " |
| 107 | 107 | } |
| 108 | 108 | if i < len(lines)-1 || state == "" { |
| 109 | 109 | io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n") |