internal/control/view.go
201 lines · 5583 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 ref, title, state = t.safe(ref), t.safe(title), t.safe(state)
74 if t.Cols == 0 {
75 switch {
76 case title == "" && state == "":
77 io.WriteString(v.w, ref+"\n")
78 case state == "":
79 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+"\n")
80 case title == "":
81 io.WriteString(v.w, ref+" "+t.paint(stateColor(state), state)+"\n")
82 default:
83 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n")
84 }
85 return
86 }
87 prefix := ref + " "
88 indent := strings.Repeat(" ", cells(prefix))
89 rest := title
90 if state != "" {
91 if rest != "" {
92 rest += " "
93 }
94 rest += state
95 }
96 if rest == "" {
97 io.WriteString(v.w, ref+"\n")
98 return
99 }
100 lines := termtext.Wrap(rest, t.Cols-cells(prefix))
101 for i, line := range lines {
102 p := indent
103 if i == 0 {
104 p = prefix
105 }
106 if i < len(lines)-1 || state == "" {
107 io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n")
108 continue
109 }
110 // Last line carries the state word, the last word overall
111 // (states are single words): keep it coloured, not bold.
112 head, last := line, line
113 if idx := strings.LastIndex(line, " "); idx >= 0 {
114 head, last = line[:idx], line[idx+1:]
115 } else {
116 head = ""
117 }
118 io.WriteString(v.w, p)
119 if head != "" {
120 io.WriteString(v.w, t.paint(sgrBold, head)+" ")
121 }
122 io.WriteString(v.w, t.paint(stateColor(state), last)+"\n")
123 }
124}
125
126// fields prints key/value pairs aligned on the widest key, skipping
127// empty values. A value that does not fit wraps, its continuation
128// lines indented to the value column.
129func (v *view) fields(kv ...string) {
130 wide := 0
131 for i := 0; i+1 < len(kv); i += 2 {
132 if kv[i+1] != "" {
133 wide = max(wide, cells(kv[i]))
134 }
135 }
136 v.sep()
137 for i := 0; i+1 < len(kv); i += 2 {
138 key, val := v.c.Term.safe(kv[i]), v.c.Term.safe(kv[i+1])
139 if val == "" {
140 continue
141 }
142 prefix := " " + v.c.Term.paint(sgrDim, pad(key, wide)) + " "
143 if v.c.Term.Cols == 0 {
144 io.WriteString(v.w, prefix+val+"\n")
145 continue
146 }
147 indent := strings.Repeat(" ", 2+wide+2)
148 for j, line := range termtext.Wrap(val, v.c.Term.Cols-2-wide-2) {
149 p := indent
150 if j == 0 {
151 p = prefix
152 }
153 io.WriteString(v.w, p+line+"\n")
154 }
155 }
156}
157
158func (v *view) body(src, format string) {
159 if strings.TrimSpace(src) == "" {
160 return
161 }
162 v.sep()
163 src = v.c.Term.safe(src)
164 for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") {
165 if line == "" {
166 io.WriteString(v.w, "\n")
167 continue
168 }
169 io.WriteString(v.w, " "+line+"\n")
170 }
171}
172
173// event is one line for a system comment: its text without link
174// targets, the time at the right edge at a terminal.
175func (v *view) event(text, format, ts string) {
176 line := "· " + termtext.Inline(v.c.Term.safe(text), format)
177 when := v.c.Term.safe(v.c.when(ts))
178 if cols := v.c.Term.Cols; cols > 0 {
179 room := cols - 2 - 2 - cells(when)
180 line = pad(clip(line, room), room)
181 }
182 io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n")
183 v.wrote = true
184}
185
186func (v *view) comment(author, ts, body, format string) {
187 when := v.c.Term.safe(v.c.when(ts))
188 author = v.c.Term.safe(author)
189 cols := v.c.Term.Cols
190 if cols > 0 {
191 suffix := ", " + when + " "
192 author = clip(author, max(0, cols-cells("── "+suffix)-1))
193 }
194 head := "── " + author + ", " + when + " "
195 if cols > 0 {
196 head += strings.Repeat("─", max(1, cols-cells(head)))
197 }
198 v.sep()
199 io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n")
200 v.body(body, format)
201}