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