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