internal/control/table.go
361 lines · 9080 bytes
39 symbols in this file
cellKindkindTextkindFlexkindRefkindStatekindAgekindNumkindSizekindSwatchkindGlyphkindMetacellcRefcStatecTextcFlexcAgecNumcSizecLinkcSwatchcMarkcGlyphcFlexRefcYoucMetatableCtx.noteCtx.tabletable.rowtable.flushtable.numerictable.linestable.dropEmptytable.capSparsetable.fittable.jointable.joinRowstripSGR
1package control
2
3import (
4 "io"
5 "slices"
6 "strconv"
7 "strings"
8)
9
10type cellKind int
11
12const (
13 kindText cellKind = iota
14 kindFlex
15 kindRef
16 kindState
17 kindAge
18 kindNum
19 kindSize
20 kindSwatch
21 kindGlyph
22 kindMeta
23)
24
25// cell is one column of a table row. The kind decides colour, time
26// format, and whether the column may be clipped to fit the terminal.
27type cell struct {
28 kind cellKind
29 s string
30 sgr string // colour for a kindText cell whose meaning is not its word
31 url string // the page a kindRef cell links to, when the terminal shows links
32}
33
34func cRef(s string) cell { return cell{kind: kindRef, s: s} }
35func cState(s string) cell { return cell{kind: kindState, s: s} }
36func cText(s string) cell { return cell{kind: kindText, s: s} }
37func cFlex(s string) cell { return cell{kind: kindFlex, s: s} }
38func cAge(ts string) cell { return cell{kind: kindAge, s: ts} }
39func cNum(n int64) cell { return cell{kind: kindNum, s: strconv.FormatInt(n, 10)} }
40func cSize(n int64) cell { return cell{kind: kindSize, s: strconv.FormatInt(n, 10)} }
41
42// cLink is a reference that links to its page at a terminal that shows
43// links; url is a path on this instance or an absolute URL.
44func cLink(s, url string) cell { return cell{kind: kindRef, s: s, url: url} }
45
46// cSwatch is a label colour: a coloured dot before the hex at a terminal
47// with 24-bit colour.
48func cSwatch(hex string) cell { return cell{kind: kindSwatch, s: hex} }
49
50// cMark is text coloured for what it says about the row rather than for
51// its word: "2 failed" red, "review requested" yellow.
52func cMark(s, sgr string) cell { return cell{kind: kindText, s: s, sgr: sgr} }
53
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// cFlexRef is an identifier too long to keep whole at every width, a
61// key fingerprint: dim like a reference, but cut to fit like a title.
62// Piped and --json output carry it whole.
63func cFlexRef(s string) cell { return cell{kind: kindFlex, s: s, sgr: sgrDim} }
64
65// cYou is the mark for a row that waits on the viewer.
66func cYou() cell { return cell{kind: kindGlyph, s: "●", sgr: sgrYellow} }
67
68// cMeta is a row's trailing facts, dim and joined by " · ". Empty parts
69// are skipped.
70func cMeta(parts ...string) cell {
71 var keep []string
72 for _, p := range parts {
73 if p != "" {
74 keep = append(keep, p)
75 }
76 }
77 return cell{kind: kindMeta, s: strings.Join(keep, " · ")}
78}
79
80// table is a list command's rows. Plain, each row is written as it
81// comes, tab-separated with no header. At a terminal rows are held
82// until flush, then padded and fitted to the width, under a header only
83// when a column holds numbers.
84type table struct {
85 term Term
86 w io.Writer
87 header []string
88 rows [][]cell
89}
90
91// note adds a marker to a row: its own trailing cell in plain output,
92// as rows have always carried it, and joined to the state cell at
93// cells[at] at a terminal ("private, archived"), so the table has no
94// unnamed column.
95func (c *Ctx) note(cells []cell, at int, plain, word string) []cell {
96 if c.Term.Cols == 0 {
97 return append(cells, cText(plain))
98 }
99 cells[at].s += ", " + word
100 return cells
101}
102
103func (c *Ctx) table(w io.Writer, header ...string) *table {
104 return &table{term: c.Term, w: w, header: header}
105}
106
107func (t *table) row(cs ...cell) {
108 if t.term.Cols == 0 {
109 parts := make([]string, len(cs))
110 for i, c := range cs {
111 if c.kind == kindAge {
112 parts[i] = stamp(c.s)
113 } else {
114 parts[i] = c.s
115 }
116 }
117 io.WriteString(t.w, strings.Join(parts, "\t")+"\n")
118 return
119 }
120 now := termNow()
121 for i := range cs {
122 cs[i].s = termSafe(cs[i].s)
123 switch cs[i].kind {
124 case kindAge:
125 cs[i].s = relAge(cs[i].s, now)
126 case kindSize:
127 if n, err := strconv.ParseInt(cs[i].s, 10, 64); err == nil {
128 cs[i].s = humanBytes(n)
129 }
130 case kindSwatch:
131 if t.term.TrueColor && rgb(cs[i].s) != "" {
132 cs[i].s = "● " + cs[i].s
133 }
134 }
135 }
136 t.rows = append(t.rows, cs)
137}
138
139func (t *table) flush() {
140 if t.term.Cols == 0 || len(t.rows) == 0 {
141 return
142 }
143 var b strings.Builder
144 for _, l := range t.lines() {
145 b.WriteString(l + "\n")
146 }
147 io.WriteString(t.w, b.String())
148}
149
150// numeric reports whether a column holds numbers or sizes, which need a
151// header to say what they count.
152func (t *table) numeric() bool {
153 for _, r := range t.rows {
154 for _, c := range r {
155 if c.kind == kindNum || c.kind == kindSize {
156 return true
157 }
158 }
159 }
160 return false
161}
162
163// lines lays the rows out at the terminal width: a dim header first
164// only when a column is a number, then each row padded and painted.
165func (t *table) lines() []string {
166 if !t.numeric() {
167 t.header = nil
168 }
169 t.dropEmpty()
170 // The column count is never smaller than the longest row: a row with
171 // more cells than the header has still gets every cell rendered, the
172 // header just shows blank above the ones it doesn't name.
173 n := len(t.header)
174 for _, r := range t.rows {
175 n = max(n, len(r))
176 }
177 widths := make([]int, n)
178 for i, h := range t.header {
179 widths[i] = cells(h)
180 }
181 for _, r := range t.rows {
182 for i := 0; i < len(r); i++ {
183 widths[i] = max(widths[i], cells(r[i].s))
184 }
185 }
186 t.capSparse(widths)
187 t.fit(widths)
188
189 var out []string
190 line := make([]string, n)
191 if len(t.header) > 0 {
192 for i := range line {
193 line[i] = ""
194 if i < len(t.header) {
195 line[i] = clip(t.header[i], widths[i])
196 }
197 }
198 out = append(out, t.term.paint(sgrDim, strings.TrimRight(t.join(line, widths), " ")))
199 }
200 for _, r := range t.rows {
201 for i := 0; i < n; i++ {
202 s := ""
203 if i < len(r) {
204 s = clip(r[i].s, widths[i])
205 }
206 line[i] = s
207 }
208 out = append(out, strings.TrimRight(t.joinRow(r, line, widths), " "))
209 }
210 return out
211}
212
213// dropEmpty removes a column that is blank on every row, header and
214// all: an issue list where nothing is labelled has no LABELS column.
215func (t *table) dropEmpty() {
216 n := len(t.header)
217 for _, r := range t.rows {
218 n = max(n, len(r))
219 }
220 for i := n - 1; i >= 0; i-- {
221 empty := true
222 for _, r := range t.rows {
223 if i < len(r) && r[i].s != "" {
224 empty = false
225 break
226 }
227 }
228 if !empty {
229 continue
230 }
231 if i < len(t.header) {
232 t.header = slices.Delete(slices.Clone(t.header), i, i+1)
233 }
234 for j, r := range t.rows {
235 if i < len(r) {
236 t.rows[j] = slices.Delete(r, i, i+1)
237 }
238 }
239 }
240}
241
242// capSparse narrows a flexible column that is blank on most rows to a
243// third of the terminal, so a few long values do not push every other
244// row's later columns to the right edge.
245func (t *table) capSparse(widths []int) {
246 for i := range widths {
247 filled, flex := 0, false
248 for _, r := range t.rows {
249 if i < len(r) && r[i].kind == kindFlex {
250 flex = true
251 if r[i].s != "" {
252 filled++
253 }
254 }
255 }
256 if flex && filled*2 < len(t.rows) {
257 widths[i] = min(widths[i], max(8, t.term.Cols/3))
258 }
259 }
260}
261
262// fit shrinks columns until a row fits the terminal: the flexible
263// column first, down to 8 cells, then the other text columns from the
264// right, down to 8 each.
265func (t *table) fit(widths []int) {
266 total := func() int {
267 s := 2 * (len(widths) - 1)
268 for _, w := range widths {
269 s += w
270 }
271 return s
272 }
273 kinds := make([]cellKind, len(widths))
274 for i := range widths {
275 for _, r := range t.rows {
276 if i < len(r) {
277 kinds[i] = r[i].kind
278 break
279 }
280 }
281 }
282 shrink := func(i int) {
283 if over := total() - t.term.Cols; over > 0 && widths[i] > 8 {
284 widths[i] = max(8, widths[i]-over)
285 }
286 }
287 for i, k := range kinds {
288 if k == kindFlex {
289 shrink(i)
290 }
291 }
292 for i := len(kinds) - 1; i >= 0; i-- {
293 if kinds[i] == kindText || kinds[i] == kindMeta {
294 shrink(i)
295 }
296 }
297}
298
299// join pads every column but the last and separates them by two spaces.
300func (t *table) join(line []string, widths []int) string {
301 var b strings.Builder
302 for i, s := range line {
303 if i > 0 {
304 b.WriteString(" ")
305 }
306 if i == len(line)-1 {
307 b.WriteString(s)
308 } else {
309 b.WriteString(pad(s, widths[i]))
310 }
311 }
312 return b.String()
313}
314
315// joinRow is join with state cells coloured after padding, so the
316// SGR bytes never count against the width.
317func (t *table) joinRow(r []cell, line []string, widths []int) string {
318 var b strings.Builder
319 for i, s := range line {
320 if i > 0 {
321 b.WriteString(" ")
322 }
323 padding := ""
324 if i < len(line)-1 {
325 padding = strings.Repeat(" ", max(0, widths[i]-cells(s)))
326 }
327 if i < len(r) {
328 switch r[i].kind {
329 case kindState:
330 s = t.term.paintState(s)
331 case kindRef:
332 s = t.term.link(r[i].url, t.term.paint(sgrDim, s))
333 case kindGlyph:
334 s = t.term.paint(r[i].sgr, s)
335 case kindMeta:
336 s = t.term.paint(sgrDim, s)
337 case kindSwatch:
338 s = t.term.swatch(s)
339 default:
340 s = t.term.paint(r[i].sgr, s)
341 }
342 }
343 b.WriteString(s + padding)
344 }
345 return b.String()
346}
347
348// stripSGR removes SGR sequences, for tests and width checks.
349func stripSGR(s string) string {
350 var b strings.Builder
351 for i := 0; i < len(s); i++ {
352 if s[i] == 0x1b {
353 if j := strings.IndexByte(s[i:], 'm'); j >= 0 {
354 i += j
355 continue
356 }
357 }
358 b.WriteByte(s[i])
359 }
360 return b.String()
361}