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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 133 | 133 | borderActive renderer.Style | |
| 134 | 134 | cache composeCacheInput | |
| 135 | 135 | 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 | 141 | func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) { | |
| @@ -146,7 +149,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 146 | 149 | cache := input.cache | |
| 147 | 150 | full := input.full | |
| 148 | 151 | ||
| 149 | - | frame := base.Clone() | |
| 152 | + | frame := copyFrameInto(input.scratch, base) | |
| 150 | 153 | applyOverlayBackdrop(frame, theme) | |
| 151 | 154 | geometry := floating.geometry.translate(content.X, content.Y) | |
| 152 | 155 | blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame) | |
| @@ -133,6 +133,9 @@ type floatingComposeInput struct { | |||||
| 133 | borderActive renderer.Style | 133 | borderActive renderer.Style | ||
| 134 | cache composeCacheInput | 134 | cache composeCacheInput | ||
| 135 | full bool | 135 | 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.cache | 149 | cache := input.cache | ||
| 147 | full := input.full | 150 | 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 | |||
| 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 | 758 | var ( | |
| 713 | 759 | benchmarkComposeSink composedRenderFrame | |
| 714 | 760 | 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 | 870 | b.ReportAllocs() | |
| 821 | 871 | b.ResetTimer() | |
| 822 | 872 | 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 composedRenderFrame | 759 | benchmarkComposeSink composedRenderFrame | ||
| 714 | benchmarkOutputSink []byte | 760 | 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 { | |||
| 48 | 48 | theme themeui.Theme | |
| 49 | 49 | styleGeneration uint64 | |
| 50 | 50 | 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 | 86 | type composedRenderFrame struct { | |
| @@ -174,8 +207,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 174 | 207 | overlaysActive := state.overlays.active() | |
| 175 | 208 | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |
| 176 | 209 | 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 | 215 | if toastsVisible { | |
| 178 | - | frame = baseFrame.Clone() | |
| 216 | + | usedOverlay = true | |
| 217 | + | frame = copyFrameInto(&transient.overlay, baseFrame) | |
| 179 | 218 | toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles) | |
| 180 | 219 | } | |
| 181 | 220 | if state.floating.visible { | |
| @@ -190,6 +229,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 190 | 229 | borderActive: styles.BorderActive, | |
| 191 | 230 | cache: in, | |
| 192 | 231 | full: full || overlaysActive, | |
| 232 | + | scratch: &transient.popup, | |
| 193 | 233 | }) | |
| 194 | 234 | damage = floatingDamage | |
| 195 | 235 | } | |
| @@ -216,12 +256,23 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 216 | 256 | frame = scratch.copyViewport.frame | |
| 217 | 257 | frame.CopyFrom(baseFrame) | |
| 218 | 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 | 264 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |
| 222 | 265 | } | |
| 223 | 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 | 276 | for x := range width { | |
| 226 | 277 | topBar[x] = baseFrame.Cell(x, 0) | |
| 227 | 278 | bottomBar[x] = baseFrame.Cell(x, rows+1) | |
| @@ -252,7 +303,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 252 | 303 | // never into baseFrame. Keep | |
| 253 | 304 | // that unadorned base reusable between wheel events. Modal/floating paths | |
| 254 | 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 | 307 | outCache.bars = barCache{top: topBar, bottom: bottomBar} | |
| 257 | 308 | if copyViewport.document != nil { | |
| 258 | 309 | copyViewport.frame = frame | |
| @@ -48,6 +48,39 @@ type composeCacheInput struct { | |||||
| 48 | theme themeui.Theme | 48 | theme themeui.Theme | ||
| 49 | styleGeneration uint64 | 49 | styleGeneration uint64 | ||
| 50 | copyViewport copyViewportState | 50 | 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 > 0 | 208 | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | ||
| 176 | var toastFootprints []domain.Rect | 209 | 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 = floatingDamage | 234 | damage = floatingDamage | ||
| 195 | } | 235 | } | ||
| @@ -216,12 +256,23 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 216 | frame = scratch.copyViewport.frame | 256 | 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. Keep | 303 | // never into baseFrame. Keep | ||
| 253 | // that unadorned base reusable between wheel events. Modal/floating paths | 304 | // 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 = frame | 309 | 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 | + | } | |||