internal/control/table.go

361 lines · 9080 bytes

39 symbols in this file
  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}