internal/control/view.go

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

198 lines · 5387 bytes

  1package control
  2
  3import (
  4	"io"
  5	"strings"
  6
  7	"gitbay.org/gitbay/internal/termtext"
  8)
  9
 10// when is a stored timestamp in a view: the web's format at a
 11// terminal, RFC3339 to the second when plain.
 12func (c *Ctx) when(s string) string {
 13	if c.Term.Cols == 0 {
 14		return stamp(s)
 15	}
 16	t, ok := parseStamp(s)
 17	if !ok {
 18		return s
 19	}
 20	return t.Format("2006-01-02 15:04 UTC")
 21}
 22
 23// siteURL is the instance's address with path segments appended.
 24func (c *Ctx) siteURL(parts ...string) string {
 25	return strings.TrimRight(c.Cfg.Server.SiteURL, "/") + "/" + strings.Join(parts, "/")
 26}
 27
 28// view lays out a show: a title line, aligned fields, a body, events,
 29// comments. Plain output is the same lines without colour or wrapping.
 30type view struct {
 31	c     *Ctx
 32	w     io.Writer
 33	wrote bool // has this view written anything yet
 34}
 35
 36func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} }
 37
 38func (v *view) opts() termtext.Options {
 39	return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL}
 40}
 41
 42// sep writes a blank line before the next block, unless this view has
 43// written nothing yet: fields, a body or a section as the first thing a
 44// command prints (admin runners, admin stats, notifications settings
 45// show, ...) does not open with an empty line.
 46func (v *view) sep() {
 47	if v.wrote {
 48		io.WriteString(v.w, "\n")
 49	}
 50	v.wrote = true
 51}
 52
 53// section prints a sub-table's label: a blank line, then the label bold
 54// at a terminal or "label:" in plain. Callers skip the call entirely
 55// when the table it introduces has no rows.
 56func (v *view) section(label string) {
 57	v.sep()
 58	if v.c.Term.Cols == 0 {
 59		io.WriteString(v.w, label+":\n")
 60		return
 61	}
 62	io.WriteString(v.w, v.c.Term.paint(sgrBold, label)+"\n")
 63}
 64
 65// title prints "ref  title  state", wrapping title+state to the
 66// terminal width. Continuation lines indent under the title, and each
 67// line is painted after wrapping so no SGR sequence crosses a break.
 68// title or state may be "": either is skipped rather than leaving a
 69// trailing blank field.
 70func (v *view) title(ref, title, state string) {
 71	v.sep()
 72	t := v.c.Term
 73	if t.Cols == 0 {
 74		switch {
 75		case title == "" && state == "":
 76			io.WriteString(v.w, ref+"\n")
 77		case state == "":
 78			io.WriteString(v.w, ref+"  "+t.paint(sgrBold, title)+"\n")
 79		case title == "":
 80			io.WriteString(v.w, ref+"  "+t.paint(stateColor(state), state)+"\n")
 81		default:
 82			io.WriteString(v.w, ref+"  "+t.paint(sgrBold, title)+"  "+t.paint(stateColor(state), state)+"\n")
 83		}
 84		return
 85	}
 86	prefix := ref + "  "
 87	indent := strings.Repeat(" ", cells(prefix))
 88	rest := title
 89	if state != "" {
 90		if rest != "" {
 91			rest += "  "
 92		}
 93		rest += state
 94	}
 95	if rest == "" {
 96		io.WriteString(v.w, ref+"\n")
 97		return
 98	}
 99	lines := termtext.Wrap(rest, t.Cols-cells(prefix))
100	for i, line := range lines {
101		p := indent
102		if i == 0 {
103			p = prefix
104		}
105		if i < len(lines)-1 || state == "" {
106			io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n")
107			continue
108		}
109		// Last line carries the state word, the last word overall
110		// (states are single words): keep it coloured, not bold.
111		head, last := line, line
112		if idx := strings.LastIndex(line, " "); idx >= 0 {
113			head, last = line[:idx], line[idx+1:]
114		} else {
115			head = ""
116		}
117		io.WriteString(v.w, p)
118		if head != "" {
119			io.WriteString(v.w, t.paint(sgrBold, head)+" ")
120		}
121		io.WriteString(v.w, t.paint(stateColor(state), last)+"\n")
122	}
123}
124
125// fields prints key/value pairs aligned on the widest key, skipping
126// empty values. A value that does not fit wraps, its continuation
127// lines indented to the value column.
128func (v *view) fields(kv ...string) {
129	wide := 0
130	for i := 0; i+1 < len(kv); i += 2 {
131		if kv[i+1] != "" {
132			wide = max(wide, cells(kv[i]))
133		}
134	}
135	v.sep()
136	for i := 0; i+1 < len(kv); i += 2 {
137		key, val := kv[i], kv[i+1]
138		if val == "" {
139			continue
140		}
141		prefix := "  " + v.c.Term.paint(sgrDim, pad(key, wide)) + "  "
142		if v.c.Term.Cols == 0 {
143			io.WriteString(v.w, prefix+val+"\n")
144			continue
145		}
146		indent := strings.Repeat(" ", 2+wide+2)
147		for j, line := range termtext.Wrap(val, v.c.Term.Cols-2-wide-2) {
148			p := indent
149			if j == 0 {
150				p = prefix
151			}
152			io.WriteString(v.w, p+line+"\n")
153		}
154	}
155}
156
157func (v *view) body(src, format string) {
158	if strings.TrimSpace(src) == "" {
159		return
160	}
161	v.sep()
162	for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") {
163		if line == "" {
164			io.WriteString(v.w, "\n")
165			continue
166		}
167		io.WriteString(v.w, "  "+line+"\n")
168	}
169}
170
171// event is one line for a system comment: its text without link
172// targets, the time at the right edge at a terminal.
173func (v *view) event(text, format, ts string) {
174	line := "· " + termtext.Inline(text, format)
175	when := v.c.when(ts)
176	if cols := v.c.Term.Cols; cols > 0 {
177		room := cols - 2 - 2 - cells(when)
178		line = pad(clip(line, room), room)
179	}
180	io.WriteString(v.w, "  "+v.c.Term.paint(sgrDim, line+"  "+when)+"\n")
181	v.wrote = true
182}
183
184func (v *view) comment(author, ts, body, format string) {
185	when := v.c.when(ts)
186	cols := v.c.Term.Cols
187	if cols > 0 {
188		suffix := ", " + when + " "
189		author = clip(author, max(0, cols-cells("── "+suffix)-1))
190	}
191	head := "── " + author + ", " + when + " "
192	if cols > 0 {
193		head += strings.Repeat("─", max(1, cols-cells(head)))
194	}
195	v.sep()
196	io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n")
197	v.body(body, format)
198}