detached 82b7fa1

feat(daemon): compose notice toasts into rendered frames

Add ui.ComposeNotices, a server-side compositor that stacks up to three
notice toasts top-right (newest first, box width min(60, cols*2/5) floored
at 24, message word-wrapped to 3 lines, severity-colored border, "+N more"
overflow line), and wire it into the existing captured-overlay render
pipeline: overlayRenderSnapshot/capturedOverlayRenderState now carry a
value-copied notices slice and overflow count taken under the innermost
noticeMu and released before composition, and the composeFrame overlay gate
now also fires on pending notices so toasts render without any modal
overlay active.
commit
82b7fa18e108f40d6b7b1f6d0b47ebd342e9f80d
author
brice · · 2026-07-20 12:53 UTC
parent
52bea82
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +481 -2
A internal/usecase/daemon/notice_render_test.go +108 -0
@@ -0,0 +1,108 @@
1+package daemon
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+// replayOutputFrames decodes and writes each MsgOutput frame's terminal bytes
16+// into screen, growing its shadow the same way a real client would.
17+func replayOutputFrames(t *testing.T, screen *vt.Screen, frames []ports.Frame) {
18+ t.Helper()
19+ for _, f := range frames {
20+ require.Equal(t, ports.MsgOutput, f.Type)
21+ out, err := ports.UnmarshalOutput(f.Payload)
22+ require.NoError(t, err)
23+ screen.Write(out.Data)
24+ }
25+}
26+
27+// drainAllFrames collects every frame already queued on sends without blocking.
28+func drainAllFrames(sends chan ports.Frame) []ports.Frame {
29+ var out []ports.Frame
30+ for {
31+ select {
32+ case f := <-sends:
33+ out = append(out, f)
34+ default:
35+ return out
36+ }
37+ }
38+}
39+
40+// runeColumn returns the rune-indexed column at which text first occurs in
41+// row, or -1. Rows are exactly one rune per terminal column, so this must not
42+// use strings.Index: box-drawing borders are multi-byte and would inflate a
43+// byte offset past the real column.
44+func runeColumn(row, text string) int {
45+ runes, sub := []rune(row), []rune(text)
46+ for i := 0; i+len(sub) <= len(runes); i++ {
47+ match := true
48+ for j, r := range sub {
49+ if runes[i+j] != r {
50+ match = false
51+ break
52+ }
53+ }
54+ if match {
55+ return i
56+ }
57+ }
58+ return -1
59+}
60+
61+// containsTopRight reports whether text appears anywhere at or past minCol in
62+// the frame's top half.
63+func containsTopRight(rows []string, text string, minCol int) bool {
64+ for y, row := range rows {
65+ if y > len(rows)/2 {
66+ break
67+ }
68+ if runeColumn(row, text) >= minCol {
69+ return true
70+ }
71+ }
72+ return false
73+}
74+
75+// TestPaintComposesNoticeToastTopRightAndExpiresOnTTL drives an actual
76+// notify -> showToast -> paint -> emit round trip and decodes the resulting
77+// wire bytes through a terminal emulator, the same way a real client would,
78+// so this proves the composed frame (not just internal toast state) carries
79+// the message top-right, and that it disappears once the fake clock crosses
80+// the toast's TTL.
81+func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
82+ clk := newNoticeClock()
83+ d, sess, ac, sends := newNoticeFixture(t, clk)
84+ screen := vt.NewScreen(ac.size.Cols, ac.size.Rows)
85+
86+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "couldn't open pane", nil)
87+ awaitToastCount(t, ac, 1)
88+ replayOutputFrames(t, screen, drainAllFrames(sends))
89+
90+ rows := frameRows(screen.Frame)
91+ if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
92+ t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))
93+ }
94+
95+ clk.advance(8 * time.Second) // error-severity TTL, per noticeTTL
96+ awaitToastCount(t, ac, 0)
97+ select {
98+ case f := <-sends:
99+ replayOutputFrames(t, screen, append([]ports.Frame{f}, drainAllFrames(sends)...))
100+ case <-time.After(2 * time.Second):
101+ t.Fatal("expiry did not invalidate the render")
102+ }
103+
104+ rows = frameRows(screen.Frame)
105+ if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
106+ t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))
107+ }
108+}
@@ -0,0 +1,108 @@
1+package daemon
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+// replayOutputFrames decodes and writes each MsgOutput frame's terminal bytes
16+// into screen, growing its shadow the same way a real client would.
17+func replayOutputFrames(t *testing.T, screen *vt.Screen, frames []ports.Frame) {
18+ t.Helper()
19+ for _, f := range frames {
20+ require.Equal(t, ports.MsgOutput, f.Type)
21+ out, err := ports.UnmarshalOutput(f.Payload)
22+ require.NoError(t, err)
23+ screen.Write(out.Data)
24+ }
25+}
26+
27+// drainAllFrames collects every frame already queued on sends without blocking.
28+func drainAllFrames(sends chan ports.Frame) []ports.Frame {
29+ var out []ports.Frame
30+ for {
31+ select {
32+ case f := <-sends:
33+ out = append(out, f)
34+ default:
35+ return out
36+ }
37+ }
38+}
39+
40+// runeColumn returns the rune-indexed column at which text first occurs in
41+// row, or -1. Rows are exactly one rune per terminal column, so this must not
42+// use strings.Index: box-drawing borders are multi-byte and would inflate a
43+// byte offset past the real column.
44+func runeColumn(row, text string) int {
45+ runes, sub := []rune(row), []rune(text)
46+ for i := 0; i+len(sub) <= len(runes); i++ {
47+ match := true
48+ for j, r := range sub {
49+ if runes[i+j] != r {
50+ match = false
51+ break
52+ }
53+ }
54+ if match {
55+ return i
56+ }
57+ }
58+ return -1
59+}
60+
61+// containsTopRight reports whether text appears anywhere at or past minCol in
62+// the frame's top half.
63+func containsTopRight(rows []string, text string, minCol int) bool {
64+ for y, row := range rows {
65+ if y > len(rows)/2 {
66+ break
67+ }
68+ if runeColumn(row, text) >= minCol {
69+ return true
70+ }
71+ }
72+ return false
73+}
74+
75+// TestPaintComposesNoticeToastTopRightAndExpiresOnTTL drives an actual
76+// notify -> showToast -> paint -> emit round trip and decodes the resulting
77+// wire bytes through a terminal emulator, the same way a real client would,
78+// so this proves the composed frame (not just internal toast state) carries
79+// the message top-right, and that it disappears once the fake clock crosses
80+// the toast's TTL.
81+func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
82+ clk := newNoticeClock()
83+ d, sess, ac, sends := newNoticeFixture(t, clk)
84+ screen := vt.NewScreen(ac.size.Cols, ac.size.Rows)
85+
86+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "couldn't open pane", nil)
87+ awaitToastCount(t, ac, 1)
88+ replayOutputFrames(t, screen, drainAllFrames(sends))
89+
90+ rows := frameRows(screen.Frame)
91+ if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
92+ t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))
93+ }
94+
95+ clk.advance(8 * time.Second) // error-severity TTL, per noticeTTL
96+ awaitToastCount(t, ac, 0)
97+ select {
98+ case f := <-sends:
99+ replayOutputFrames(t, screen, append([]ports.Frame{f}, drainAllFrames(sends)...))
100+ case <-time.After(2 * time.Second):
101+ t.Fatal("expiry did not invalidate the render")
102+ }
103+
104+ rows = frameRows(screen.Frame)
105+ if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
106+ t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))
107+ }
108+}
M internal/usecase/daemon/overlay_runtime.go +16 -0
@@ -4,6 +4,7 @@ import (
44 "sync"
55 "time"
66
7+ "github.com/bnema/vev/internal/domain"
78 scopy "github.com/bnema/vev/internal/usecase/copy"
89 "github.com/bnema/vev/internal/usecase/palette"
910 "github.com/bnema/vev/internal/usecase/picker"
@@ -243,6 +244,9 @@ type overlayRenderSnapshot struct {
243244 promptActive bool
244245 promptModel *promptui.Model
245246 promptLocked bool
247+
248+ notices []domain.Notification
249+ noticeOverflow int
246250 }
247251
248252 // SnapshotForRender captures the overlay state needed by paint.
@@ -257,6 +261,18 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
257261 return snap
258262 }
259263
264+ // noticeMu is innermost and never held across render, so it is taken and
265+ // released here rather than tracked like paletteMu/promptMu below.
266+ rt.noticeMu.Lock()
267+ if len(rt.noticeToasts) > 0 {
268+ snap.notices = make([]domain.Notification, len(rt.noticeToasts))
269+ for i, t := range rt.noticeToasts {
270+ snap.notices[i] = t.n
271+ }
272+ }
273+ snap.noticeOverflow = rt.noticeOverflow
274+ rt.noticeMu.Unlock()
275+
260276 rt.copyMu.Lock()
261277 snap.copyActive = rt.copyMode != nil
262278 snap.copyPane = rt.copyPane
@@ -4,6 +4,7 @@ import (
4 "sync"4 "sync"
5 "time"5 "time"
6 6
7+ "github.com/bnema/vev/internal/domain"
7 scopy "github.com/bnema/vev/internal/usecase/copy"8 scopy "github.com/bnema/vev/internal/usecase/copy"
8 "github.com/bnema/vev/internal/usecase/palette"9 "github.com/bnema/vev/internal/usecase/palette"
9 "github.com/bnema/vev/internal/usecase/picker"10 "github.com/bnema/vev/internal/usecase/picker"
@@ -243,6 +244,9 @@ type overlayRenderSnapshot struct {
243 promptActive bool244 promptActive bool
244 promptModel *promptui.Model245 promptModel *promptui.Model
245 promptLocked bool246 promptLocked bool
247+
248+ notices []domain.Notification
249+ noticeOverflow int
246 }250 }
247 251
248 // SnapshotForRender captures the overlay state needed by paint.252 // SnapshotForRender captures the overlay state needed by paint.
@@ -257,6 +261,18 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
257 return snap261 return snap
258 }262 }
259 263
264+ // noticeMu is innermost and never held across render, so it is taken and
265+ // released here rather than tracked like paletteMu/promptMu below.
266+ rt.noticeMu.Lock()
267+ if len(rt.noticeToasts) > 0 {
268+ snap.notices = make([]domain.Notification, len(rt.noticeToasts))
269+ for i, t := range rt.noticeToasts {
270+ snap.notices[i] = t.n
271+ }
272+ }
273+ snap.noticeOverflow = rt.noticeOverflow
274+ rt.noticeMu.Unlock()
275+
260 rt.copyMu.Lock()276 rt.copyMu.Lock()
261 snap.copyActive = rt.copyMode != nil277 snap.copyActive = rt.copyMode != nil
262 snap.copyPane = rt.copyPane278 snap.copyPane = rt.copyPane
M internal/usecase/daemon/render_capture.go +2 -0
@@ -91,6 +91,8 @@ type capturedOverlayRenderState struct {
9191 copyFeedback string
9292 paletteGuidance string
9393 copySearch, picker, palette, prompt capturedModal
94+ notices []domain.Notification
95+ noticeOverflow int
9496 }
9597
9698 type capturedModal struct {
@@ -91,6 +91,8 @@ type capturedOverlayRenderState struct {
91 copyFeedback string91 copyFeedback string
92 paletteGuidance string92 paletteGuidance string
93 copySearch, picker, palette, prompt capturedModal93 copySearch, picker, palette, prompt capturedModal
94+ notices []domain.Notification
95+ noticeOverflow int
94 }96 }
95 97
96 type capturedModal struct {98 type capturedModal struct {
M internal/usecase/daemon/render_pipeline.go +25 -2
@@ -151,7 +151,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
151151 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
152152 }
153153 }
154- if state.overlays.active() {
154+ // Toasts render even with no modal overlay active, so this gate also
155+ // checks for pending notices rather than only state.overlays.active().
156+ overlaysOrToasts := state.overlays.active() || len(state.overlays.notices) > 0
157+ if overlaysOrToasts {
155158 // Without a floating frame, overlay composition would otherwise mutate
156159 // the base cache in place. Floating composition already owns a clone.
157160 if !state.floating.visible {
@@ -159,7 +162,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
159162 }
160163 frame, damage = composeCapturedOverlays(state, frame, damage, content)
161164 }
162- if full || state.overlays.active() {
165+ if full || overlaysOrToasts {
163166 damage = []renderer.Damage{renderer.FullRedraw()}
164167 }
165168 cursorInputs := state.cursor
@@ -199,6 +202,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
199202 o := &state.overlays
200203 o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
201204 o.copyMode = snap.copyMode
205+ o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
202206 if snap.copyPane != nil {
203207 o.copyPaneID = snap.copyPane.id
204208 }
@@ -276,9 +280,28 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
276280 }
277281 damage = []renderer.Damage{renderer.FullRedraw()}
278282 }
283+ if len(o.notices) > 0 || o.noticeOverflow > 0 {
284+ views := make([]ui.NoticeView, len(o.notices))
285+ for i, n := range o.notices {
286+ views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count}
287+ }
288+ ui.ComposeNotices(frame, views, o.noticeOverflow, noticeStylesFrom(state.styles))
289+ }
279290 return frame, damage
280291 }
281292
293+// noticeStylesFrom picks the toast box color per severity from the theme's
294+// existing chrome roles. A first pass: dedicated notice styling is out of
295+// scope here.
296+func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
297+ return ui.NoticeStyles{
298+ Text: styles.PickerBase,
299+ BoxError: styles.BorderActive,
300+ BoxWarn: styles.BorderMuted,
301+ BoxInfo: styles.BorderMuted,
302+ }
303+}
304+
282305 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
283306 changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
284307 if !changed {
@@ -151,7 +151,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
151 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})151 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
152 }152 }
153 }153 }
154- if state.overlays.active() {154+ // Toasts render even with no modal overlay active, so this gate also
155+ // checks for pending notices rather than only state.overlays.active().
156+ overlaysOrToasts := state.overlays.active() || len(state.overlays.notices) > 0
157+ if overlaysOrToasts {
155 // Without a floating frame, overlay composition would otherwise mutate158 // Without a floating frame, overlay composition would otherwise mutate
156 // the base cache in place. Floating composition already owns a clone.159 // the base cache in place. Floating composition already owns a clone.
157 if !state.floating.visible {160 if !state.floating.visible {
@@ -159,7 +162,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
159 }162 }
160 frame, damage = composeCapturedOverlays(state, frame, damage, content)163 frame, damage = composeCapturedOverlays(state, frame, damage, content)
161 }164 }
162- if full || state.overlays.active() {165+ if full || overlaysOrToasts {
163 damage = []renderer.Damage{renderer.FullRedraw()}166 damage = []renderer.Damage{renderer.FullRedraw()}
164 }167 }
165 cursorInputs := state.cursor168 cursorInputs := state.cursor
@@ -199,6 +202,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
199 o := &state.overlays202 o := &state.overlays
200 o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive203 o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
201 o.copyMode = snap.copyMode204 o.copyMode = snap.copyMode
205+ o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
202 if snap.copyPane != nil {206 if snap.copyPane != nil {
203 o.copyPaneID = snap.copyPane.id207 o.copyPaneID = snap.copyPane.id
204 }208 }
@@ -276,9 +280,28 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
276 }280 }
277 damage = []renderer.Damage{renderer.FullRedraw()}281 damage = []renderer.Damage{renderer.FullRedraw()}
278 }282 }
283+ if len(o.notices) > 0 || o.noticeOverflow > 0 {
284+ views := make([]ui.NoticeView, len(o.notices))
285+ for i, n := range o.notices {
286+ views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count}
287+ }
288+ ui.ComposeNotices(frame, views, o.noticeOverflow, noticeStylesFrom(state.styles))
289+ }
279 return frame, damage290 return frame, damage
280 }291 }
281 292
293+// noticeStylesFrom picks the toast box color per severity from the theme's
294+// existing chrome roles. A first pass: dedicated notice styling is out of
295+// scope here.
296+func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
297+ return ui.NoticeStyles{
298+ Text: styles.PickerBase,
299+ BoxError: styles.BorderActive,
300+ BoxWarn: styles.BorderMuted,
301+ BoxInfo: styles.BorderMuted,
302+ }
303+}
304+
282 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {305 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
283 changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle306 changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
284 if !changed {307 if !changed {
A internal/usecase/ui/notice.go +157 -0
@@ -0,0 +1,157 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const (
12+ noticeMargin = 1
13+ noticeMaxWidth = 60
14+ noticeMinWidth = 24
15+ noticeMaxLines = 3
16+)
17+
18+// NoticeView is a plain rendering view of one notification. Severity mirrors
19+// domain.NoticeSeverity as a bare uint8 so this package never needs to depend
20+// on internal/usecase/daemon for its input type.
21+type NoticeView struct {
22+ Severity uint8
23+ Title string
24+ Message string
25+ Count int
26+}
27+
28+// NoticeStyles selects the box border style by severity; Text styles both the
29+// title (drawn on the border) and the wrapped message body.
30+type NoticeStyles struct {
31+ Text renderer.Style
32+ BoxInfo renderer.Style
33+ BoxWarn renderer.Style
34+ BoxError renderer.Style
35+}
36+
37+// ComposeNotices stacks notice boxes top-right of frame, newest first. Each
38+// box is min(60, frame.Width*2/5) wide (floored at 24), titled with the
39+// notice's slug and an optional " ×N" count suffix, and its message is
40+// word-wrapped to at most 3 lines. Boxes stack downward with one blank row
41+// between them; once the count behind overflow is nonzero, a right-aligned
42+// "+N more" line follows the last box.
43+func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) {
44+ if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {
45+ return
46+ }
47+
48+ width := frame.Width * 2 / 5
49+ if width > noticeMaxWidth {
50+ width = noticeMaxWidth
51+ }
52+ if width < noticeMinWidth {
53+ width = noticeMinWidth
54+ }
55+ if width > frame.Width {
56+ width = frame.Width
57+ }
58+ if width <= 0 {
59+ return
60+ }
61+ x := frame.Width - noticeMargin - width
62+ y := noticeMargin
63+
64+ for _, n := range notices {
65+ if y >= frame.Height {
66+ break
67+ }
68+ innerWidth := width - 2
69+ lines := wrapNoticeText(n.Message, innerWidth, noticeMaxLines)
70+ if len(lines) == 0 {
71+ lines = []string{""}
72+ }
73+ height := len(lines) + 2
74+ bounds := domain.Rect{X: x, Y: y, Width: width, Height: height}
75+ box := noticeBoxStyle(styles, n.Severity)
76+ DrawBox(frame, bounds, box)
77+
78+ title := n.Title
79+ if n.Count > 1 {
80+ title = fmt.Sprintf("%s ×%d", n.Title, n.Count)
81+ }
82+ if title != "" && bounds.Width > 2 {
83+ left := bounds.X + 1
84+ right := bounds.X + bounds.Width - 1
85+ start := max(left, bounds.X+(bounds.Width-textWidth(title))/2)
86+ DrawText(frame, start, bounds.Y, right, title, box)
87+ }
88+
89+ inner := domain.Rect{X: bounds.X + 1, Y: bounds.Y + 1, Width: max(0, bounds.Width-2), Height: max(0, bounds.Height-2)}
90+ if inner.Width > 0 && inner.Height > 0 {
91+ fill := renderer.BlankCell()
92+ fill.Style = styles.Text
93+ FillRect(frame, inner, fill)
94+ for i, line := range lines {
95+ if i >= inner.Height {
96+ break
97+ }
98+ DrawText(frame, inner.X, inner.Y+i, inner.X+inner.Width, line, styles.Text)
99+ }
100+ }
101+
102+ y += height + 1
103+ }
104+
105+ if overflow > 0 && y < frame.Height {
106+ text := fmt.Sprintf("+%d more", overflow)
107+ start := x + width - textWidth(text)
108+ if start < x {
109+ start = x
110+ }
111+ DrawText(frame, start, y, x+width, text, styles.Text)
112+ }
113+}
114+
115+func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style {
116+ switch domain.NoticeSeverity(severity) {
117+ case domain.NoticeWarn:
118+ return styles.BoxWarn
119+ case domain.NoticeError:
120+ return styles.BoxError
121+ default:
122+ return styles.BoxInfo
123+ }
124+}
125+
126+// wrapNoticeText greedily fills lines up to width, then caps at maxLines by
127+// collapsing any remainder into the last line and truncating it, so an
128+// overlong message is clipped visibly rather than silently dropped mid-word.
129+func wrapNoticeText(message string, width, maxLines int) []string {
130+ if width <= 0 || maxLines <= 0 {
131+ return nil
132+ }
133+ words := strings.Fields(message)
134+ if len(words) == 0 {
135+ return nil
136+ }
137+
138+ lines := make([]string, 0, maxLines)
139+ current := words[0]
140+ for _, w := range words[1:] {
141+ candidate := current + " " + w
142+ if textWidth(candidate) <= width {
143+ current = candidate
144+ continue
145+ }
146+ lines = append(lines, current)
147+ current = w
148+ }
149+ lines = append(lines, current)
150+
151+ if len(lines) <= maxLines {
152+ return lines
153+ }
154+ rest := strings.Join(lines[maxLines-1:], " ")
155+ lines = lines[:maxLines-1]
156+ return append(lines, TruncateText(rest, width))
157+}
@@ -0,0 +1,157 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const (
12+ noticeMargin = 1
13+ noticeMaxWidth = 60
14+ noticeMinWidth = 24
15+ noticeMaxLines = 3
16+)
17+
18+// NoticeView is a plain rendering view of one notification. Severity mirrors
19+// domain.NoticeSeverity as a bare uint8 so this package never needs to depend
20+// on internal/usecase/daemon for its input type.
21+type NoticeView struct {
22+ Severity uint8
23+ Title string
24+ Message string
25+ Count int
26+}
27+
28+// NoticeStyles selects the box border style by severity; Text styles both the
29+// title (drawn on the border) and the wrapped message body.
30+type NoticeStyles struct {
31+ Text renderer.Style
32+ BoxInfo renderer.Style
33+ BoxWarn renderer.Style
34+ BoxError renderer.Style
35+}
36+
37+// ComposeNotices stacks notice boxes top-right of frame, newest first. Each
38+// box is min(60, frame.Width*2/5) wide (floored at 24), titled with the
39+// notice's slug and an optional " ×N" count suffix, and its message is
40+// word-wrapped to at most 3 lines. Boxes stack downward with one blank row
41+// between them; once the count behind overflow is nonzero, a right-aligned
42+// "+N more" line follows the last box.
43+func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) {
44+ if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {
45+ return
46+ }
47+
48+ width := frame.Width * 2 / 5
49+ if width > noticeMaxWidth {
50+ width = noticeMaxWidth
51+ }
52+ if width < noticeMinWidth {
53+ width = noticeMinWidth
54+ }
55+ if width > frame.Width {
56+ width = frame.Width
57+ }
58+ if width <= 0 {
59+ return
60+ }
61+ x := frame.Width - noticeMargin - width
62+ y := noticeMargin
63+
64+ for _, n := range notices {
65+ if y >= frame.Height {
66+ break
67+ }
68+ innerWidth := width - 2
69+ lines := wrapNoticeText(n.Message, innerWidth, noticeMaxLines)
70+ if len(lines) == 0 {
71+ lines = []string{""}
72+ }
73+ height := len(lines) + 2
74+ bounds := domain.Rect{X: x, Y: y, Width: width, Height: height}
75+ box := noticeBoxStyle(styles, n.Severity)
76+ DrawBox(frame, bounds, box)
77+
78+ title := n.Title
79+ if n.Count > 1 {
80+ title = fmt.Sprintf("%s ×%d", n.Title, n.Count)
81+ }
82+ if title != "" && bounds.Width > 2 {
83+ left := bounds.X + 1
84+ right := bounds.X + bounds.Width - 1
85+ start := max(left, bounds.X+(bounds.Width-textWidth(title))/2)
86+ DrawText(frame, start, bounds.Y, right, title, box)
87+ }
88+
89+ inner := domain.Rect{X: bounds.X + 1, Y: bounds.Y + 1, Width: max(0, bounds.Width-2), Height: max(0, bounds.Height-2)}
90+ if inner.Width > 0 && inner.Height > 0 {
91+ fill := renderer.BlankCell()
92+ fill.Style = styles.Text
93+ FillRect(frame, inner, fill)
94+ for i, line := range lines {
95+ if i >= inner.Height {
96+ break
97+ }
98+ DrawText(frame, inner.X, inner.Y+i, inner.X+inner.Width, line, styles.Text)
99+ }
100+ }
101+
102+ y += height + 1
103+ }
104+
105+ if overflow > 0 && y < frame.Height {
106+ text := fmt.Sprintf("+%d more", overflow)
107+ start := x + width - textWidth(text)
108+ if start < x {
109+ start = x
110+ }
111+ DrawText(frame, start, y, x+width, text, styles.Text)
112+ }
113+}
114+
115+func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style {
116+ switch domain.NoticeSeverity(severity) {
117+ case domain.NoticeWarn:
118+ return styles.BoxWarn
119+ case domain.NoticeError:
120+ return styles.BoxError
121+ default:
122+ return styles.BoxInfo
123+ }
124+}
125+
126+// wrapNoticeText greedily fills lines up to width, then caps at maxLines by
127+// collapsing any remainder into the last line and truncating it, so an
128+// overlong message is clipped visibly rather than silently dropped mid-word.
129+func wrapNoticeText(message string, width, maxLines int) []string {
130+ if width <= 0 || maxLines <= 0 {
131+ return nil
132+ }
133+ words := strings.Fields(message)
134+ if len(words) == 0 {
135+ return nil
136+ }
137+
138+ lines := make([]string, 0, maxLines)
139+ current := words[0]
140+ for _, w := range words[1:] {
141+ candidate := current + " " + w
142+ if textWidth(candidate) <= width {
143+ current = candidate
144+ continue
145+ }
146+ lines = append(lines, current)
147+ current = w
148+ }
149+ lines = append(lines, current)
150+
151+ if len(lines) <= maxLines {
152+ return lines
153+ }
154+ rest := strings.Join(lines[maxLines-1:], " ")
155+ lines = lines[:maxLines-1]
156+ return append(lines, TruncateText(rest, width))
157+}
A internal/usecase/ui/notice_test.go +173 -0
@@ -0,0 +1,173 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+// frameText joins every row of frame into one newline-separated string so
13+// tests can search for substrings without caring which cells they land on.
14+func frameText(f renderer.Frame) string {
15+ rows := make([]string, f.Height)
16+ for y := 0; y < f.Height; y++ {
17+ var b strings.Builder
18+ for x := 0; x < f.Width; x++ {
19+ cell := f.At(x, y)
20+ if cell.Continuation {
21+ continue
22+ }
23+ if cell.Rune == 0 {
24+ b.WriteByte(' ')
25+ continue
26+ }
27+ b.WriteRune(cell.Rune)
28+ }
29+ rows[y] = b.String()
30+ }
31+ return strings.Join(rows, "\n")
32+}
33+
34+func TestComposeNotices(t *testing.T) {
35+ styles := NoticeStyles{
36+ Text: renderer.Style{Foreground: 7, Background: -1},
37+ BoxInfo: renderer.Style{Foreground: 4, Background: -1},
38+ BoxWarn: renderer.Style{Foreground: 3, Background: -1},
39+ BoxError: renderer.Style{Foreground: 1, Background: -1},
40+ }
41+ isBorderRune := func(r rune) bool {
42+ switch r {
43+ case '┌', '┐', '└', '┘', '─', '│':
44+ return true
45+ default:
46+ return false
47+ }
48+ }
49+
50+ tests := []struct {
51+ name string
52+ notices []NoticeView
53+ overflow int
54+ check func(t *testing.T, f renderer.Frame)
55+ }{
56+ {
57+ name: "single error notice draws a bordered box top-right with title and message",
58+ notices: []NoticeView{
59+ {Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "couldn't open pane", Count: 3},
60+ },
61+ check: func(t *testing.T, f renderer.Frame) {
62+ found := false
63+ for y := 0; y <= 8 && !found; y++ {
64+ for x := 40; x < f.Width; x++ {
65+ if isBorderRune(f.At(x, y).Rune) {
66+ found = true
67+ break
68+ }
69+ }
70+ }
71+ if !found {
72+ t.Fatalf("no box border found in top-right quadrant (x>=40, y<=8):\n%s", frameText(f))
73+ }
74+ text := frameText(f)
75+ if !strings.Contains(text, "pane-spawn ×3") {
76+ t.Fatalf("frame missing title %q:\n%s", "pane-spawn ×3", text)
77+ }
78+ if !strings.Contains(text, "couldn't open pane") {
79+ t.Fatalf("frame missing message %q:\n%s", "couldn't open pane", text)
80+ }
81+ },
82+ },
83+ {
84+ name: "three notices with overflow renders a +N more line",
85+ notices: []NoticeView{
86+ {Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "one"},
87+ {Severity: uint8(domain.NoticeWarn), Title: "tab-spawn", Message: "two"},
88+ {Severity: uint8(domain.NoticeInfo), Title: "clipboard", Message: "three"},
89+ },
90+ overflow: 2,
91+ check: func(t *testing.T, f renderer.Frame) {
92+ text := frameText(f)
93+ if !strings.Contains(text, "+2 more") {
94+ t.Fatalf("frame missing overflow line %q:\n%s", "+2 more", text)
95+ }
96+ },
97+ },
98+ {
99+ name: "no notices and no overflow draws nothing",
100+ notices: nil,
101+ overflow: 0,
102+ check: func(t *testing.T, f renderer.Frame) {
103+ want := renderer.NewFrame(f.Width, f.Height)
104+ if frameText(f) != frameText(want) {
105+ t.Fatalf("frame changed with no notices and no overflow:\n%s", frameText(f))
106+ }
107+ },
108+ },
109+ }
110+
111+ for _, tt := range tests {
112+ t.Run(tt.name, func(t *testing.T) {
113+ f := renderer.NewFrame(80, 24)
114+ ComposeNotices(f, tt.notices, tt.overflow, styles)
115+ tt.check(t, f)
116+ })
117+ }
118+}
119+
120+func TestComposeNoticesSeverityStylingIsDistinguishable(t *testing.T) {
121+ styles := NoticeStyles{
122+ Text: renderer.Style{Foreground: 7, Background: -1},
123+ BoxInfo: renderer.Style{Foreground: 4, Background: -1},
124+ BoxWarn: renderer.Style{Foreground: 3, Background: -1},
125+ BoxError: renderer.Style{Foreground: 1, Background: -1},
126+ }
127+
128+ tests := []struct {
129+ name string
130+ sev domain.NoticeSeverity
131+ want renderer.Style
132+ }{
133+ {"info", domain.NoticeInfo, styles.BoxInfo},
134+ {"warn", domain.NoticeWarn, styles.BoxWarn},
135+ {"error", domain.NoticeError, styles.BoxError},
136+ }
137+ for _, tt := range tests {
138+ t.Run(tt.name, func(t *testing.T) {
139+ f := renderer.NewFrame(80, 24)
140+ ComposeNotices(f, []NoticeView{{Severity: uint8(tt.sev), Title: "x", Message: "y"}}, 0, styles)
141+ corner := findRune(f, '┌')
142+ if corner == nil {
143+ t.Fatalf("no box top-left corner found:\n%s", frameText(f))
144+ }
145+ if !corner.Style.Equal(tt.want) {
146+ t.Fatalf("box border style = %+v, want %s style %+v", corner.Style, tt.name, tt.want)
147+ }
148+ })
149+ }
150+}
151+
152+// findRune returns the first cell in f carrying r, or nil if none does.
153+func findRune(f renderer.Frame, r rune) *renderer.Cell {
154+ for y := 0; y < f.Height; y++ {
155+ for x := 0; x < f.Width; x++ {
156+ if cell := f.At(x, y); cell.Rune == r {
157+ return &cell
158+ }
159+ }
160+ }
161+ return nil
162+}
163+
164+func TestComposeNoticesHandlesTinyFrames(t *testing.T) {
165+ styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()}
166+ for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {
167+ t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
168+ f := renderer.NewFrame(size.Cols, size.Rows)
169+ notices := []NoticeView{{Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "a very long message that should wrap across several lines of text"}}
170+ ComposeNotices(f, notices, 5, styles)
171+ })
172+ }
173+}
@@ -0,0 +1,173 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+// frameText joins every row of frame into one newline-separated string so
13+// tests can search for substrings without caring which cells they land on.
14+func frameText(f renderer.Frame) string {
15+ rows := make([]string, f.Height)
16+ for y := 0; y < f.Height; y++ {
17+ var b strings.Builder
18+ for x := 0; x < f.Width; x++ {
19+ cell := f.At(x, y)
20+ if cell.Continuation {
21+ continue
22+ }
23+ if cell.Rune == 0 {
24+ b.WriteByte(' ')
25+ continue
26+ }
27+ b.WriteRune(cell.Rune)
28+ }
29+ rows[y] = b.String()
30+ }
31+ return strings.Join(rows, "\n")
32+}
33+
34+func TestComposeNotices(t *testing.T) {
35+ styles := NoticeStyles{
36+ Text: renderer.Style{Foreground: 7, Background: -1},
37+ BoxInfo: renderer.Style{Foreground: 4, Background: -1},
38+ BoxWarn: renderer.Style{Foreground: 3, Background: -1},
39+ BoxError: renderer.Style{Foreground: 1, Background: -1},
40+ }
41+ isBorderRune := func(r rune) bool {
42+ switch r {
43+ case '┌', '┐', '└', '┘', '─', '│':
44+ return true
45+ default:
46+ return false
47+ }
48+ }
49+
50+ tests := []struct {
51+ name string
52+ notices []NoticeView
53+ overflow int
54+ check func(t *testing.T, f renderer.Frame)
55+ }{
56+ {
57+ name: "single error notice draws a bordered box top-right with title and message",
58+ notices: []NoticeView{
59+ {Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "couldn't open pane", Count: 3},
60+ },
61+ check: func(t *testing.T, f renderer.Frame) {
62+ found := false
63+ for y := 0; y <= 8 && !found; y++ {
64+ for x := 40; x < f.Width; x++ {
65+ if isBorderRune(f.At(x, y).Rune) {
66+ found = true
67+ break
68+ }
69+ }
70+ }
71+ if !found {
72+ t.Fatalf("no box border found in top-right quadrant (x>=40, y<=8):\n%s", frameText(f))
73+ }
74+ text := frameText(f)
75+ if !strings.Contains(text, "pane-spawn ×3") {
76+ t.Fatalf("frame missing title %q:\n%s", "pane-spawn ×3", text)
77+ }
78+ if !strings.Contains(text, "couldn't open pane") {
79+ t.Fatalf("frame missing message %q:\n%s", "couldn't open pane", text)
80+ }
81+ },
82+ },
83+ {
84+ name: "three notices with overflow renders a +N more line",
85+ notices: []NoticeView{
86+ {Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "one"},
87+ {Severity: uint8(domain.NoticeWarn), Title: "tab-spawn", Message: "two"},
88+ {Severity: uint8(domain.NoticeInfo), Title: "clipboard", Message: "three"},
89+ },
90+ overflow: 2,
91+ check: func(t *testing.T, f renderer.Frame) {
92+ text := frameText(f)
93+ if !strings.Contains(text, "+2 more") {
94+ t.Fatalf("frame missing overflow line %q:\n%s", "+2 more", text)
95+ }
96+ },
97+ },
98+ {
99+ name: "no notices and no overflow draws nothing",
100+ notices: nil,
101+ overflow: 0,
102+ check: func(t *testing.T, f renderer.Frame) {
103+ want := renderer.NewFrame(f.Width, f.Height)
104+ if frameText(f) != frameText(want) {
105+ t.Fatalf("frame changed with no notices and no overflow:\n%s", frameText(f))
106+ }
107+ },
108+ },
109+ }
110+
111+ for _, tt := range tests {
112+ t.Run(tt.name, func(t *testing.T) {
113+ f := renderer.NewFrame(80, 24)
114+ ComposeNotices(f, tt.notices, tt.overflow, styles)
115+ tt.check(t, f)
116+ })
117+ }
118+}
119+
120+func TestComposeNoticesSeverityStylingIsDistinguishable(t *testing.T) {
121+ styles := NoticeStyles{
122+ Text: renderer.Style{Foreground: 7, Background: -1},
123+ BoxInfo: renderer.Style{Foreground: 4, Background: -1},
124+ BoxWarn: renderer.Style{Foreground: 3, Background: -1},
125+ BoxError: renderer.Style{Foreground: 1, Background: -1},
126+ }
127+
128+ tests := []struct {
129+ name string
130+ sev domain.NoticeSeverity
131+ want renderer.Style
132+ }{
133+ {"info", domain.NoticeInfo, styles.BoxInfo},
134+ {"warn", domain.NoticeWarn, styles.BoxWarn},
135+ {"error", domain.NoticeError, styles.BoxError},
136+ }
137+ for _, tt := range tests {
138+ t.Run(tt.name, func(t *testing.T) {
139+ f := renderer.NewFrame(80, 24)
140+ ComposeNotices(f, []NoticeView{{Severity: uint8(tt.sev), Title: "x", Message: "y"}}, 0, styles)
141+ corner := findRune(f, '┌')
142+ if corner == nil {
143+ t.Fatalf("no box top-left corner found:\n%s", frameText(f))
144+ }
145+ if !corner.Style.Equal(tt.want) {
146+ t.Fatalf("box border style = %+v, want %s style %+v", corner.Style, tt.name, tt.want)
147+ }
148+ })
149+ }
150+}
151+
152+// findRune returns the first cell in f carrying r, or nil if none does.
153+func findRune(f renderer.Frame, r rune) *renderer.Cell {
154+ for y := 0; y < f.Height; y++ {
155+ for x := 0; x < f.Width; x++ {
156+ if cell := f.At(x, y); cell.Rune == r {
157+ return &cell
158+ }
159+ }
160+ }
161+ return nil
162+}
163+
164+func TestComposeNoticesHandlesTinyFrames(t *testing.T) {
165+ styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()}
166+ for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {
167+ t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
168+ f := renderer.NewFrame(size.Cols, size.Rows)
169+ notices := []NoticeView{{Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "a very long message that should wrap across several lines of text"}}
170+ ComposeNotices(f, notices, 5, styles)
171+ })
172+ }
173+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close