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}