Commit 4099abc8b9
Verified · cmc
Layout: unified · split
internal/control/screen.go added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 16 | type 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. | |
| 25 | type 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)"; when | |
| 31 | // it is more than the rows shown, more is the command for the rest. An | |
| 32 | // empty section is left out unless empty says to draw "(0)". | |
| 33 | type section struct { | |
| 34 | title string | |
| 35 | n int | |
| 36 | rows []row | |
| 37 | more []string | |
| 38 | empty bool | |
| 39 | } | |
| 40 | ||
| 41 | // row is a section row, and optionally the markup that follows it (a | |
| 42 | // comment's body). | |
| 43 | type row struct { | |
| 44 | cells []cell | |
| 45 | body string | |
| 46 | format string | |
| 47 | } | |
| 48 | ||
| 49 | func rowOf(cs ...cell) row { return row{cells: cs} } | |
| 50 | ||
| 51 | // action is one command in the legend, under its group's name. argv is | |
| 52 | // the command as typed after "gitbay". | |
| 53 | type action struct { | |
| 54 | group string | |
| 55 | argv []string | |
| 56 | } | |
| 57 | ||
| 58 | // render draws s: the parts in order, one blank line between parts and | |
| 59 | // between sections. | |
| 60 | func (c *Ctx) render(w io.Writer, s screen) { | |
| 61 | var blocks []string | |
| 62 | if b := c.renderFields(s.fields); b != "" { | |
| 63 | blocks = append(blocks, b) | |
| 64 | } | |
| 65 | if b := c.renderBody(s.body, s.format, ""); b != "" { | |
| 66 | blocks = append(blocks, b) | |
| 67 | } | |
| 68 | for _, sec := range s.sections { | |
| 69 | if b := c.renderSection(sec); b != "" { | |
| 70 | blocks = append(blocks, b) | |
| 71 | } | |
| 72 | } | |
| 73 | if b := c.renderLegend(s.actions); b != "" { | |
| 74 | blocks = append(blocks, b) | |
| 75 | } | |
| 76 | io.WriteString(w, strings.Join(blocks, "\n")) | |
| 77 | } | |
| 78 | ||
| 79 | // renderFields aligns labels on the widest, dim, with a colon. Every | |
| 80 | // cell but the last is painted as a table paints it; the last wraps to | |
| 81 | // the width, continuation lines under its first column. | |
| 82 | func (c *Ctx) renderFields(fs []field) string { | |
| 83 | t := c.Term | |
| 84 | wide := 0 | |
| 85 | for _, f := range fs { | |
| 86 | if len(f.value) > 0 { | |
| 87 | wide = max(wide, cells(f.label)+1) | |
| 88 | } | |
| 89 | } | |
| 90 | var b strings.Builder | |
| 91 | for _, f := range fs { | |
| 92 | if len(f.value) == 0 { | |
| 93 | continue | |
| 94 | } | |
| 95 | prefix := t.paint(sgrDim, f.label+":") + strings.Repeat(" ", wide-cells(f.label)-1+2) | |
| 96 | col := wide + 2 | |
| 97 | head := "" | |
| 98 | for _, cl := range f.value[:len(f.value)-1] { | |
| 99 | s := c.cellText(cl) | |
| 100 | if s == "" { | |
| 101 | continue | |
| 102 | } | |
| 103 | head += c.paintCell(cl, s) + " " | |
| 104 | col += cells(s) + 2 | |
| 105 | } | |
| 106 | last := f.value[len(f.value)-1] | |
| 107 | indent := strings.Repeat(" ", col) | |
| 108 | for i, l := range termtext.Wrap(c.cellText(last), max(8, t.Cols-col)) { | |
| 109 | p := indent | |
| 110 | if i == 0 { | |
| 111 | p = prefix + head | |
| 112 | } | |
| 113 | b.WriteString(strings.TrimRight(p+c.paintCell(last, l), " ") + "\n") | |
| 114 | } | |
| 115 | } | |
| 116 | return b.String() | |
| 117 | } | |
| 118 | ||
| 119 | // cellText is a cell's terminal text: made safe, ages relative, sizes | |
| 120 | // humanized, as table.row prepares it. | |
| 121 | func (c *Ctx) cellText(cl cell) string { | |
| 122 | s := termSafe(cl.s) | |
| 123 | switch cl.kind { | |
| 124 | case kindAge: | |
| 125 | return relAge(s, termNow()) | |
| 126 | case kindSize: | |
| 127 | if n, err := strconv.ParseInt(s, 10, 64); err == nil { | |
| 128 | return humanBytes(n) | |
| 129 | } | |
| 130 | } | |
| 131 | return s | |
| 132 | } | |
| 133 | ||
| 134 | // paintCell paints text s as its cell's kind paints at a terminal. | |
| 135 | func (c *Ctx) paintCell(cl cell, s string) string { | |
| 136 | t := c.Term | |
| 137 | switch cl.kind { | |
| 138 | case kindState: | |
| 139 | return t.paintState(s) | |
| 140 | case kindRef: | |
| 141 | return t.link(cl.url, t.paint(sgrDim, s)) | |
| 142 | case kindMeta: | |
| 143 | return t.paint(sgrDim, s) | |
| 144 | case kindSwatch: | |
| 145 | return t.swatch(s) | |
| 146 | } | |
| 147 | return t.paint(cl.sgr, s) | |
| 148 | } | |
| 149 | ||
| 150 | // renderBody renders markup to the width, each line prefixed by indent. | |
| 151 | // Format "text" is wrapped as it is, without markup. | |
| 152 | func (c *Ctx) renderBody(src, format, indent string) string { | |
| 153 | if strings.TrimSpace(src) == "" { | |
| 154 | return "" | |
| 155 | } | |
| 156 | width := max(0, c.Term.Cols-len(indent)) | |
| 157 | var out []string | |
| 158 | if format == "text" { | |
| 159 | for _, para := range strings.Split(strings.TrimRight(termSafe(src), "\n"), "\n") { | |
| 160 | out = append(out, termtext.Wrap(para, width)...) | |
| 161 | } | |
| 162 | } else { | |
| 163 | opts := termtext.Options{Width: width, Color: c.Term.Color, Base: c.Cfg.Server.SiteURL} | |
| 164 | out = strings.Split(strings.TrimRight(termtext.Render(termSafe(src), format, opts), "\n"), "\n") | |
| 165 | } | |
| 166 | var b strings.Builder | |
| 167 | for _, l := range out { | |
| 168 | if l == "" { | |
| 169 | b.WriteString("\n") | |
| 170 | continue | |
| 171 | } | |
| 172 | b.WriteString(indent + l + "\n") | |
| 173 | } | |
| 174 | return b.String() | |
| 175 | } | |
| 176 | ||
| 177 | // renderSection is the heading, the rows laid out as one table with no | |
| 178 | // header, each row's body indented beneath it, and the "+n more" line. | |
| 179 | func (c *Ctx) renderSection(s section) string { | |
| 180 | if len(s.rows) == 0 && !s.empty { | |
| 181 | return "" | |
| 182 | } | |
| 183 | var b strings.Builder | |
| 184 | b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)) + "\n") | |
| 185 | if len(s.rows) > 0 { | |
| 186 | tb := &table{term: c.Term, w: io.Discard} | |
| 187 | for _, r := range s.rows { | |
| 188 | tb.row(append([]cell(nil), r.cells...)...) | |
| 189 | } | |
| 190 | for i, l := range tb.lines() { | |
| 191 | b.WriteString(l + "\n") | |
| 192 | b.WriteString(c.renderBody(s.rows[i].body, s.rows[i].format, " ")) | |
| 193 | } | |
| 194 | } | |
| 195 | if rest := s.n - len(s.rows); rest > 0 && len(s.more) > 0 { | |
| 196 | b.WriteString(c.Term.paint(sgrDim, fmt.Sprintf("+%d more %s", rest, c.cmdline(s.more))) + "\n") | |
| 197 | } | |
| 198 | return b.String() | |
| 199 | } | |
| 200 | ||
| 201 | func (c *Ctx) renderLegend(as []action) string { return "" } | |
| 202 | ||
| 203 | func (c *Ctx) cmdline(argv []string) string { return "gitbay " + strings.Join(argv, " ") } | |
internal/control/screen_test.go added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | func screenCtx(cols int, color bool) *Ctx { | |
| 9 | return &Ctx{Term: Term{Cols: cols, Color: color}} | |
| 10 | } | |
| 11 | ||
| 12 | func renderString(c *Ctx, s screen) string { | |
| 13 | var b strings.Builder | |
| 14 | c.render(&b, s) | |
| 15 | return b.String() | |
| 16 | } | |
| 17 | ||
| 18 | func sampleScreen() screen { | |
| 19 | return screen{ | |
| 20 | fields: []field{ | |
| 21 | {"Merge", []cell{cRef("!552"), cText("wire $PAGER through long views")}}, | |
| 22 | {"State", []cell{cState("open"), cMeta("cli-pager → main", "cmc")}}, | |
| 23 | {"Checks", []cell{cGlyph("running"), cText("1 running")}}, | |
| 24 | }, | |
| 25 | body: "Pages long views.", | |
| 26 | format: "md", | |
| 27 | sections: []section{ | |
| 28 | {title: "Commits", n: 2, rows: []row{ | |
| 29 | rowOf(cRef("8f3a1c2"), cFlex("cli: page long output")), | |
| 30 | rowOf(cRef("2b77e90"), cFlex("control: mark views")), | |
| 31 | }}, | |
| 32 | {title: "Discussion", n: 0, empty: true}, | |
| 33 | {title: "Hidden", n: 0}, | |
| 34 | }, | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | func TestRenderPlainLayout(t *testing.T) { | |
| 39 | got := renderString(screenCtx(80, false), sampleScreen()) | |
| 40 | want := `Merge: !552 wire $PAGER through long views | |
| 41 | State: open cli-pager → main · cmc | |
| 42 | Checks: ◐ 1 running | |
| 43 | ||
| 44 | Pages long views. | |
| 45 | ||
| 46 | Commits (2) | |
| 47 | 8f3a1c2 cli: page long output | |
| 48 | 2b77e90 control: mark views | |
| 49 | ||
| 50 | Discussion (0) | |
| 51 | ` | |
| 52 | if got != want { | |
| 53 | t.Errorf("got:\n%s\nwant:\n%s", got, want) | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | func TestRenderColourIsOnlyPaint(t *testing.T) { | |
| 58 | for _, cols := range []int{80, 120} { | |
| 59 | plain := renderString(screenCtx(cols, false), sampleScreen()) | |
| 60 | colour := renderString(screenCtx(cols, true), sampleScreen()) | |
| 61 | if stripSGR(colour) != plain { | |
| 62 | t.Errorf("cols %d: colour render differs beyond SGR:\n%s\n---\n%s", cols, stripSGR(colour), plain) | |
| 63 | } | |
| 64 | if !strings.Contains(colour, sgrBold+sgrBlue+"Commits (2)"+sgrReset) { | |
| 65 | t.Errorf("cols %d: heading not bold blue: %q", cols, colour) | |
| 66 | } | |
| 67 | if !strings.Contains(colour, sgrDim+"Merge:"+sgrReset) { | |
| 68 | t.Errorf("cols %d: label not dim: %q", cols, colour) | |
| 69 | } | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | func TestRenderMoreLine(t *testing.T) { | |
| 74 | s := screen{sections: []section{{title: "Builds", n: 14, more: []string{"build", "list", "krz/gitbay"}, | |
| 75 | rows: []row{rowOf(cRef("1779"), cFlex("test"))}}}} | |
| 76 | got := renderString(screenCtx(80, false), s) | |
| 77 | if !strings.HasSuffix(got, "1779 test\n+13 more gitbay build list krz/gitbay\n") { | |
| 78 | t.Errorf("got %q", got) | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | func TestRenderRowBody(t *testing.T) { | |
| 83 | s := screen{sections: []section{{title: "Discussion", n: 1, rows: []row{ | |
| 84 | {cells: []cell{cText("cmc"), cMeta("2h")}, body: "Looks good.", format: "md"}, | |
| 85 | }}}} | |
| 86 | got := renderString(screenCtx(80, false), s) | |
| 87 | want := "Discussion (1)\ncmc 2h\n Looks good.\n" | |
| 88 | if got != want { | |
| 89 | t.Errorf("got %q, want %q", got, want) | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | func TestRenderTextBodyIsNotMarkup(t *testing.T) { | |
| 94 | s := screen{body: "a *b* c", format: "text"} | |
| 95 | if got := renderString(screenCtx(80, false), s); got != "a *b* c\n" { | |
| 96 | t.Errorf("got %q", got) | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | func TestRenderFieldWraps(t *testing.T) { | |
| 101 | long := strings.Repeat("word ", 20) | |
| 102 | s := screen{fields: []field{{"Merge", []cell{cRef("!1"), cText(strings.TrimSpace(long))}}}} | |
| 103 | out := renderString(screenCtx(40, false), s) | |
| 104 | lines := strings.Split(strings.TrimRight(out, "\n"), "\n") | |
| 105 | if len(lines) < 2 { | |
| 106 | t.Fatalf("did not wrap: %q", out) | |
| 107 | } | |
| 108 | for _, l := range lines { | |
| 109 | if cells(l) > 40 { | |
| 110 | t.Errorf("line wider than 40: %q", l) | |
| 111 | } | |
| 112 | } | |
| 113 | if !strings.HasPrefix(lines[1], strings.Repeat(" ", len("Merge: !1 "))) { | |
| 114 | t.Errorf("continuation not under the value: %q", lines[1]) | |
| 115 | } | |
| 116 | } | |