internal/control/table.go

8dcfa45a8ac03a5ff9c36828274d05acadcf846c
gitbay/internal/control/table.go history · blame · raw

201 lines · 4499 bytes

  1package control
  2
  3import (
  4	"io"
  5	"strconv"
  6	"strings"
  7)
  8
  9type cellKind int
 10
 11const (
 12	kindText cellKind = iota
 13	kindFlex
 14	kindRef
 15	kindState
 16	kindAge
 17	kindNum
 18)
 19
 20// cell is one column of a table row. The kind decides colour, time
 21// format, and whether the column may be clipped to fit the terminal.
 22type cell struct {
 23	kind cellKind
 24	s    string
 25}
 26
 27func cRef(s string) cell   { return cell{kindRef, s} }
 28func cState(s string) cell { return cell{kindState, s} }
 29func cText(s string) cell  { return cell{kindText, s} }
 30func cFlex(s string) cell  { return cell{kindFlex, s} }
 31func cAge(ts string) cell  { return cell{kindAge, ts} }
 32func cNum(n int64) cell    { return cell{kindNum, strconv.FormatInt(n, 10)} }
 33
 34// table is a list command's rows. Plain, each row is written as it
 35// comes, tab-separated with no header. At a terminal rows are held
 36// until flush, then written under a header, padded, and fitted to the
 37// width.
 38type table struct {
 39	term   Term
 40	w      io.Writer
 41	header []string
 42	rows   [][]cell
 43}
 44
 45func (c *Ctx) table(w io.Writer, header ...string) *table {
 46	return &table{term: c.Term, w: w, header: header}
 47}
 48
 49func (t *table) row(cs ...cell) {
 50	if t.term.Cols == 0 {
 51		parts := make([]string, len(cs))
 52		for i, c := range cs {
 53			if c.kind == kindAge {
 54				parts[i] = stamp(c.s)
 55			} else {
 56				parts[i] = c.s
 57			}
 58		}
 59		io.WriteString(t.w, strings.Join(parts, "\t")+"\n")
 60		return
 61	}
 62	now := termNow()
 63	for i := range cs {
 64		cs[i].s = termSafe(cs[i].s)
 65		if cs[i].kind == kindAge {
 66			cs[i].s = relAge(cs[i].s, now)
 67		}
 68	}
 69	t.rows = append(t.rows, cs)
 70}
 71
 72func (t *table) flush() {
 73	if t.term.Cols == 0 || len(t.rows) == 0 {
 74		return
 75	}
 76	// The column count is never smaller than the longest row: a row with
 77	// more cells than the header has still gets every cell rendered, the
 78	// header just shows blank above the ones it doesn't name.
 79	n := len(t.header)
 80	for _, r := range t.rows {
 81		n = max(n, len(r))
 82	}
 83	widths := make([]int, n)
 84	for i, h := range t.header {
 85		widths[i] = cells(h)
 86	}
 87	for _, r := range t.rows {
 88		for i := 0; i < len(r); i++ {
 89			widths[i] = max(widths[i], cells(r[i].s))
 90		}
 91	}
 92	t.fit(widths)
 93
 94	var b strings.Builder
 95	line := make([]string, n)
 96	for i := range line {
 97		if i < len(t.header) {
 98			line[i] = clip(t.header[i], widths[i])
 99		}
100	}
101	b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n")
102	for _, r := range t.rows {
103		for i := 0; i < n; i++ {
104			s := ""
105			if i < len(r) {
106				s = clip(r[i].s, widths[i])
107			}
108			line[i] = s
109		}
110		b.WriteString(t.joinRow(r, line, widths) + "\n")
111	}
112	io.WriteString(t.w, b.String())
113}
114
115// fit shrinks columns until a row fits the terminal: the flexible
116// column first, down to 8 cells, then the other text columns from the
117// right, down to 8 each.
118func (t *table) fit(widths []int) {
119	total := func() int {
120		s := 2 * (len(widths) - 1)
121		for _, w := range widths {
122			s += w
123		}
124		return s
125	}
126	kinds := make([]cellKind, len(widths))
127	for i := range widths {
128		for _, r := range t.rows {
129			if i < len(r) {
130				kinds[i] = r[i].kind
131				break
132			}
133		}
134	}
135	shrink := func(i int) {
136		if over := total() - t.term.Cols; over > 0 && widths[i] > 8 {
137			widths[i] = max(8, widths[i]-over)
138		}
139	}
140	for i, k := range kinds {
141		if k == kindFlex {
142			shrink(i)
143		}
144	}
145	for i := len(kinds) - 1; i >= 0; i-- {
146		if kinds[i] == kindText {
147			shrink(i)
148		}
149	}
150}
151
152// join pads every column but the last and separates them by two spaces.
153func (t *table) join(line []string, widths []int) string {
154	var b strings.Builder
155	for i, s := range line {
156		if i > 0 {
157			b.WriteString("  ")
158		}
159		if i == len(line)-1 {
160			b.WriteString(s)
161		} else {
162			b.WriteString(pad(s, widths[i]))
163		}
164	}
165	return b.String()
166}
167
168// joinRow is join with state cells coloured after padding, so the
169// SGR bytes never count against the width.
170func (t *table) joinRow(r []cell, line []string, widths []int) string {
171	var b strings.Builder
172	for i, s := range line {
173		if i > 0 {
174			b.WriteString("  ")
175		}
176		padding := ""
177		if i < len(line)-1 {
178			padding = strings.Repeat(" ", max(0, widths[i]-cells(s)))
179		}
180		if i < len(r) && r[i].kind == kindState {
181			s = t.term.paint(stateColor(s), s)
182		}
183		b.WriteString(s + padding)
184	}
185	return b.String()
186}
187
188// stripSGR removes SGR sequences, for tests and width checks.
189func stripSGR(s string) string {
190	var b strings.Builder
191	for i := 0; i < len(s); i++ {
192		if s[i] == 0x1b {
193			if j := strings.IndexByte(s[i:], 'm'); j >= 0 {
194				i += j
195				continue
196			}
197		}
198		b.WriteByte(s[i])
199	}
200	return b.String()
201}