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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 4 | 4 | "sync" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/palette" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/picker" | |
| @@ -243,6 +244,9 @@ type overlayRenderSnapshot struct { | |||
| 243 | 244 | promptActive bool | |
| 244 | 245 | promptModel *promptui.Model | |
| 245 | 246 | promptLocked bool | |
| 247 | + | ||
| 248 | + | notices []domain.Notification | |
| 249 | + | noticeOverflow int | |
| 246 | 250 | } | |
| 247 | 251 | ||
| 248 | 252 | // SnapshotForRender captures the overlay state needed by paint. | |
| @@ -257,6 +261,18 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 257 | 261 | 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 | 276 | rt.copyMu.Lock() | |
| 261 | 277 | snap.copyActive = rt.copyMode != nil | |
| 262 | 278 | 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 bool | 244 | promptActive bool | ||
| 244 | promptModel *promptui.Model | 245 | promptModel *promptui.Model | ||
| 245 | promptLocked bool | 246 | 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 snap | 261 | 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 != nil | 277 | snap.copyActive = rt.copyMode != nil | ||
| 262 | snap.copyPane = rt.copyPane | 278 | snap.copyPane = rt.copyPane | ||
M internal/usecase/daemon/render_capture.go +2 -0
| @@ -91,6 +91,8 @@ type capturedOverlayRenderState struct { | |||
| 91 | 91 | copyFeedback string | |
| 92 | 92 | paletteGuidance string | |
| 93 | 93 | copySearch, picker, palette, prompt capturedModal | |
| 94 | + | notices []domain.Notification | |
| 95 | + | noticeOverflow int | |
| 94 | 96 | } | |
| 95 | 97 | ||
| 96 | 98 | type capturedModal struct { | |
| @@ -91,6 +91,8 @@ type capturedOverlayRenderState struct { | |||||
| 91 | copyFeedback string | 91 | copyFeedback string | ||
| 92 | paletteGuidance string | 92 | paletteGuidance string | ||
| 93 | copySearch, picker, palette, prompt capturedModal | 93 | 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 ... | |||
| 151 | 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 | 158 | // Without a floating frame, overlay composition would otherwise mutate | |
| 156 | 159 | // the base cache in place. Floating composition already owns a clone. | |
| 157 | 160 | if !state.floating.visible { | |
| @@ -159,7 +162,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 159 | 162 | } | |
| 160 | 163 | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |
| 161 | 164 | } | |
| 162 | - | if full || state.overlays.active() { | |
| 165 | + | if full || overlaysOrToasts { | |
| 163 | 166 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 164 | 167 | } | |
| 165 | 168 | cursorInputs := state.cursor | |
| @@ -199,6 +202,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 199 | 202 | o := &state.overlays | |
| 200 | 203 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | |
| 201 | 204 | o.copyMode = snap.copyMode | |
| 205 | + | o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow | |
| 202 | 206 | if snap.copyPane != nil { | |
| 203 | 207 | o.copyPaneID = snap.copyPane.id | |
| 204 | 208 | } | |
| @@ -276,9 +280,28 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 276 | 280 | } | |
| 277 | 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 | 290 | 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 | 305 | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |
| 283 | 306 | 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 | 307 | 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 mutate | 158 | // 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.cursor | 168 | cursorInputs := state.cursor | ||
| @@ -199,6 +202,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 199 | o := &state.overlays | 202 | o := &state.overlays | ||
| 200 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | 203 | 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.copyMode | 204 | 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.id | 207 | 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, damage | 290 | 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.hasStyle | 306 | 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 | + | } | |||