detached dfadb21

perf(daemon): preserve incremental damage with notice toasts

commit
dfadb212ef92b5bb859f870f57ecfcc1f6b1539d
author
brice · · 2026-07-21 10:42 UTC
parent
6cda4a5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +217 -33
M internal/usecase/daemon/notify.go +4 -1
@@ -374,5 +374,8 @@ func (d *Daemon) repaintForNotice(ac *attachedClient) {
374374 // invalidateRender cannot paint without a session.
375375 return
376376 }
377- d.invalidateRender(sess, ac, true, "notify.go")
377+ // Toast footprints carry their own old/new damage, so a normal notice
378+ // transition can remain incremental. First paint and transport recovery
379+ // still provide their independent reset invariants.
380+ d.invalidateRender(sess, ac, false, "notify.go")
378381 }
@@ -374,5 +374,8 @@ func (d *Daemon) repaintForNotice(ac *attachedClient) {
374 // invalidateRender cannot paint without a session.374 // invalidateRender cannot paint without a session.
375 return375 return
376 }376 }
377- d.invalidateRender(sess, ac, true, "notify.go")377+ // Toast footprints carry their own old/new damage, so a normal notice
378+ // transition can remain incremental. First paint and transport recovery
379+ // still provide their independent reset invariants.
380+ d.invalidateRender(sess, ac, false, "notify.go")
378 }381 }
M internal/usecase/daemon/notify_test.go +14 -16
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/vev/internal/domain"
1616 "github.com/bnema/vev/internal/ports"
1717 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/pkg/vt"
1819 )
1920
2021 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
@@ -299,16 +300,6 @@ func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notifi
299300 return got
300301 }
301302
302-func drainFrames(sends chan ports.Frame) {
303- for {
304- select {
305- case <-sends:
306- default:
307- return
308- }
309- }
310-}
311-
312303 func TestReportErrorUserErrorBecomesNotification(t *testing.T) {
313304 tests := []struct {
314305 name string
@@ -746,7 +737,11 @@ func TestToastExpiresOnFakeClock(t *testing.T) {
746737
747738 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
748739 awaitToastCount(t, ac, 1)
749- drainFrames(sends)
740+ shown := awaitFrame(t, sends, ports.MsgOutput)
741+ shownOutput, err := ports.UnmarshalOutput(shown.Payload)
742+ require.NoError(t, err)
743+ terminal := vt.NewScreen(80, 25)
744+ terminal.Write(shownOutput.Data)
750745
751746 // Short of the 8s error TTL nothing expires.
752747 clk.advance(7 * time.Second)
@@ -758,11 +753,14 @@ func TestToastExpiresOnFakeClock(t *testing.T) {
758753 _, overflow := visibleToasts(ac)
759754 require.Zero(t, overflow, "overflow resets once no toast is visible")
760755
761- select {
762- case <-sends:
763- case <-time.After(2 * time.Second):
764- t.Fatal("expiry did not invalidate the render")
765- }
756+ expired := awaitFrame(t, sends, ports.MsgOutput)
757+ expiredOutput, err := ports.UnmarshalOutput(expired.Payload)
758+ require.NoError(t, err)
759+ terminal.Write(expiredOutput.Data)
760+ ac.sendMu.Lock()
761+ base := ac.pipelineCache.frame.Clone()
762+ ac.sendMu.Unlock()
763+ require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell")
766764 }
767765
768766 // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/pkg/vt"
18 )19 )
19 20
20 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {21 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
@@ -299,16 +300,6 @@ func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notifi
299 return got300 return got
300 }301 }
301 302
302-func drainFrames(sends chan ports.Frame) {
303- for {
304- select {
305- case <-sends:
306- default:
307- return
308- }
309- }
310-}
311-
312 func TestReportErrorUserErrorBecomesNotification(t *testing.T) {303 func TestReportErrorUserErrorBecomesNotification(t *testing.T) {
313 tests := []struct {304 tests := []struct {
314 name string305 name string
@@ -746,7 +737,11 @@ func TestToastExpiresOnFakeClock(t *testing.T) {
746 737
747 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)738 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
748 awaitToastCount(t, ac, 1)739 awaitToastCount(t, ac, 1)
749- drainFrames(sends)740+ shown := awaitFrame(t, sends, ports.MsgOutput)
741+ shownOutput, err := ports.UnmarshalOutput(shown.Payload)
742+ require.NoError(t, err)
743+ terminal := vt.NewScreen(80, 25)
744+ terminal.Write(shownOutput.Data)
750 745
751 // Short of the 8s error TTL nothing expires.746 // Short of the 8s error TTL nothing expires.
752 clk.advance(7 * time.Second)747 clk.advance(7 * time.Second)
@@ -758,11 +753,14 @@ func TestToastExpiresOnFakeClock(t *testing.T) {
758 _, overflow := visibleToasts(ac)753 _, overflow := visibleToasts(ac)
759 require.Zero(t, overflow, "overflow resets once no toast is visible")754 require.Zero(t, overflow, "overflow resets once no toast is visible")
760 755
761- select {756+ expired := awaitFrame(t, sends, ports.MsgOutput)
762- case <-sends:757+ expiredOutput, err := ports.UnmarshalOutput(expired.Payload)
763- case <-time.After(2 * time.Second):758+ require.NoError(t, err)
764- t.Fatal("expiry did not invalidate the render")759+ terminal.Write(expiredOutput.Data)
765- }760+ ac.sendMu.Lock()
761+ base := ac.pipelineCache.frame.Clone()
762+ ac.sendMu.Unlock()
763+ require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell")
766 }764 }
767 765
768 // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a766 // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a
M internal/usecase/daemon/render_pipeline.go +34 -13
@@ -44,6 +44,7 @@ type composeCacheInput struct {
4444 floatingGeometry floatingGeometry
4545 floatingTitleGeneration uint64
4646 bars barCache
47+ toastFootprints []domain.Rect
4748 theme themeui.Theme
4849 styleGeneration uint64
4950 }
@@ -152,10 +153,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
152153 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
153154 }
154155 }
155- // Toasts render even with no modal overlay active, so this gate also
156- // checks for pending notices rather than only state.overlays.active().
157- overlaysOrToasts := state.overlays.active() || len(state.overlays.notices) > 0
158- if overlaysOrToasts {
156+ // The cache stays toast-free. The terminal shadow, in contrast, includes
157+ // toasts, so every render damages both the last and current toast coverage.
158+ // This restores cells exposed by dismissal and redraws a stable toast over
159+ // any underlying pane update without promoting either case to a full frame.
160+ overlaysActive := state.overlays.active()
161+ toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
162+ if overlaysActive || toastsVisible {
159163 // Without a floating frame, overlay composition would otherwise mutate
160164 // the base cache in place. Floating composition already owns a clone.
161165 if !state.floating.visible {
@@ -163,13 +167,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
163167 }
164168 frame, damage = composeCapturedOverlays(state, frame, damage, content)
165169 }
166- if full || overlaysOrToasts {
170+ toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles)
171+ if full || overlaysActive {
167172 damage = []renderer.Damage{renderer.FullRedraw()}
173+ } else {
174+ damage = appendToastDamage(damage, in.toastFootprints)
175+ damage = appendToastDamage(damage, toastFootprints)
168176 }
169177 cursorInputs := state.cursor
170- cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
178+ cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
171179 cursor := desiredCapturedCursor(cursorInputs)
172- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
180+ outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
173181 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
174182 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
175183 }
@@ -296,14 +304,27 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
296304 }
297305 damage = []renderer.Damage{renderer.FullRedraw()}
298306 }
299- if len(o.notices) > 0 || o.noticeOverflow > 0 {
300- views := make([]ui.NoticeView, len(o.notices))
301- for i, n := range o.notices {
302- views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count}
307+ return frame, damage
308+}
309+
310+func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
311+ if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
312+ return nil
313+ }
314+ views := make([]ui.NoticeView, len(overlays.notices))
315+ for i, n := range overlays.notices {
316+ views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count}
317+ }
318+ return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))
319+}
320+
321+func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage {
322+ for _, footprint := range footprints {
323+ if footprint.Width > 0 && footprint.Height > 0 {
324+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
303325 }
304- ui.ComposeNotices(frame, views, o.noticeOverflow, noticeStylesFrom(state.styles))
305326 }
306- return frame, damage
327+ return damage
307328 }
308329
309330 // noticeStylesFrom picks the toast box color per severity from the theme's
@@ -44,6 +44,7 @@ type composeCacheInput struct {
44 floatingGeometry floatingGeometry44 floatingGeometry floatingGeometry
45 floatingTitleGeneration uint6445 floatingTitleGeneration uint64
46 bars barCache46 bars barCache
47+ toastFootprints []domain.Rect
47 theme themeui.Theme48 theme themeui.Theme
48 styleGeneration uint6449 styleGeneration uint64
49 }50 }
@@ -152,10 +153,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
152 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})153 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
153 }154 }
154 }155 }
155- // Toasts render even with no modal overlay active, so this gate also156+ // The cache stays toast-free. The terminal shadow, in contrast, includes
156- // checks for pending notices rather than only state.overlays.active().157+ // toasts, so every render damages both the last and current toast coverage.
157- overlaysOrToasts := state.overlays.active() || len(state.overlays.notices) > 0158+ // This restores cells exposed by dismissal and redraws a stable toast over
158- if overlaysOrToasts {159+ // any underlying pane update without promoting either case to a full frame.
160+ overlaysActive := state.overlays.active()
161+ toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
162+ if overlaysActive || toastsVisible {
159 // Without a floating frame, overlay composition would otherwise mutate163 // Without a floating frame, overlay composition would otherwise mutate
160 // the base cache in place. Floating composition already owns a clone.164 // the base cache in place. Floating composition already owns a clone.
161 if !state.floating.visible {165 if !state.floating.visible {
@@ -163,13 +167,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
163 }167 }
164 frame, damage = composeCapturedOverlays(state, frame, damage, content)168 frame, damage = composeCapturedOverlays(state, frame, damage, content)
165 }169 }
166- if full || overlaysOrToasts {170+ toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles)
171+ if full || overlaysActive {
167 damage = []renderer.Damage{renderer.FullRedraw()}172 damage = []renderer.Damage{renderer.FullRedraw()}
173+ } else {
174+ damage = appendToastDamage(damage, in.toastFootprints)
175+ damage = appendToastDamage(damage, toastFootprints)
168 }176 }
169 cursorInputs := state.cursor177 cursorInputs := state.cursor
170- cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()178+ cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
171 cursor := desiredCapturedCursor(cursorInputs)179 cursor := desiredCapturedCursor(cursorInputs)
172- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}180+ outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
173 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))181 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
174 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}182 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
175 }183 }
@@ -296,14 +304,27 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
296 }304 }
297 damage = []renderer.Damage{renderer.FullRedraw()}305 damage = []renderer.Damage{renderer.FullRedraw()}
298 }306 }
299- if len(o.notices) > 0 || o.noticeOverflow > 0 {307+ return frame, damage
300- views := make([]ui.NoticeView, len(o.notices))308+}
301- for i, n := range o.notices {309+
302- views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count}310+func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
311+ if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
312+ return nil
313+ }
314+ views := make([]ui.NoticeView, len(overlays.notices))
315+ for i, n := range overlays.notices {
316+ views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count}
317+ }
318+ return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))
319+}
320+
321+func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage {
322+ for _, footprint := range footprints {
323+ if footprint.Width > 0 && footprint.Height > 0 {
324+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
303 }325 }
304- ui.ComposeNotices(frame, views, o.noticeOverflow, noticeStylesFrom(state.styles))
305 }326 }
306- return frame, damage327+ return damage
307 }328 }
308 329
309 // noticeStylesFrom picks the toast box color per severity from the theme's330 // noticeStylesFrom picks the toast box color per severity from the theme's
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -0
@@ -300,6 +300,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput {
300300 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
301301 maps.Copy(out.titleGenerations, in.titleGenerations)
302302 out.damage = append([]renderer.Damage(nil), in.damage...)
303+ out.toastFootprints = append([]domain.Rect(nil), in.toastFootprints...)
303304 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
304305 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
305306 return out
@@ -300,6 +300,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput {
300 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))300 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
301 maps.Copy(out.titleGenerations, in.titleGenerations)301 maps.Copy(out.titleGenerations, in.titleGenerations)
302 out.damage = append([]renderer.Damage(nil), in.damage...)302 out.damage = append([]renderer.Damage(nil), in.damage...)
303+ out.toastFootprints = append([]domain.Rect(nil), in.toastFootprints...)
303 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)304 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
304 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)305 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
305 return out306 return out
A internal/usecase/daemon/toast_damage_test.go +156 -0
@@ -0,0 +1,156 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing.T) {
14+ baseState := toastDamageState(nil, nil, true)
15+ base := composeFrame(baseState, composeCacheInput{})
16+ stream := newOutputStateStream()
17+ terminal := vt.NewScreen(base.frame.Width, base.frame.Height)
18+ replayToastFrame(t, stream, terminal, base)
19+
20+ one := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
21+ shownState := toastDamageState([]domain.Notification{one}, nil, false)
22+ shown := composeFrame(shownState, base.cache)
23+ requireNoFullRedraw(t, shown.damage)
24+ requireToastFootprintsDamaged(t, shown.damage, shown.cache.toastFootprints)
25+ replayToastFrame(t, stream, terminal, shown)
26+
27+ // A one-cell PTY update while the toast is stable redraws only the pane cell
28+ // and toast footprint, never the whole terminal.
29+ updatedState := toastDamageState([]domain.Notification{one}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
30+ updatedState.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
31+ updated := composeFrame(updatedState, shown.cache)
32+ requireNoFullRedraw(t, updated.damage)
33+ require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})
34+ requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)
35+ requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints)
36+ incremental, err := stream.render(updated.frame, updated.damage, updated.reset)
37+ require.NoError(t, err)
38+ fullRenderer := renderer.New(renderer.Capabilities{})
39+ full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})
40+ require.NoError(t, err)
41+ require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output")
42+ terminal.Write(incremental)
43+ require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame))
44+
45+ // Stacking and overflow stay visually identical while their complete old and
46+ // new coverage remains incremental damage.
47+ two := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "second", Count: 1}
48+ three := domain.Notification{Code: domain.NoticeConfigReload, Severity: domain.NoticeError, Message: "third", Count: 1}
49+ stackedState := toastDamageState([]domain.Notification{two, one, three}, nil, false)
50+ stackedState.overlays.noticeOverflow = 2
51+ stacked := composeFrame(stackedState, updated.cache)
52+ requireNoFullRedraw(t, stacked.damage)
53+ requireToastFootprintsDamaged(t, stacked.damage, updated.cache.toastFootprints)
54+ requireToastFootprintsDamaged(t, stacked.damage, stacked.cache.toastFootprints)
55+ replayToastFrame(t, stream, terminal, stacked)
56+
57+ // Coalescing changes the title count in place. Both old and new coverage is
58+ // damaged even where their geometry happens to be identical.
59+ coalescedTwo := two
60+ coalescedTwo.Count = 12
61+ coalescedState := toastDamageState([]domain.Notification{coalescedTwo, one, three}, nil, false)
62+ coalescedState.overlays.noticeOverflow = 2
63+ coalesced := composeFrame(coalescedState, stacked.cache)
64+ requireNoFullRedraw(t, coalesced.damage)
65+ requireToastFootprintsDamaged(t, coalesced.damage, stacked.cache.toastFootprints)
66+ requireToastFootprintsDamaged(t, coalesced.damage, coalesced.cache.toastFootprints)
67+ replayToastFrame(t, stream, terminal, coalesced)
68+
69+ // Dismissal and expiry have the same rendering transition: no current toast
70+ // exists, so the previous footprint alone must restore every exposed cell.
71+ for _, tt := range []struct {
72+ name string
73+ from composedRenderFrame
74+ }{
75+ {name: "dismiss", from: coalesced},
76+ {name: "expiry", from: shown},
77+ } {
78+ t.Run(tt.name, func(t *testing.T) {
79+ cleared := composeFrame(toastDamageState(nil, nil, false), tt.from.cache)
80+ requireNoFullRedraw(t, cleared.damage)
81+ requireToastFootprintsDamaged(t, cleared.damage, tt.from.cache.toastFootprints)
82+ require.Empty(t, cleared.cache.toastFootprints)
83+ })
84+ }
85+
86+ cleared := composeFrame(toastDamageState(nil, nil, false), coalesced.cache)
87+ replayToastFrame(t, stream, terminal, cleared)
88+ require.Equal(t, frameRows(coalesced.cache.frame), frameRows(cleared.frame), "dismissal must restore the toast-free base without artifacts")
89+}
90+
91+func BenchmarkComposeFrameStableToastOneCellUpdate(b *testing.B) {
92+ base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{})
93+ notice := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
94+ shown := composeFrame(toastDamageState([]domain.Notification{notice}, nil, false), base.cache, composeCacheInput{})
95+ cache, scratch := shown.cache, base.cache
96+ state := toastDamageState([]domain.Notification{notice}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
97+
98+ b.ReportAllocs()
99+ for i := 0; b.Loop(); i++ {
100+ cell := renderer.Cell{Rune: rune('A' + i%26), Style: renderer.DefaultStyle()}
101+ state.panes[0].frame.Set(0, 0, cell)
102+ out := composeFrame(state, cache, scratch)
103+ if hasFullRedraw(out.damage) {
104+ b.Fatal("stable toast forced a full redraw")
105+ }
106+ scratch, cache = cache, out.cache
107+ }
108+}
109+
110+func toastDamageState(notices []domain.Notification, damage []renderer.Damage, reset bool) capturedRenderState {
111+ paneFrame := renderer.NewFrame(80, 20)
112+ for y := 0; y < paneFrame.Height; y++ {
113+ for x := 0; x < paneFrame.Width; x++ {
114+ paneFrame.Set(x, y, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
115+ }
116+ }
117+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 20}}
118+ return capturedRenderState{
119+ reset: reset,
120+ layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 20}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "toast-damage", valid: true},
121+ panes: []capturedPaneRenderState{{id: "pane", frame: paneFrame, placement: placement, focused: true, damage: damage}},
122+ overlays: capturedOverlayRenderState{notices: notices},
123+ styles: resolveStyles(nil),
124+ }
125+}
126+
127+func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {
128+ t.Helper()
129+ data, err := stream.render(composed.frame, composed.damage, composed.reset)
130+ require.NoError(t, err)
131+ terminal.Write(data)
132+ require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))
133+}
134+
135+func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) {
136+ t.Helper()
137+ for _, d := range damage {
138+ require.NotEqual(t, renderer.DamageFullRedraw, d.Kind)
139+ }
140+}
141+
142+func requireToastFootprintsDamaged(t *testing.T, damage []renderer.Damage, footprints []domain.Rect) {
143+ t.Helper()
144+ for _, footprint := range footprints {
145+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
146+ }
147+}
148+
149+func hasFullRedraw(damage []renderer.Damage) bool {
150+ for _, d := range damage {
151+ if d.Kind == renderer.DamageFullRedraw {
152+ return true
153+ }
154+ }
155+ return false
156+}
@@ -0,0 +1,156 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing.T) {
14+ baseState := toastDamageState(nil, nil, true)
15+ base := composeFrame(baseState, composeCacheInput{})
16+ stream := newOutputStateStream()
17+ terminal := vt.NewScreen(base.frame.Width, base.frame.Height)
18+ replayToastFrame(t, stream, terminal, base)
19+
20+ one := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
21+ shownState := toastDamageState([]domain.Notification{one}, nil, false)
22+ shown := composeFrame(shownState, base.cache)
23+ requireNoFullRedraw(t, shown.damage)
24+ requireToastFootprintsDamaged(t, shown.damage, shown.cache.toastFootprints)
25+ replayToastFrame(t, stream, terminal, shown)
26+
27+ // A one-cell PTY update while the toast is stable redraws only the pane cell
28+ // and toast footprint, never the whole terminal.
29+ updatedState := toastDamageState([]domain.Notification{one}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
30+ updatedState.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
31+ updated := composeFrame(updatedState, shown.cache)
32+ requireNoFullRedraw(t, updated.damage)
33+ require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})
34+ requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)
35+ requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints)
36+ incremental, err := stream.render(updated.frame, updated.damage, updated.reset)
37+ require.NoError(t, err)
38+ fullRenderer := renderer.New(renderer.Capabilities{})
39+ full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})
40+ require.NoError(t, err)
41+ require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output")
42+ terminal.Write(incremental)
43+ require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame))
44+
45+ // Stacking and overflow stay visually identical while their complete old and
46+ // new coverage remains incremental damage.
47+ two := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "second", Count: 1}
48+ three := domain.Notification{Code: domain.NoticeConfigReload, Severity: domain.NoticeError, Message: "third", Count: 1}
49+ stackedState := toastDamageState([]domain.Notification{two, one, three}, nil, false)
50+ stackedState.overlays.noticeOverflow = 2
51+ stacked := composeFrame(stackedState, updated.cache)
52+ requireNoFullRedraw(t, stacked.damage)
53+ requireToastFootprintsDamaged(t, stacked.damage, updated.cache.toastFootprints)
54+ requireToastFootprintsDamaged(t, stacked.damage, stacked.cache.toastFootprints)
55+ replayToastFrame(t, stream, terminal, stacked)
56+
57+ // Coalescing changes the title count in place. Both old and new coverage is
58+ // damaged even where their geometry happens to be identical.
59+ coalescedTwo := two
60+ coalescedTwo.Count = 12
61+ coalescedState := toastDamageState([]domain.Notification{coalescedTwo, one, three}, nil, false)
62+ coalescedState.overlays.noticeOverflow = 2
63+ coalesced := composeFrame(coalescedState, stacked.cache)
64+ requireNoFullRedraw(t, coalesced.damage)
65+ requireToastFootprintsDamaged(t, coalesced.damage, stacked.cache.toastFootprints)
66+ requireToastFootprintsDamaged(t, coalesced.damage, coalesced.cache.toastFootprints)
67+ replayToastFrame(t, stream, terminal, coalesced)
68+
69+ // Dismissal and expiry have the same rendering transition: no current toast
70+ // exists, so the previous footprint alone must restore every exposed cell.
71+ for _, tt := range []struct {
72+ name string
73+ from composedRenderFrame
74+ }{
75+ {name: "dismiss", from: coalesced},
76+ {name: "expiry", from: shown},
77+ } {
78+ t.Run(tt.name, func(t *testing.T) {
79+ cleared := composeFrame(toastDamageState(nil, nil, false), tt.from.cache)
80+ requireNoFullRedraw(t, cleared.damage)
81+ requireToastFootprintsDamaged(t, cleared.damage, tt.from.cache.toastFootprints)
82+ require.Empty(t, cleared.cache.toastFootprints)
83+ })
84+ }
85+
86+ cleared := composeFrame(toastDamageState(nil, nil, false), coalesced.cache)
87+ replayToastFrame(t, stream, terminal, cleared)
88+ require.Equal(t, frameRows(coalesced.cache.frame), frameRows(cleared.frame), "dismissal must restore the toast-free base without artifacts")
89+}
90+
91+func BenchmarkComposeFrameStableToastOneCellUpdate(b *testing.B) {
92+ base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{})
93+ notice := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
94+ shown := composeFrame(toastDamageState([]domain.Notification{notice}, nil, false), base.cache, composeCacheInput{})
95+ cache, scratch := shown.cache, base.cache
96+ state := toastDamageState([]domain.Notification{notice}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
97+
98+ b.ReportAllocs()
99+ for i := 0; b.Loop(); i++ {
100+ cell := renderer.Cell{Rune: rune('A' + i%26), Style: renderer.DefaultStyle()}
101+ state.panes[0].frame.Set(0, 0, cell)
102+ out := composeFrame(state, cache, scratch)
103+ if hasFullRedraw(out.damage) {
104+ b.Fatal("stable toast forced a full redraw")
105+ }
106+ scratch, cache = cache, out.cache
107+ }
108+}
109+
110+func toastDamageState(notices []domain.Notification, damage []renderer.Damage, reset bool) capturedRenderState {
111+ paneFrame := renderer.NewFrame(80, 20)
112+ for y := 0; y < paneFrame.Height; y++ {
113+ for x := 0; x < paneFrame.Width; x++ {
114+ paneFrame.Set(x, y, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
115+ }
116+ }
117+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 20}}
118+ return capturedRenderState{
119+ reset: reset,
120+ layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 20}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "toast-damage", valid: true},
121+ panes: []capturedPaneRenderState{{id: "pane", frame: paneFrame, placement: placement, focused: true, damage: damage}},
122+ overlays: capturedOverlayRenderState{notices: notices},
123+ styles: resolveStyles(nil),
124+ }
125+}
126+
127+func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {
128+ t.Helper()
129+ data, err := stream.render(composed.frame, composed.damage, composed.reset)
130+ require.NoError(t, err)
131+ terminal.Write(data)
132+ require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))
133+}
134+
135+func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) {
136+ t.Helper()
137+ for _, d := range damage {
138+ require.NotEqual(t, renderer.DamageFullRedraw, d.Kind)
139+ }
140+}
141+
142+func requireToastFootprintsDamaged(t *testing.T, damage []renderer.Damage, footprints []domain.Rect) {
143+ t.Helper()
144+ for _, footprint := range footprints {
145+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
146+ }
147+}
148+
149+func hasFullRedraw(damage []renderer.Damage) bool {
150+ for _, d := range damage {
151+ if d.Kind == renderer.DamageFullRedraw {
152+ return true
153+ }
154+ }
155+ return false
156+}
M internal/usecase/ui/notice.go +8 -3
@@ -43,11 +43,13 @@ type NoticeStyles struct {
4343 // lines. Boxes stack downward with one blank row between them; once the
4444 // count behind overflow is nonzero, a right-aligned "+N more" line follows
4545 // the last box.
46-func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) {
46+func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) []domain.Rect {
4747 if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {
48- return
48+ return nil
4949 }
5050
51+ footprints := make([]domain.Rect, 0, len(notices)+1)
52+
5153 width := frame.Width * 2 / 5
5254 if width > noticeMaxWidth {
5355 width = noticeMaxWidth
@@ -59,7 +61,7 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st
5961 width = frame.Width
6062 }
6163 if width <= 0 {
62- return
64+ return nil
6365 }
6466 x := frame.Width - noticeMargin - width
6567 y := noticeMargin
@@ -75,6 +77,7 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st
7577 }
7678 height := len(lines) + 2
7779 bounds := domain.Rect{X: x, Y: y, Width: width, Height: height}
80+ footprints = append(footprints, bounds)
7881 box := noticeBoxStyle(styles, n.Severity)
7982 DrawBox(frame, bounds, box)
8083
@@ -112,7 +115,9 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st
112115 start = x
113116 }
114117 DrawText(frame, start, y, x+width, text, styles.Text)
118+ footprints = append(footprints, domain.Rect{X: start, Y: y, Width: x + width - start, Height: 1})
115119 }
120+ return footprints
116121 }
117122
118123 func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style {
@@ -43,11 +43,13 @@ type NoticeStyles struct {
43 // lines. Boxes stack downward with one blank row between them; once the43 // lines. Boxes stack downward with one blank row between them; once the
44 // count behind overflow is nonzero, a right-aligned "+N more" line follows44 // count behind overflow is nonzero, a right-aligned "+N more" line follows
45 // the last box.45 // the last box.
46-func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) {46+func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) []domain.Rect {
47 if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {47 if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {
48- return48+ return nil
49 }49 }
50 50
51+ footprints := make([]domain.Rect, 0, len(notices)+1)
52+
51 width := frame.Width * 2 / 553 width := frame.Width * 2 / 5
52 if width > noticeMaxWidth {54 if width > noticeMaxWidth {
53 width = noticeMaxWidth55 width = noticeMaxWidth
@@ -59,7 +61,7 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st
59 width = frame.Width61 width = frame.Width
60 }62 }
61 if width <= 0 {63 if width <= 0 {
62- return64+ return nil
63 }65 }
64 x := frame.Width - noticeMargin - width66 x := frame.Width - noticeMargin - width
65 y := noticeMargin67 y := noticeMargin
@@ -75,6 +77,7 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st
75 }77 }
76 height := len(lines) + 278 height := len(lines) + 2
77 bounds := domain.Rect{X: x, Y: y, Width: width, Height: height}79 bounds := domain.Rect{X: x, Y: y, Width: width, Height: height}
80+ footprints = append(footprints, bounds)
78 box := noticeBoxStyle(styles, n.Severity)81 box := noticeBoxStyle(styles, n.Severity)
79 DrawBox(frame, bounds, box)82 DrawBox(frame, bounds, box)
80 83
@@ -112,7 +115,9 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st
112 start = x115 start = x
113 }116 }
114 DrawText(frame, start, y, x+width, text, styles.Text)117 DrawText(frame, start, y, x+width, text, styles.Text)
118+ footprints = append(footprints, domain.Rect{X: start, Y: y, Width: x + width - start, Height: 1})
115 }119 }
120+ return footprints
116 }121 }
117 122
118 func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style {123 func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style {

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