internal/control/screen.go
320 lines · 8763 bytes
1package control
2
3import (
4 "fmt"
5 "io"
6 "strconv"
7 "strings"
8
9 "gitbay.org/gitbay/internal/termtext"
10)
11
12// screen is a show or a list as a terminal draws it: a header block of
13// labelled fields, a body, counted sections, and the commands that
14// apply. Commands build one only at a terminal; plain and --json output
15// never reach it.
16type screen struct {
17 fields []field
18 body string // markup source
19 format string // the body's markup format; "text" is not rendered
20 sections []section
21 actions []action
22}
23
24// field is one header line: a label and the cells after it.
25type field struct {
26 label string
27 value []cell
28}
29
30// section is a titled run of rows. n is the total, shown as "(n)", and
31// note follows it dim ("+86 −12"); when n is more than the rows shown,
32// more is the command for the rest. An empty section is left out unless
33// empty says to draw "(0)".
34type section struct {
35 title string
36 n int
37 note string
38 rows []row
39 more []string
40 empty bool
41}
42
43// row is a section row, and optionally the markup that follows it (a
44// comment's body).
45type row struct {
46 cells []cell
47 body string
48 format string
49}
50
51func rowOf(cs ...cell) row { return row{cells: cs} }
52
53// action is one command in the legend, under its group's name. argv is
54// the command as typed after "gitbay".
55type action struct {
56 group string
57 argv []string
58}
59
60// listScreen is a list command's terminal screen: one section of rows
61// and the commands that apply.
62func listScreen(title string, rows []row, actions ...action) screen {
63 return screen{sections: []section{{title: title, n: len(rows), rows: rows}}, actions: actions}
64}
65
66// render draws s: the parts in order, one blank line between parts and
67// between sections.
68func (c *Ctx) render(w io.Writer, s screen) {
69 var blocks []string
70 if b := c.renderFields(s.fields); b != "" {
71 blocks = append(blocks, b)
72 }
73 if b := c.renderBody(s.body, s.format, ""); b != "" {
74 blocks = append(blocks, b)
75 }
76 for _, sec := range s.sections {
77 if b := c.renderSection(sec); b != "" {
78 blocks = append(blocks, b)
79 }
80 }
81 if b := c.renderLegend(s.actions); b != "" {
82 blocks = append(blocks, b)
83 }
84 io.WriteString(w, strings.Join(blocks, "\n"))
85}
86
87// renderFields aligns labels on the widest, dim, with a colon. Every
88// cell but the last is painted as a table paints it; the last wraps to
89// the width, continuation lines under its first column.
90func (c *Ctx) renderFields(fs []field) string {
91 t := c.Term
92 wide := 0
93 for _, f := range fs {
94 if len(f.value) > 0 {
95 wide = max(wide, cells(f.label)+1)
96 }
97 }
98 var b strings.Builder
99 for _, f := range fs {
100 if len(f.value) == 0 {
101 continue
102 }
103 prefix := t.paint(sgrDim, f.label+":") + strings.Repeat(" ", wide-cells(f.label)-1+2)
104 col := wide + 2
105 head := ""
106 for _, cl := range f.value[:len(f.value)-1] {
107 s := c.cellText(cl)
108 if s == "" {
109 continue
110 }
111 head += c.paintCell(cl, s) + " "
112 col += cells(s) + 2
113 }
114 last := f.value[len(f.value)-1]
115 indent := strings.Repeat(" ", col)
116 for i, l := range termtext.Wrap(c.cellText(last), max(8, t.Cols-col)) {
117 p := indent
118 if i == 0 {
119 p = prefix + head
120 }
121 b.WriteString(strings.TrimRight(p+c.paintCell(last, l), " ") + "\n")
122 }
123 }
124 return b.String()
125}
126
127// cellText is a cell's terminal text: made safe, ages relative, sizes
128// humanized, as table.row prepares it.
129func (c *Ctx) cellText(cl cell) string {
130 s := termSafe(cl.s)
131 switch cl.kind {
132 case kindAge:
133 return relAge(s, termNow())
134 case kindSize:
135 if n, err := strconv.ParseInt(s, 10, 64); err == nil {
136 return humanBytes(n)
137 }
138 }
139 return s
140}
141
142// paintCell paints text s as its cell's kind paints at a terminal.
143func (c *Ctx) paintCell(cl cell, s string) string {
144 t := c.Term
145 switch cl.kind {
146 case kindState:
147 return t.paintState(s)
148 case kindRef:
149 return t.link(cl.url, t.paint(sgrDim, s))
150 case kindMeta:
151 return t.paint(sgrDim, s)
152 case kindSwatch:
153 return t.swatch(s)
154 }
155 return t.paint(cl.sgr, s)
156}
157
158// renderBody renders markup to the width, each line prefixed by indent.
159// Format "text" is wrapped as it is, without markup.
160func (c *Ctx) renderBody(src, format, indent string) string {
161 if strings.TrimSpace(src) == "" {
162 return ""
163 }
164 width := max(0, c.Term.Cols-len(indent))
165 var out []string
166 if format == "text" {
167 for _, para := range strings.Split(strings.TrimRight(termSafe(src), "\n"), "\n") {
168 out = append(out, termtext.Wrap(para, width)...)
169 }
170 } else {
171 opts := termtext.Options{Width: width, Color: c.Term.Color, Base: c.Cfg.Server.SiteURL}
172 out = strings.Split(strings.TrimRight(termtext.Render(termSafe(src), format, opts), "\n"), "\n")
173 }
174 var b strings.Builder
175 for _, l := range out {
176 if l == "" {
177 b.WriteString("\n")
178 continue
179 }
180 b.WriteString(indent + l + "\n")
181 }
182 return b.String()
183}
184
185// renderSection is the heading, the rows laid out as one table with no
186// header, each row's body indented beneath it, and the "+n more" line.
187func (c *Ctx) renderSection(s section) string {
188 if len(s.rows) == 0 && !s.empty {
189 return ""
190 }
191 var b strings.Builder
192 b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)))
193 if s.note != "" {
194 b.WriteString(" " + c.Term.paint(sgrDim, termSafe(s.note)))
195 }
196 b.WriteString("\n")
197 if len(s.rows) > 0 {
198 tb := &table{term: c.Term, w: io.Discard}
199 for _, r := range s.rows {
200 tb.row(append([]cell(nil), r.cells...)...)
201 }
202 for i, l := range tb.lines() {
203 b.WriteString(l + "\n")
204 b.WriteString(c.renderBody(s.rows[i].body, s.rows[i].format, " "))
205 }
206 }
207 if rest := s.n - len(s.rows); rest > 0 && len(s.more) > 0 {
208 b.WriteString(c.Term.paint(sgrDim, fmt.Sprintf("+%d more %s", rest, c.cmdline(s.more))) + "\n")
209 }
210 return b.String()
211}
212
213// cmdline is argv as the viewer would type it: "gitbay", the words
214// shell-quoted, and the repository the CLI inferred left out.
215func (c *Ctx) cmdline(argv []string) string {
216 words := []string{"gitbay"}
217 for _, a := range argv {
218 if c.Term.Here != "" && a == c.Term.Here {
219 continue
220 }
221 words = append(words, shellWord(a))
222 }
223 return strings.Join(words, " ")
224}
225
226// renderLegend is a rule, then the action groups in the order they first
227// appear, each a bold name over its commands in blue. Groups sit side by
228// side, three to a band, when the terminal is 80 wide or more and the
229// band fits; otherwise they stack.
230func (c *Ctx) renderLegend(as []action) string {
231 if len(as) == 0 {
232 return ""
233 }
234 t := c.Term
235 type group struct {
236 name string
237 cmds []string
238 }
239 var groups []*group
240 byName := map[string]*group{}
241 for _, a := range as {
242 g := byName[a.group]
243 if g == nil {
244 g = &group{name: a.group}
245 byName[a.group] = g
246 groups = append(groups, g)
247 }
248 g.cmds = append(g.cmds, c.cmdline(a.argv))
249 }
250 var b strings.Builder
251 b.WriteString(t.paint(sgrDim, strings.Repeat("─", t.Cols)) + "\n")
252 for start := 0; start < len(groups); start += 3 {
253 band := groups[start:min(start+3, len(groups))]
254 widths := make([]int, len(band))
255 total, height := 2*(len(band)-1), 0
256 for i, g := range band {
257 widths[i] = cells(g.name)
258 for _, cmd := range g.cmds {
259 widths[i] = max(widths[i], cells(cmd))
260 }
261 total += widths[i]
262 height = max(height, len(g.cmds))
263 }
264 if t.Cols < 80 || total > t.Cols {
265 for _, g := range band {
266 b.WriteString(t.paint(sgrBold, g.name) + "\n")
267 for _, cmd := range g.cmds {
268 b.WriteString(t.paint(sgrBlue, cmd) + "\n")
269 }
270 }
271 continue
272 }
273 for line := -1; line < height; line++ {
274 var l strings.Builder
275 for i, g := range band {
276 s, sgr := "", sgrBlue
277 if line < 0 {
278 s, sgr = g.name, sgrBold
279 } else if line < len(g.cmds) {
280 s = g.cmds[line]
281 }
282 l.WriteString(t.paint(sgr, s))
283 if i < len(band)-1 {
284 l.WriteString(strings.Repeat(" ", widths[i]-cells(s)+2))
285 }
286 }
287 b.WriteString(strings.TrimRight(l.String(), " ") + "\n")
288 }
289 }
290 return b.String()
291}
292
293// discussion is a thread's comments as a section, each author and age
294// with its body beneath. System events are events'. It is drawn when
295// empty, as "(0)".
296func discussion(cs []commentOut) section {
297 sec := section{title: "Discussion", empty: true}
298 for _, cm := range cs {
299 if cm.Kind == "system" {
300 continue
301 }
302 sec.n++
303 sec.rows = append(sec.rows, row{cells: []cell{cText(cm.Author), cAge(cm.CreatedAt)}, body: cm.Body, format: cm.BodyFormat})
304 }
305 return sec
306}
307
308// events is a thread's system comments ("referenced in commit ...") as
309// a section, each its age and its text without link targets.
310func events(cs []commentOut) section {
311 sec := section{title: "Events"}
312 for _, cm := range cs {
313 if cm.Kind != "system" {
314 continue
315 }
316 sec.n++
317 sec.rows = append(sec.rows, rowOf(cAge(cm.CreatedAt), cFlex(termtext.Inline(cm.Body, cm.BodyFormat))))
318 }
319 return sec
320}