detached d5dd60d

perf(daemon): compose toast and floating frames into reusable scratch pages

Toast, modal, and floating-terminal frames each cloned the whole base
page on every paint. They are transient: renderer.Prepare copies the
frame, so nothing references them after the publication. Carry two
scratch pages (overlay, popup) on composeCacheInput so they rotate with
the committed/spare cache pair, and fill them with Frame.CopyFrom.

The cached base frame and the copy-viewport page are never used as
scratch, so a failed publication still falls back to an untouched
committed cache. Pages unused by a composition are released.
commit
d5dd60dafefb842d0871b15f51f2d83798d9bcb6
author
brice · · 2026-10-08 07:17 UTC
committer
brice · · 2026-10-08 08:12 UTC
parent
f3a8767
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +240 -4
M internal/usecase/daemon/floating_render.go +4 -1
@@ -133,6 +133,9 @@ type floatingComposeInput struct {
133133 borderActive renderer.Style
134134 cache composeCacheInput
135135 full bool
136+ // scratch, when non-nil, is the page the popup frame is composed into. It
137+ // must not alias baseFrame's page; nil allocates a clone.
138+ scratch *renderer.Frame
136139 }
137140
138141 func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
@@ -146,7 +149,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
146149 cache := input.cache
147150 full := input.full
148151
149- frame := base.Clone()
152+ frame := copyFrameInto(input.scratch, base)
150153 applyOverlayBackdrop(frame, theme)
151154 geometry := floating.geometry.translate(content.X, content.Y)
152155 blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
@@ -133,6 +133,9 @@ type floatingComposeInput struct {
133 borderActive renderer.Style133 borderActive renderer.Style
134 cache composeCacheInput134 cache composeCacheInput
135 full bool135 full bool
136+ // scratch, when non-nil, is the page the popup frame is composed into. It
137+ // must not alias baseFrame's page; nil allocates a clone.
138+ scratch *renderer.Frame
136 }139 }
137 140
138 func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {141 func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
@@ -146,7 +149,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
146 cache := input.cache149 cache := input.cache
147 full := input.full150 full := input.full
148 151
149- frame := base.Clone()152+ frame := copyFrameInto(input.scratch, base)
150 applyOverlayBackdrop(frame, theme)153 applyOverlayBackdrop(frame, theme)
151 geometry := floating.geometry.translate(content.X, content.Y)154 geometry := floating.geometry.translate(content.X, content.Y)
152 blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)155 blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
M internal/usecase/daemon/floating_render_test.go +50 -0
@@ -709,6 +709,52 @@ func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testi
709709 }
710710 }
711711
712+// TestComposeCapturedFloatingFrameScratchReusesPopupPage pins that a primed
713+// scratch page removes the per-frame base clone, leaves the base untouched,
714+// and yields exactly the frame an unscratched composition builds.
715+func TestComposeCapturedFloatingFrameScratchReusesPopupPage(t *testing.T) {
716+ base := renderer.NewFrame(80, 24)
717+ for y := range base.Height {
718+ for x := range base.Width {
719+ base.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()})
720+ }
721+ }
722+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
723+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
724+ input := floatingComposeInput{
725+ baseFrame: base,
726+ floating: capturedFloatingRenderState{
727+ visible: true,
728+ pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
729+ geometry: geometry,
730+ title: "float",
731+ generation: 1,
732+ titleGeneration: 1,
733+ },
734+ content: content,
735+ cache: composeCacheInput{
736+ valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1,
737+ },
738+ }
739+ baseBefore := frameRows(base)
740+ want, wantDamage := composeCapturedFloatingFrame(input)
741+
742+ var popup renderer.Frame
743+ input.scratch = &popup
744+ got, gotDamage := composeCapturedFloatingFrame(input) // sizes the scratch page
745+ require.Equal(t, frameRows(want), frameRows(got))
746+ require.Equal(t, wantDamage, gotDamage)
747+ require.False(t, framesShareStorage(got, base), "popup frame must not alias the base page")
748+ require.Equal(t, baseBefore, frameRows(base), "base must stay free of popup cells")
749+
750+ allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })
751+ cloneAllocs := testing.AllocsPerRun(100, func() { benchmarkComposeSink.frame = base.Clone() })
752+ require.Less(t, allocs, cloneAllocs, "scratch composition must allocate less than a clone")
753+ if copyEnterAllocationBudgetEnabled {
754+ assertRenderByteBudget(t, func() { benchmarkComposeSink.frame, _ = composeCapturedFloatingFrame(input) }, 1<<10)
755+ }
756+}
757+
712758 var (
713759 benchmarkComposeSink composedRenderFrame
714760 benchmarkOutputSink []byte
@@ -817,6 +863,10 @@ func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
817863 },
818864 }
819865
866+ // Production passes the attachment's reusable popup page.
867+ var popup renderer.Frame
868+ input.scratch = &popup
869+
820870 b.ReportAllocs()
821871 b.ResetTimer()
822872 for b.Loop() {
@@ -709,6 +709,52 @@ func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testi
709 }709 }
710 }710 }
711 711
712+// TestComposeCapturedFloatingFrameScratchReusesPopupPage pins that a primed
713+// scratch page removes the per-frame base clone, leaves the base untouched,
714+// and yields exactly the frame an unscratched composition builds.
715+func TestComposeCapturedFloatingFrameScratchReusesPopupPage(t *testing.T) {
716+ base := renderer.NewFrame(80, 24)
717+ for y := range base.Height {
718+ for x := range base.Width {
719+ base.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()})
720+ }
721+ }
722+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
723+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
724+ input := floatingComposeInput{
725+ baseFrame: base,
726+ floating: capturedFloatingRenderState{
727+ visible: true,
728+ pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
729+ geometry: geometry,
730+ title: "float",
731+ generation: 1,
732+ titleGeneration: 1,
733+ },
734+ content: content,
735+ cache: composeCacheInput{
736+ valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1,
737+ },
738+ }
739+ baseBefore := frameRows(base)
740+ want, wantDamage := composeCapturedFloatingFrame(input)
741+
742+ var popup renderer.Frame
743+ input.scratch = &popup
744+ got, gotDamage := composeCapturedFloatingFrame(input) // sizes the scratch page
745+ require.Equal(t, frameRows(want), frameRows(got))
746+ require.Equal(t, wantDamage, gotDamage)
747+ require.False(t, framesShareStorage(got, base), "popup frame must not alias the base page")
748+ require.Equal(t, baseBefore, frameRows(base), "base must stay free of popup cells")
749+
750+ allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })
751+ cloneAllocs := testing.AllocsPerRun(100, func() { benchmarkComposeSink.frame = base.Clone() })
752+ require.Less(t, allocs, cloneAllocs, "scratch composition must allocate less than a clone")
753+ if copyEnterAllocationBudgetEnabled {
754+ assertRenderByteBudget(t, func() { benchmarkComposeSink.frame, _ = composeCapturedFloatingFrame(input) }, 1<<10)
755+ }
756+}
757+
712 var (758 var (
713 benchmarkComposeSink composedRenderFrame759 benchmarkComposeSink composedRenderFrame
714 benchmarkOutputSink []byte760 benchmarkOutputSink []byte
@@ -817,6 +863,10 @@ func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
817 },863 },
818 }864 }
819 865
866+ // Production passes the attachment's reusable popup page.
867+ var popup renderer.Frame
868+ input.scratch = &popup
869+
820 b.ReportAllocs()870 b.ReportAllocs()
821 b.ResetTimer()871 b.ResetTimer()
822 for b.Loop() {872 for b.Loop() {
M internal/usecase/daemon/render_pipeline.go +54 -3
@@ -48,6 +48,39 @@ type composeCacheInput struct {
4848 theme themeui.Theme
4949 styleGeneration uint64
5050 copyViewport copyViewportState
51+ // transient holds reusable pages for frames that are painted for one
52+ // publication only (toasts, floating popups, modals). See transientFrames.
53+ transient transientFrames
54+}
55+
56+// transientFrames are scratch pages for composed frames that never become a
57+// cache: renderer.Prepare copies the frame it is given, so once a publication
58+// finishes nothing references them. They travel with composeCacheInput through
59+// the same commit/spare rotation as the cached frame, so a page is only ever
60+// written by a composition whose input scratch owns it. Neither slot may hold
61+// a page shared with a cached frame (frame, copyViewport.frame) or with another
62+// slot; they are filled only by copyFrameInto, which allocates a fresh page.
63+type transientFrames struct {
64+ // overlay receives the toast-decorated or modal-decorated copy of the base
65+ // frame. Toasts and modals never both clone, so they share the slot.
66+ overlay renderer.Frame
67+ // popup receives the floating-terminal composition. It may start from the
68+ // overlay page (toasts under a popup), so it must be a distinct page.
69+ popup renderer.Frame
70+}
71+
72+// copyFrameInto makes *dst an independent copy of src, reusing dst's storage,
73+// and returns it. A nil dst (direct callers without scratch) allocates a clone.
74+func copyFrameInto(dst *renderer.Frame, src renderer.Frame) renderer.Frame {
75+ if dst == nil {
76+ return src.Clone()
77+ }
78+ dst.CopyFrom(src)
79+ if dst.Width != src.Width || dst.Height != src.Height {
80+ // CopyFrom leaves dst untouched for an unusable source.
81+ *dst = src.Clone()
82+ }
83+ return *dst
5184 }
5285
5386 type composedRenderFrame struct {
@@ -174,8 +207,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
174207 overlaysActive := state.overlays.active()
175208 toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
176209 var toastFootprints []domain.Rect
210+ // Toast, popup, and modal frames are transient: they live only until the
211+ // publication that consumes them, so they are composed into scratch pages
212+ // carried by the cache rotation instead of a fresh clone per frame.
213+ transient := scratch.transient
214+ usedOverlay := false
177215 if toastsVisible {
178- frame = baseFrame.Clone()
216+ usedOverlay = true
217+ frame = copyFrameInto(&transient.overlay, baseFrame)
179218 toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
180219 }
181220 if state.floating.visible {
@@ -190,6 +229,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
190229 borderActive: styles.BorderActive,
191230 cache: in,
192231 full: full || overlaysActive,
232+ scratch: &transient.popup,
193233 })
194234 damage = floatingDamage
195235 }
@@ -216,12 +256,23 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
216256 frame = scratch.copyViewport.frame
217257 frame.CopyFrom(baseFrame)
218258 case !toastsVisible && !state.floating.visible:
219- frame = baseFrame.Clone()
259+ // Not a copy viewport (that is the case above), so this frame is
260+ // never retained as viewport cache.
261+ usedOverlay = true
262+ frame = copyFrameInto(&transient.overlay, baseFrame)
220263 }
221264 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
222265 }
223266 frame, damage = composeCapturedOverlays(state, frame, damage)
224267 }
268+ // Release pages this composition did not need, so an attachment that stops
269+ // showing toasts, popups, or modals does not pin two spare frames forever.
270+ if !usedOverlay {
271+ transient.overlay = renderer.Frame{}
272+ }
273+ if !state.floating.visible {
274+ transient.popup = renderer.Frame{}
275+ }
225276 for x := range width {
226277 topBar[x] = baseFrame.Cell(x, 0)
227278 bottomBar[x] = baseFrame.Cell(x, rows+1)
@@ -252,7 +303,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
252303 // never into baseFrame. Keep
253304 // that unadorned base reusable between wheel events. Modal/floating paths
254305 // retain their conservative invalidation, and copy exit requests a reset.
255- outCache := composeCacheInput{valid: !overlaysActive || copyOnly, 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}
306+ outCache := composeCacheInput{valid: !overlaysActive || copyOnly, 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, transient: transient}
256307 outCache.bars = barCache{top: topBar, bottom: bottomBar}
257308 if copyViewport.document != nil {
258309 copyViewport.frame = frame
@@ -48,6 +48,39 @@ type composeCacheInput struct {
48 theme themeui.Theme48 theme themeui.Theme
49 styleGeneration uint6449 styleGeneration uint64
50 copyViewport copyViewportState50 copyViewport copyViewportState
51+ // transient holds reusable pages for frames that are painted for one
52+ // publication only (toasts, floating popups, modals). See transientFrames.
53+ transient transientFrames
54+}
55+
56+// transientFrames are scratch pages for composed frames that never become a
57+// cache: renderer.Prepare copies the frame it is given, so once a publication
58+// finishes nothing references them. They travel with composeCacheInput through
59+// the same commit/spare rotation as the cached frame, so a page is only ever
60+// written by a composition whose input scratch owns it. Neither slot may hold
61+// a page shared with a cached frame (frame, copyViewport.frame) or with another
62+// slot; they are filled only by copyFrameInto, which allocates a fresh page.
63+type transientFrames struct {
64+ // overlay receives the toast-decorated or modal-decorated copy of the base
65+ // frame. Toasts and modals never both clone, so they share the slot.
66+ overlay renderer.Frame
67+ // popup receives the floating-terminal composition. It may start from the
68+ // overlay page (toasts under a popup), so it must be a distinct page.
69+ popup renderer.Frame
70+}
71+
72+// copyFrameInto makes *dst an independent copy of src, reusing dst's storage,
73+// and returns it. A nil dst (direct callers without scratch) allocates a clone.
74+func copyFrameInto(dst *renderer.Frame, src renderer.Frame) renderer.Frame {
75+ if dst == nil {
76+ return src.Clone()
77+ }
78+ dst.CopyFrom(src)
79+ if dst.Width != src.Width || dst.Height != src.Height {
80+ // CopyFrom leaves dst untouched for an unusable source.
81+ *dst = src.Clone()
82+ }
83+ return *dst
51 }84 }
52 85
53 type composedRenderFrame struct {86 type composedRenderFrame struct {
@@ -174,8 +207,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
174 overlaysActive := state.overlays.active()207 overlaysActive := state.overlays.active()
175 toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0208 toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
176 var toastFootprints []domain.Rect209 var toastFootprints []domain.Rect
210+ // Toast, popup, and modal frames are transient: they live only until the
211+ // publication that consumes them, so they are composed into scratch pages
212+ // carried by the cache rotation instead of a fresh clone per frame.
213+ transient := scratch.transient
214+ usedOverlay := false
177 if toastsVisible {215 if toastsVisible {
178- frame = baseFrame.Clone()216+ usedOverlay = true
217+ frame = copyFrameInto(&transient.overlay, baseFrame)
179 toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)218 toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
180 }219 }
181 if state.floating.visible {220 if state.floating.visible {
@@ -190,6 +229,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
190 borderActive: styles.BorderActive,229 borderActive: styles.BorderActive,
191 cache: in,230 cache: in,
192 full: full || overlaysActive,231 full: full || overlaysActive,
232+ scratch: &transient.popup,
193 })233 })
194 damage = floatingDamage234 damage = floatingDamage
195 }235 }
@@ -216,12 +256,23 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
216 frame = scratch.copyViewport.frame256 frame = scratch.copyViewport.frame
217 frame.CopyFrom(baseFrame)257 frame.CopyFrom(baseFrame)
218 case !toastsVisible && !state.floating.visible:258 case !toastsVisible && !state.floating.visible:
219- frame = baseFrame.Clone()259+ // Not a copy viewport (that is the case above), so this frame is
260+ // never retained as viewport cache.
261+ usedOverlay = true
262+ frame = copyFrameInto(&transient.overlay, baseFrame)
220 }263 }
221 frame, damage = composeCapturedCopyMode(state, frame, damage, content)264 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
222 }265 }
223 frame, damage = composeCapturedOverlays(state, frame, damage)266 frame, damage = composeCapturedOverlays(state, frame, damage)
224 }267 }
268+ // Release pages this composition did not need, so an attachment that stops
269+ // showing toasts, popups, or modals does not pin two spare frames forever.
270+ if !usedOverlay {
271+ transient.overlay = renderer.Frame{}
272+ }
273+ if !state.floating.visible {
274+ transient.popup = renderer.Frame{}
275+ }
225 for x := range width {276 for x := range width {
226 topBar[x] = baseFrame.Cell(x, 0)277 topBar[x] = baseFrame.Cell(x, 0)
227 bottomBar[x] = baseFrame.Cell(x, rows+1)278 bottomBar[x] = baseFrame.Cell(x, rows+1)
@@ -252,7 +303,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
252 // never into baseFrame. Keep303 // never into baseFrame. Keep
253 // that unadorned base reusable between wheel events. Modal/floating paths304 // that unadorned base reusable between wheel events. Modal/floating paths
254 // retain their conservative invalidation, and copy exit requests a reset.305 // retain their conservative invalidation, and copy exit requests a reset.
255- outCache := composeCacheInput{valid: !overlaysActive || copyOnly, 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}306+ outCache := composeCacheInput{valid: !overlaysActive || copyOnly, 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, transient: transient}
256 outCache.bars = barCache{top: topBar, bottom: bottomBar}307 outCache.bars = barCache{top: topBar, bottom: bottomBar}
257 if copyViewport.document != nil {308 if copyViewport.document != nil {
258 copyViewport.frame = frame309 copyViewport.frame = frame
A internal/usecase/daemon/render_transient_frames_test.go +132 -0
@@ -0,0 +1,132 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// framesShareStorage reports whether writing through a is visible through b.
12+func framesShareStorage(a, b renderer.Frame) bool {
13+ if a.Width <= 0 || a.Height <= 0 || b.Width != a.Width || b.Height != a.Height {
14+ return false
15+ }
16+ orig := a.Cell(0, 0)
17+ sentinel := renderer.Cell{Rune: '§', Style: renderer.DefaultStyle()}
18+ if orig.Equal(sentinel) {
19+ sentinel.Rune = '¶'
20+ }
21+ a.Set(0, 0, sentinel)
22+ shared := b.Cell(0, 0).Equal(sentinel)
23+ a.Set(0, 0, orig)
24+ return shared
25+}
26+
27+func transientToastFloatingState(withToast, withFloating bool, seed rune) capturedRenderState {
28+ var notices []domain.Notification
29+ if withToast {
30+ notices = []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}
31+ }
32+ state := toastDamageState(notices, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
33+ state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: seed, Style: renderer.DefaultStyle()})
34+ if withFloating {
35+ content := domain.Rect{Y: 1, Width: 80, Height: 20}
36+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 60, Height: 60})
37+ state.floating = capturedFloatingRenderState{
38+ visible: true,
39+ pane: capturedPaneRenderState{frame: renderer.NewFrame(geometry.Inner.Width, geometry.Inner.Height), title: "float"},
40+ geometry: geometry,
41+ title: "float",
42+ generation: 1,
43+ }
44+ }
45+ return state
46+}
47+
48+// TestComposeFrameTransientScratchNeverAliasesCaches replays the attachment
49+// commit/spare rotation across toast, floating, and plain frames. Each result
50+// must match an independent composition without scratch, the retained cache
51+// must stay toast-free, and no handed-out frame may share a page with either
52+// cache slot (which would let a later paint corrupt committed state).
53+func TestComposeFrameTransientScratchNeverAliasesCaches(t *testing.T) {
54+ type step struct {
55+ toast, floating bool
56+ }
57+ steps := []step{
58+ {false, false}, {true, false}, {true, false}, {true, true}, {false, true},
59+ {true, true}, {true, false}, {false, false}, {true, false}, {false, true}, {true, true},
60+ }
61+ var render attachmentRenderState
62+ for i, st := range steps {
63+ seed := rune('A' + i)
64+ state := transientToastFloatingState(st.toast, st.floating, seed)
65+ state.reset = i == 0
66+ // A frame without scratch is the reference: it only ever clones.
67+ want := composeFrame(state, render.cache)
68+ got := composeFrame(state, render.cache, render.spare)
69+
70+ require.Equal(t, frameRows(want.frame), frameRows(got.frame), "step %d frame", i)
71+ require.Equal(t, frameRows(want.cache.frame), frameRows(got.cache.frame), "step %d cache", i)
72+ require.Equal(t, want.damage, got.damage, "step %d damage", i)
73+
74+ // The result is built in the spare page, so it may share that one, but it
75+ // must never touch the committed cache a failed publication falls back to.
76+ require.False(t, framesShareStorage(got.frame, render.cache.frame), "step %d: composed frame aliases the committed cache", i)
77+ require.False(t, framesShareStorage(got.cache.frame, render.cache.frame), "step %d: new cache base aliases the committed cache", i)
78+ if st.toast || st.floating {
79+ // A decorated frame lives on a transient page: never the cache base
80+ // (toast-free by contract) nor the committed or spare cache pages.
81+ require.False(t, framesShareStorage(got.frame, got.cache.frame), "step %d: decorated frame aliases the cache base", i)
82+ require.False(t, framesShareStorage(got.frame, render.spare.frame), "step %d: decorated frame aliases the spare cache", i)
83+ }
84+ if !st.toast {
85+ require.NotContains(t, frameText(got.cache.frame), "copied")
86+ }
87+ require.NotContains(t, frameText(got.cache.frame), "copied", "step %d: cache must stay toast-free", i)
88+
89+ beforeCommitted := frameRows(render.cache.frame)
90+ render.commitComposition(got.cache)
91+ if i > 0 {
92+ require.Equal(t, beforeCommitted, frameRows(render.spare.frame))
93+ }
94+ // The two slots' transient pages must be distinct from each other, from
95+ // both cache frames, and from every frame the next composition reads.
96+ pages := []renderer.Frame{render.cache.transient.overlay, render.cache.transient.popup, render.spare.transient.overlay, render.spare.transient.popup}
97+ for a := range pages {
98+ for b := a + 1; b < len(pages); b++ {
99+ require.False(t, framesShareStorage(pages[a], pages[b]), "step %d: transient pages %d and %d alias", i, a, b)
100+ }
101+ require.False(t, framesShareStorage(pages[a], render.cache.frame), "step %d: transient page %d aliases cache", i, a)
102+ require.False(t, framesShareStorage(pages[a], render.spare.frame), "step %d: transient page %d aliases spare", i, a)
103+ }
104+ }
105+}
106+
107+// TestComposeFrameStableToastReusesScratchPages pins the saving: with a primed
108+// scratch, a stable toast frame no longer clones the base page.
109+func TestComposeFrameStableToastReusesScratchPages(t *testing.T) {
110+ base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{})
111+ notice := []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}
112+ shown := composeFrame(toastDamageState(notice, nil, false), base.cache, composeCacheInput{})
113+ state := toastDamageState(notice, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
114+
115+ cache, scratch := shown.cache, base.cache
116+ // Prime both rotation slots so every page has reached its steady size.
117+ for range 3 {
118+ out := composeFrame(state, cache, scratch)
119+ scratch, cache = cache, out.cache
120+ }
121+ reused := testing.AllocsPerRun(50, func() {
122+ out := composeFrame(state, cache, scratch)
123+ scratch, cache = cache, out.cache
124+ })
125+ cloned := testing.AllocsPerRun(50, func() { benchmarkComposeSink.frame = cache.frame.Clone() })
126+ require.Less(t, reused, 30.0, "per-frame compose allocations")
127+ t.Logf("compose allocs=%v, one Clone allocs=%v", reused, cloned)
128+ out := composeFrame(state, cache, scratch)
129+ require.False(t, framesShareStorage(out.frame, out.cache.frame))
130+ require.Contains(t, frameText(out.frame), "copied")
131+ require.NotContains(t, frameText(out.cache.frame), "copied")
132+}
@@ -0,0 +1,132 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// framesShareStorage reports whether writing through a is visible through b.
12+func framesShareStorage(a, b renderer.Frame) bool {
13+ if a.Width <= 0 || a.Height <= 0 || b.Width != a.Width || b.Height != a.Height {
14+ return false
15+ }
16+ orig := a.Cell(0, 0)
17+ sentinel := renderer.Cell{Rune: '§', Style: renderer.DefaultStyle()}
18+ if orig.Equal(sentinel) {
19+ sentinel.Rune = '¶'
20+ }
21+ a.Set(0, 0, sentinel)
22+ shared := b.Cell(0, 0).Equal(sentinel)
23+ a.Set(0, 0, orig)
24+ return shared
25+}
26+
27+func transientToastFloatingState(withToast, withFloating bool, seed rune) capturedRenderState {
28+ var notices []domain.Notification
29+ if withToast {
30+ notices = []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}
31+ }
32+ state := toastDamageState(notices, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
33+ state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: seed, Style: renderer.DefaultStyle()})
34+ if withFloating {
35+ content := domain.Rect{Y: 1, Width: 80, Height: 20}
36+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 60, Height: 60})
37+ state.floating = capturedFloatingRenderState{
38+ visible: true,
39+ pane: capturedPaneRenderState{frame: renderer.NewFrame(geometry.Inner.Width, geometry.Inner.Height), title: "float"},
40+ geometry: geometry,
41+ title: "float",
42+ generation: 1,
43+ }
44+ }
45+ return state
46+}
47+
48+// TestComposeFrameTransientScratchNeverAliasesCaches replays the attachment
49+// commit/spare rotation across toast, floating, and plain frames. Each result
50+// must match an independent composition without scratch, the retained cache
51+// must stay toast-free, and no handed-out frame may share a page with either
52+// cache slot (which would let a later paint corrupt committed state).
53+func TestComposeFrameTransientScratchNeverAliasesCaches(t *testing.T) {
54+ type step struct {
55+ toast, floating bool
56+ }
57+ steps := []step{
58+ {false, false}, {true, false}, {true, false}, {true, true}, {false, true},
59+ {true, true}, {true, false}, {false, false}, {true, false}, {false, true}, {true, true},
60+ }
61+ var render attachmentRenderState
62+ for i, st := range steps {
63+ seed := rune('A' + i)
64+ state := transientToastFloatingState(st.toast, st.floating, seed)
65+ state.reset = i == 0
66+ // A frame without scratch is the reference: it only ever clones.
67+ want := composeFrame(state, render.cache)
68+ got := composeFrame(state, render.cache, render.spare)
69+
70+ require.Equal(t, frameRows(want.frame), frameRows(got.frame), "step %d frame", i)
71+ require.Equal(t, frameRows(want.cache.frame), frameRows(got.cache.frame), "step %d cache", i)
72+ require.Equal(t, want.damage, got.damage, "step %d damage", i)
73+
74+ // The result is built in the spare page, so it may share that one, but it
75+ // must never touch the committed cache a failed publication falls back to.
76+ require.False(t, framesShareStorage(got.frame, render.cache.frame), "step %d: composed frame aliases the committed cache", i)
77+ require.False(t, framesShareStorage(got.cache.frame, render.cache.frame), "step %d: new cache base aliases the committed cache", i)
78+ if st.toast || st.floating {
79+ // A decorated frame lives on a transient page: never the cache base
80+ // (toast-free by contract) nor the committed or spare cache pages.
81+ require.False(t, framesShareStorage(got.frame, got.cache.frame), "step %d: decorated frame aliases the cache base", i)
82+ require.False(t, framesShareStorage(got.frame, render.spare.frame), "step %d: decorated frame aliases the spare cache", i)
83+ }
84+ if !st.toast {
85+ require.NotContains(t, frameText(got.cache.frame), "copied")
86+ }
87+ require.NotContains(t, frameText(got.cache.frame), "copied", "step %d: cache must stay toast-free", i)
88+
89+ beforeCommitted := frameRows(render.cache.frame)
90+ render.commitComposition(got.cache)
91+ if i > 0 {
92+ require.Equal(t, beforeCommitted, frameRows(render.spare.frame))
93+ }
94+ // The two slots' transient pages must be distinct from each other, from
95+ // both cache frames, and from every frame the next composition reads.
96+ pages := []renderer.Frame{render.cache.transient.overlay, render.cache.transient.popup, render.spare.transient.overlay, render.spare.transient.popup}
97+ for a := range pages {
98+ for b := a + 1; b < len(pages); b++ {
99+ require.False(t, framesShareStorage(pages[a], pages[b]), "step %d: transient pages %d and %d alias", i, a, b)
100+ }
101+ require.False(t, framesShareStorage(pages[a], render.cache.frame), "step %d: transient page %d aliases cache", i, a)
102+ require.False(t, framesShareStorage(pages[a], render.spare.frame), "step %d: transient page %d aliases spare", i, a)
103+ }
104+ }
105+}
106+
107+// TestComposeFrameStableToastReusesScratchPages pins the saving: with a primed
108+// scratch, a stable toast frame no longer clones the base page.
109+func TestComposeFrameStableToastReusesScratchPages(t *testing.T) {
110+ base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{})
111+ notice := []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}
112+ shown := composeFrame(toastDamageState(notice, nil, false), base.cache, composeCacheInput{})
113+ state := toastDamageState(notice, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
114+
115+ cache, scratch := shown.cache, base.cache
116+ // Prime both rotation slots so every page has reached its steady size.
117+ for range 3 {
118+ out := composeFrame(state, cache, scratch)
119+ scratch, cache = cache, out.cache
120+ }
121+ reused := testing.AllocsPerRun(50, func() {
122+ out := composeFrame(state, cache, scratch)
123+ scratch, cache = cache, out.cache
124+ })
125+ cloned := testing.AllocsPerRun(50, func() { benchmarkComposeSink.frame = cache.frame.Clone() })
126+ require.Less(t, reused, 30.0, "per-frame compose allocations")
127+ t.Logf("compose allocs=%v, one Clone allocs=%v", reused, cloned)
128+ out := composeFrame(state, cache, scratch)
129+ require.False(t, framesShareStorage(out.frame, out.cache.frame))
130+ require.Contains(t, frameText(out.frame), "copied")
131+ require.NotContains(t, frameText(out.cache.frame), "copied")
132+}

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