internal/control/screen.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/control/screen.go history · blame · raw

320 lines · 8763 bytes

17 symbols in this file
  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}