Commit b19c86712e
Verified · cmc
Layout: unified · split
internal/control/screen.go +78 −2
| @@ -198,6 +198,82 @@ func (c *Ctx) renderSection(s section) string { | ||
| 198 | 198 | return b.String() |
| 199 | 199 | } |
| 200 | 200 | |
| 201 | func (c *Ctx) renderLegend(as []action) string { return "" } | |
| 201 | // cmdline is argv as the viewer would type it: "gitbay", the words | |
| 202 | // shell-quoted, and the repository the CLI inferred left out. | |
| 203 | func (c *Ctx) cmdline(argv []string) string { | |
| 204 | words := []string{"gitbay"} | |
| 205 | for _, a := range argv { | |
| 206 | if c.Term.Here != "" && a == c.Term.Here { | |
| 207 | continue | |
| 208 | } | |
| 209 | words = append(words, shellWord(a)) | |
| 210 | } | |
| 211 | return strings.Join(words, " ") | |
| 212 | } | |
| 202 | 213 | |
| 203 | func (c *Ctx) cmdline(argv []string) string { return "gitbay " + strings.Join(argv, " ") } | |
| 214 | // renderLegend is a rule, then the action groups in the order they first | |
| 215 | // appear, each a bold name over its commands in blue. Groups sit side by | |
| 216 | // side, three to a band, when the terminal is 80 wide or more and the | |
| 217 | // band fits; otherwise they stack. | |
| 218 | func (c *Ctx) renderLegend(as []action) string { | |
| 219 | if len(as) == 0 { | |
| 220 | return "" | |
| 221 | } | |
| 222 | t := c.Term | |
| 223 | type group struct { | |
| 224 | name string | |
| 225 | cmds []string | |
| 226 | } | |
| 227 | var groups []*group | |
| 228 | byName := map[string]*group{} | |
| 229 | for _, a := range as { | |
| 230 | g := byName[a.group] | |
| 231 | if g == nil { | |
| 232 | g = &group{name: a.group} | |
| 233 | byName[a.group] = g | |
| 234 | groups = append(groups, g) | |
| 235 | } | |
| 236 | g.cmds = append(g.cmds, c.cmdline(a.argv)) | |
| 237 | } | |
| 238 | var b strings.Builder | |
| 239 | b.WriteString(t.paint(sgrDim, strings.Repeat("─", t.Cols)) + "\n") | |
| 240 | for start := 0; start < len(groups); start += 3 { | |
| 241 | band := groups[start:min(start+3, len(groups))] | |
| 242 | widths := make([]int, len(band)) | |
| 243 | total, height := 2*(len(band)-1), 0 | |
| 244 | for i, g := range band { | |
| 245 | widths[i] = cells(g.name) | |
| 246 | for _, cmd := range g.cmds { | |
| 247 | widths[i] = max(widths[i], cells(cmd)) | |
| 248 | } | |
| 249 | total += widths[i] | |
| 250 | height = max(height, len(g.cmds)) | |
| 251 | } | |
| 252 | if t.Cols < 80 || total > t.Cols { | |
| 253 | for _, g := range band { | |
| 254 | b.WriteString(t.paint(sgrBold, g.name) + "\n") | |
| 255 | for _, cmd := range g.cmds { | |
| 256 | b.WriteString(t.paint(sgrBlue, cmd) + "\n") | |
| 257 | } | |
| 258 | } | |
| 259 | continue | |
| 260 | } | |
| 261 | for line := -1; line < height; line++ { | |
| 262 | var l strings.Builder | |
| 263 | for i, g := range band { | |
| 264 | s, sgr := "", sgrBlue | |
| 265 | if line < 0 { | |
| 266 | s, sgr = g.name, sgrBold | |
| 267 | } else if line < len(g.cmds) { | |
| 268 | s = g.cmds[line] | |
| 269 | } | |
| 270 | l.WriteString(t.paint(sgr, s)) | |
| 271 | if i < len(band)-1 { | |
| 272 | l.WriteString(strings.Repeat(" ", widths[i]-cells(s)+2)) | |
| 273 | } | |
| 274 | } | |
| 275 | b.WriteString(strings.TrimRight(l.String(), " ") + "\n") | |
| 276 | } | |
| 277 | } | |
| 278 | return b.String() | |
| 279 | } | |
internal/control/screen_test.go +63
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "fmt" | |
| 4 | 5 | "strings" |
| 5 | 6 | "testing" |
| 6 | 7 | ) |
| @@ -114,3 +115,65 @@ func TestRenderFieldWraps(t *testing.T) { | ||
| 114 | 115 | t.Errorf("continuation not under the value: %q", lines[1]) |
| 115 | 116 | } |
| 116 | 117 | } |
| 118 | ||
| 119 | func TestCmdlineDropsHere(t *testing.T) { | |
| 120 | c := screenCtx(80, false) | |
| 121 | c.Term.Here = "krz/gitbay" | |
| 122 | for _, tc := range []struct { | |
| 123 | argv []string | |
| 124 | want string | |
| 125 | }{ | |
| 126 | {[]string{"mr", "diff", "krz/gitbay", "552"}, "gitbay mr diff 552"}, | |
| 127 | {[]string{"issue", "show", "krz/hutch", "3"}, "gitbay issue show krz/hutch 3"}, | |
| 128 | {[]string{"issue", "list", "--label", "needs review"}, "gitbay issue list --label 'needs review'"}, | |
| 129 | } { | |
| 130 | if got := c.cmdline(tc.argv); got != tc.want { | |
| 131 | t.Errorf("cmdline(%q) = %q, want %q", tc.argv, got, tc.want) | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | func legendSample() []action { | |
| 137 | return []action{ | |
| 138 | {"Unblock", []string{"mr", "rebase", "552"}}, | |
| 139 | {"Review", []string{"mr", "review", "krz/gitbay", "552", "--approve"}}, | |
| 140 | {"Review", []string{"mr", "comment", "krz/gitbay", "552"}}, | |
| 141 | {"Read", []string{"mr", "diff", "krz/gitbay", "552"}}, | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | func TestLegendColumns(t *testing.T) { | |
| 146 | c := screenCtx(120, false) | |
| 147 | c.Term.Here = "krz/gitbay" | |
| 148 | got := c.renderLegend(legendSample()) | |
| 149 | want := strings.Repeat("─", 120) + "\n" + | |
| 150 | fmt.Sprintf("%-22s%-32s%s\n", "Unblock", "Review", "Read") + | |
| 151 | fmt.Sprintf("%-22s%-32s%s\n", "gitbay mr rebase 552", "gitbay mr review 552 --approve", "gitbay mr diff 552") + | |
| 152 | fmt.Sprintf("%-22s%s\n", "", "gitbay mr comment 552") | |
| 153 | if got != want { | |
| 154 | t.Errorf("got:\n%s\nwant:\n%s", got, want) | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | func TestLegendStacksWhenNarrow(t *testing.T) { | |
| 159 | c := screenCtx(60, false) | |
| 160 | c.Term.Here = "krz/gitbay" | |
| 161 | got := c.renderLegend(legendSample()) | |
| 162 | want := strings.Repeat("─", 60) + "\n" + | |
| 163 | "Unblock\ngitbay mr rebase 552\n" + | |
| 164 | "Review\ngitbay mr review 552 --approve\ngitbay mr comment 552\n" + | |
| 165 | "Read\ngitbay mr diff 552\n" | |
| 166 | if got != want { | |
| 167 | t.Errorf("got:\n%s\nwant:\n%s", got, want) | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | func TestLegendColour(t *testing.T) { | |
| 172 | got := screenCtx(120, true).renderLegend(legendSample()) | |
| 173 | if !strings.Contains(got, sgrBlue+"gitbay mr rebase 552"+sgrReset) || !strings.Contains(got, sgrBold+"Unblock"+sgrReset) { | |
| 174 | t.Errorf("legend paint: %q", got) | |
| 175 | } | |
| 176 | if stripSGR(got) != screenCtx(120, false).renderLegend(legendSample()) { | |
| 177 | t.Error("colour legend differs beyond SGR") | |
| 178 | } | |
| 179 | } | |
internal/control/term.go +7
| @@ -22,6 +22,9 @@ type Term struct { | ||
| 22 | 22 | TrueColor bool |
| 23 | 23 | // Links is OSC 8 hyperlinks, for a reference's page. |
| 24 | 24 | Links bool |
| 25 | // Here is the repository the CLI inferred from the clone it runs in, | |
| 26 | // so a suggested command can leave it out. | |
| 27 | Here string | |
| 25 | 28 | } |
| 26 | 29 | |
| 27 | 30 | // ParseTerm reads "<cols>[,<option>]...". Options it does not know are |
| @@ -36,6 +39,10 @@ func ParseTerm(v string) Term { | ||
| 36 | 39 | } |
| 37 | 40 | t := Term{Cols: n} |
| 38 | 41 | for _, opt := range parts[1:] { |
| 42 | if v, ok := strings.CutPrefix(opt, "here="); ok { | |
| 43 | t.Here = v | |
| 44 | continue | |
| 45 | } | |
| 39 | 46 | switch opt { |
| 40 | 47 | case "color": |
| 41 | 48 | t.Color = true |
internal/control/term_test.go +15 −13
| @@ -8,19 +8,21 @@ import ( | ||
| 8 | 8 | |
| 9 | 9 | func TestParseTerm(t *testing.T) { |
| 10 | 10 | cases := map[string]Term{ |
| 11 | "120": {Cols: 120}, | |
| 12 | "120,color": {Cols: 120, Color: true}, | |
| 13 | "40": {Cols: 40}, | |
| 14 | "39": {}, | |
| 15 | "": {}, | |
| 16 | "abc": {}, | |
| 17 | "80,blink": {Cols: 80}, | |
| 18 | "80,": {Cols: 80}, | |
| 19 | "80,truecolor,color": {Cols: 80, Color: true, TrueColor: true}, | |
| 20 | "80,color,links": {Cols: 80, Color: true, Links: true}, | |
| 21 | "80,truecolor": {Cols: 80}, | |
| 22 | "abc,color": {}, | |
| 23 | "5000": {}, | |
| 11 | "120": {Cols: 120}, | |
| 12 | "120,color": {Cols: 120, Color: true}, | |
| 13 | "40": {Cols: 40}, | |
| 14 | "39": {}, | |
| 15 | "": {}, | |
| 16 | "abc": {}, | |
| 17 | "80,blink": {Cols: 80}, | |
| 18 | "80,": {Cols: 80}, | |
| 19 | "80,truecolor,color": {Cols: 80, Color: true, TrueColor: true}, | |
| 20 | "80,color,links": {Cols: 80, Color: true, Links: true}, | |
| 21 | "80,truecolor": {Cols: 80}, | |
| 22 | "abc,color": {}, | |
| 23 | "5000": {}, | |
| 24 | "120,color,here=krz/gitbay": {Cols: 120, Color: true, Here: "krz/gitbay"}, | |
| 25 | "120,here=": {Cols: 120}, | |
| 24 | 26 | } |
| 25 | 27 | for in, want := range cases { |
| 26 | 28 | if got := ParseTerm(in); got != want { |