internal/control/table.go

dd06e80a071d451ebb7b083363e0580550481114
gitbay/internal/control/table.go history · blame · raw

200 lines · 4452 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		if cs[i].kind == kindAge {
 65			cs[i].s = relAge(cs[i].s, now)
 66		}
 67	}
 68	t.rows = append(t.rows, cs)
 69}
 70
 71func (t *table) flush() {
 72	if t.term.Cols == 0 || len(t.rows) == 0 {
 73		return
 74	}
 75	// The column count is never smaller than the longest row: a row with
 76	// more cells than the header has still gets every cell rendered, the
 77	// header just shows blank above the ones it doesn't name.
 78	n := len(t.header)
 79	for _, r := range t.rows {
 80		n = max(n, len(r))
 81	}
 82	widths := make([]int, n)
 83	for i, h := range t.header {
 84		widths[i] = cells(h)
 85	}
 86	for _, r := range t.rows {
 87		for i := 0; i < len(r); i++ {
 88			widths[i] = max(widths[i], cells(r[i].s))
 89		}
 90	}
 91	t.fit(widths)
 92
 93	var b strings.Builder
 94	line := make([]string, n)
 95	for i := range line {
 96		if i < len(t.header) {
 97			line[i] = t.header[i]
 98		}
 99	}
100	b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n")
101	for _, r := range t.rows {
102		for i := 0; i < n; i++ {
103			s := ""
104			if i < len(r) {
105				s = clip(r[i].s, widths[i])
106			}
107			line[i] = s
108		}
109		b.WriteString(t.joinRow(r, line, widths) + "\n")
110	}
111	io.WriteString(t.w, b.String())
112}
113
114// fit shrinks columns until a row fits the terminal: the flexible
115// column first, down to 8 cells, then the other text columns from the
116// right, down to 8 each.
117func (t *table) fit(widths []int) {
118	total := func() int {
119		s := 2 * (len(widths) - 1)
120		for _, w := range widths {
121			s += w
122		}
123		return s
124	}
125	kinds := make([]cellKind, len(widths))
126	for i := range widths {
127		for _, r := range t.rows {
128			if i < len(r) {
129				kinds[i] = r[i].kind
130				break
131			}
132		}
133	}
134	shrink := func(i int) {
135		if over := total() - t.term.Cols; over > 0 && widths[i] > 8 {
136			widths[i] = max(8, widths[i]-over)
137		}
138	}
139	for i, k := range kinds {
140		if k == kindFlex {
141			shrink(i)
142		}
143	}
144	for i := len(kinds) - 1; i >= 0; i-- {
145		if kinds[i] == kindText {
146			shrink(i)
147		}
148	}
149}
150
151// join pads every column but the last and separates them by two spaces.
152func (t *table) join(line []string, widths []int) string {
153	var b strings.Builder
154	for i, s := range line {
155		if i > 0 {
156			b.WriteString("  ")
157		}
158		if i == len(line)-1 {
159			b.WriteString(s)
160		} else {
161			b.WriteString(pad(s, widths[i]))
162		}
163	}
164	return b.String()
165}
166
167// joinRow is join with state cells coloured after padding, so the
168// SGR bytes never count against the width.
169func (t *table) joinRow(r []cell, line []string, widths []int) string {
170	var b strings.Builder
171	for i, s := range line {
172		if i > 0 {
173			b.WriteString("  ")
174		}
175		padding := ""
176		if i < len(line)-1 {
177			padding = strings.Repeat(" ", max(0, widths[i]-cells(s)))
178		}
179		if i < len(r) && r[i].kind == kindState {
180			s = t.term.paint(stateColor(s), s)
181		}
182		b.WriteString(s + padding)
183	}
184	return b.String()
185}
186
187// stripSGR removes SGR sequences, for tests and width checks.
188func stripSGR(s string) string {
189	var b strings.Builder
190	for i := 0; i < len(s); i++ {
191		if s[i] == 0x1b {
192			if j := strings.IndexByte(s[i:], 'm'); j >= 0 {
193				i += j
194				continue
195			}
196		}
197		b.WriteByte(s[i])
198	}
199	return b.String()
200}