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 | } | ||