internal/control/table.go
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}