Commit f830a4d84c

f830a4d84c17528d2a825fa474d35cd60c9a0d44

parent: 1706e2f65b

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 02:48 UTC

view: wrap titles and field values to the terminal

Ref #254
internal/control/view.go +59 −7
@@ -38,13 +38,46 @@ func (v *view) opts() termtext.Options {
3838 return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL}
3939}
4040
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.
4144func (v *view) title(ref, title, state string) {
4245 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 }
4476}
4577
4678// 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.
4881func (v *view) fields(kv ...string) {
4982 wide := 0
5083 for i := 0; i+1 < len(kv); i += 2 {
@@ -54,10 +87,23 @@ func (v *view) fields(kv ...string) {
5487 }
5588 io.WriteString(v.w, "\n")
5689 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")
5897 continue
5998 }
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 }
61107 }
62108}
63109
@@ -88,9 +134,15 @@ func (v *view) event(text, format, ts string) {
88134}
89135
90136func (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)))
94146 }
95147 io.WriteString(v.w, "\n"+v.c.Term.paint(sgrDim, head)+"\n")
96148 v.body(body, format)
internal/control/view_test.go +62
@@ -64,3 +64,65 @@ func TestIssueShowTerminal(t *testing.T) {
6464 }
6565 }
6666}
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.
70func 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
121func 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) {
5959 prefix := first
6060 var open []string
6161 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)) {
6363 w.b.WriteString(prefix)
6464 for _, sgr := range open {
6565 w.b.WriteString(sgr)
@@ -146,9 +146,9 @@ func (w *out) link(text, target string) string {
146146 return text + " (" + target + ")"
147147}
148148
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
150150// wider than width is a line of its own. width <= 0 is no wrapping.
151func wrap(s string, width int) []string {
151func Wrap(s string, width int) []string {
152152 if width <= 0 {
153153 return []string{s}
154154 }