Commit f830a4d84c
Verified · cmc
internal/control/view.go +59 −7
| @@ -38,13 +38,46 @@ func (v *view) opts() termtext.Options { | ||
| 38 | 38 | return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL} |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | // title prints "ref title state", wrapping title+state to the | |
| 42 | // terminal width. Continuation lines indent under the title, and each | |
| 43 | // line is painted after wrapping so no SGR sequence crosses a break. | |
| 41 | 44 | func (v *view) title(ref, title, state string) { |
| 42 | 45 | t := v.c.Term |
| 43 | io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n") | |
| 46 | if t.Cols == 0 { | |
| 47 | io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n") | |
| 48 | return | |
| 49 | } | |
| 50 | prefix := ref + " " | |
| 51 | indent := strings.Repeat(" ", cells(prefix)) | |
| 52 | lines := termtext.Wrap(title+" "+state, t.Cols-cells(prefix)) | |
| 53 | for i, line := range lines { | |
| 54 | p := indent | |
| 55 | if i == 0 { | |
| 56 | p = prefix | |
| 57 | } | |
| 58 | if i < len(lines)-1 { | |
| 59 | io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n") | |
| 60 | continue | |
| 61 | } | |
| 62 | // Last line carries the state word, the last word overall | |
| 63 | // (states are single words): keep it coloured, not bold. | |
| 64 | rest, last := line, line | |
| 65 | if idx := strings.LastIndex(line, " "); idx >= 0 { | |
| 66 | rest, last = line[:idx], line[idx+1:] | |
| 67 | } else { | |
| 68 | rest = "" | |
| 69 | } | |
| 70 | io.WriteString(v.w, p) | |
| 71 | if rest != "" { | |
| 72 | io.WriteString(v.w, t.paint(sgrBold, rest)+" ") | |
| 73 | } | |
| 74 | io.WriteString(v.w, t.paint(stateColor(state), last)+"\n") | |
| 75 | } | |
| 44 | 76 | } |
| 45 | 77 | |
| 46 | 78 | // fields prints key/value pairs aligned on the widest key, skipping |
| 47 | // empty values. | |
| 79 | // empty values. A value that does not fit wraps, its continuation | |
| 80 | // lines indented to the value column. | |
| 48 | 81 | func (v *view) fields(kv ...string) { |
| 49 | 82 | wide := 0 |
| 50 | 83 | for i := 0; i+1 < len(kv); i += 2 { |
| @@ -54,10 +87,23 @@ func (v *view) fields(kv ...string) { | ||
| 54 | 87 | } |
| 55 | 88 | io.WriteString(v.w, "\n") |
| 56 | 89 | for i := 0; i+1 < len(kv); i += 2 { |
| 57 | if kv[i+1] == "" { | |
| 90 | key, val := kv[i], kv[i+1] | |
| 91 | if val == "" { | |
| 92 | continue | |
| 93 | } | |
| 94 | prefix := " " + v.c.Term.paint(sgrDim, pad(key, wide)) + " " | |
| 95 | if v.c.Term.Cols == 0 { | |
| 96 | io.WriteString(v.w, prefix+val+"\n") | |
| 58 | 97 | continue |
| 59 | 98 | } |
| 60 | io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, pad(kv[i], wide))+" "+kv[i+1]+"\n") | |
| 99 | indent := strings.Repeat(" ", 2+wide+2) | |
| 100 | for j, line := range termtext.Wrap(val, v.c.Term.Cols-2-wide-2) { | |
| 101 | p := indent | |
| 102 | if j == 0 { | |
| 103 | p = prefix | |
| 104 | } | |
| 105 | io.WriteString(v.w, p+line+"\n") | |
| 106 | } | |
| 61 | 107 | } |
| 62 | 108 | } |
| 63 | 109 | |
| @@ -88,9 +134,15 @@ func (v *view) event(text, format, ts string) { | ||
| 88 | 134 | } |
| 89 | 135 | |
| 90 | 136 | func (v *view) comment(author, ts, body, format string) { |
| 91 | head := "── " + author + ", " + v.c.when(ts) + " " | |
| 92 | if cols := v.c.Term.Cols; cols > 0 { | |
| 93 | head += strings.Repeat("─", max(0, cols-cells(head))) | |
| 137 | when := v.c.when(ts) | |
| 138 | cols := v.c.Term.Cols | |
| 139 | if cols > 0 { | |
| 140 | suffix := ", " + when + " " | |
| 141 | author = clip(author, max(0, cols-cells("── "+suffix)-1)) | |
| 142 | } | |
| 143 | head := "── " + author + ", " + when + " " | |
| 144 | if cols > 0 { | |
| 145 | head += strings.Repeat("─", max(1, cols-cells(head))) | |
| 94 | 146 | } |
| 95 | 147 | io.WriteString(v.w, "\n"+v.c.Term.paint(sgrDim, head)+"\n") |
| 96 | 148 | v.body(body, format) |
internal/control/view_test.go +62
| @@ -64,3 +64,65 @@ func TestIssueShowTerminal(t *testing.T) { | ||
| 64 | 64 | } |
| 65 | 65 | } |
| 66 | 66 | } |
| 67 | ||
| 68 | // TestIssueShowNarrowWraps: a long title and long labels must not push | |
| 69 | // any line past the terminal width, and no content is lost to it. | |
| 70 | func TestIssueShowNarrowWraps(t *testing.T) { | |
| 71 | st, repo, uid := newQueueTestRepo(t) | |
| 72 | title := strings.TrimSpace(strings.Repeat("a very long issue title that keeps going on and on ", 3))[:90] | |
| 73 | n, err := st.CreateIssue(repo.ID, uid, title, "body", "md") | |
| 74 | if err != nil { | |
| 75 | t.Fatal(err) | |
| 76 | } | |
| 77 | for _, name := range []string{"needs-review-before-merge", "blocked-on-release", "requires-design-signoff"} { | |
| 78 | if err := st.SetLabel(repo, name, "888888"); err != nil { | |
| 79 | t.Fatal(err) | |
| 80 | } | |
| 81 | if err := st.SetIssueLabel(repo, issueIDFor(t, st, repo.ID, n), name, true); err != nil { | |
| 82 | t.Fatal(err) | |
| 83 | } | |
| 84 | } | |
| 85 | // No SiteURL set: the url field stays a short path so it does not | |
| 86 | // itself force an unbreakable line over width, which is not what | |
| 87 | // this test is about (title/labels/author wrapping is). | |
| 88 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"}) | |
| 89 | c.Term = Term{Cols: 40, Color: true} | |
| 90 | if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK { | |
| 91 | t.Fatalf("exit %d: %s", code, errOut) | |
| 92 | } | |
| 93 | out := c.Stdout.(*bytes.Buffer).String() | |
| 94 | ||
| 95 | var titleLines []string | |
| 96 | inTitle := false | |
| 97 | for _, line := range strings.Split(stripSGR(out), "\n") { | |
| 98 | if cells(line) > 40 { | |
| 99 | t.Errorf("line over 40 cells: %q", line) | |
| 100 | } | |
| 101 | switch { | |
| 102 | case strings.HasPrefix(line, "#1 "): | |
| 103 | inTitle = true | |
| 104 | titleLines = append(titleLines, strings.TrimPrefix(line, "#1 ")) | |
| 105 | case inTitle && strings.HasPrefix(line, " "): | |
| 106 | titleLines = append(titleLines, strings.TrimSpace(line)) | |
| 107 | default: | |
| 108 | inTitle = false | |
| 109 | } | |
| 110 | } | |
| 111 | got := strings.Join(strings.Fields(strings.Join(titleLines, " ")), " ") | |
| 112 | want := strings.Join(strings.Fields(title+" open"), " ") | |
| 113 | if got != want { | |
| 114 | t.Errorf("title text lost across wrap: got %q, want %q", got, want) | |
| 115 | } | |
| 116 | if n := strings.Count(stripSGR(out), "open"); n != 1 { | |
| 117 | t.Errorf("state word appears %d times, want 1:\n%s", n, out) | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | func issueIDFor(t *testing.T, st *store.Store, repoID, n int64) int64 { | |
| 122 | t.Helper() | |
| 123 | issue, err := st.IssueByNumber(repoID, n) | |
| 124 | if err != nil { | |
| 125 | t.Fatal(err) | |
| 126 | } | |
| 127 | return issue.ID | |
| 128 | } | |
internal/termtext/termtext.go +3 −3
| @@ -59,7 +59,7 @@ func (w *out) para(s, first, rest string) { | ||
| 59 | 59 | prefix := first |
| 60 | 60 | var open []string |
| 61 | 61 | for _, hard := range strings.Split(s, "\n") { |
| 62 | for _, line := range wrap(hard, w.o.Width-cells(rest)) { | |
| 62 | for _, line := range Wrap(hard, w.o.Width-cells(rest)) { | |
| 63 | 63 | w.b.WriteString(prefix) |
| 64 | 64 | for _, sgr := range open { |
| 65 | 65 | w.b.WriteString(sgr) |
| @@ -146,9 +146,9 @@ func (w *out) link(text, target string) string { | ||
| 146 | 146 | return text + " (" + target + ")" |
| 147 | 147 | } |
| 148 | 148 | |
| 149 | // wrap breaks s at spaces into lines of at most width cells. A word | |
| 149 | // Wrap breaks s at spaces into lines of at most width cells. A word | |
| 150 | 150 | // wider than width is a line of its own. width <= 0 is no wrapping. |
| 151 | func wrap(s string, width int) []string { | |
| 151 | func Wrap(s string, width int) []string { | |
| 152 | 152 | if width <= 0 { |
| 153 | 153 | return []string{s} |
| 154 | 154 | } |