detached 668edcc

perf(daemon): compose copy-mode frames into the spare buffers (#313)

In copy mode the base frame is copied into the spare composition page with
Frame.CopyFrom instead of a fresh Clone. Plain copy viewports, scrolled or
not, are built in the spare viewport page the same way, so keyboard moves
also stop allocating a frame. Rows outside the scroll region are copied
cell by cell instead of through an allocated Frame.Row slice. The
committed cache is never written, so failed publications keep it intact.

cloneComposeCache now deep-copies the viewport frame, and a failed scroll
publication test pins that both committed frames stay unchanged.

Copy scroll: -89..-94% B/op (747 KiB -> 60 KiB at 182x53),
166 -> 138 allocs/op, -12..-14% time. Other compose benchmarks unchanged.
commit
668edccb650c6d80f6d8e25ed45311f59ba084e5
author
bnema · · 2026-10-04 16:39 UTC
committer
GitHub · · 2026-10-04 16:39 UTC
parent
8ca904f
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +44 -11
M internal/usecase/daemon/copy_scroll_delta.go +8 -4
@@ -66,9 +66,11 @@ func copyViewportDamage(previous, next copyViewportState, frame renderer.Frame)
6666 }
6767
6868 // Reuse retained compact rows instead of decoding and interning the whole
69-// immutable viewport. Both committed frames remain untouched until send succeeds.
70-func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame {
71- frame := previous.frame.Clone()
69+// immutable viewport. The result is built in spare, the alternate viewport
70+// buffer, so both committed frames remain untouched until send succeeds.
71+func composeScrolledCopyViewport(state capturedRenderState, base, spare renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame {
72+ frame := spare
73+ frame.CopyFrom(previous.frame)
7274 target := next.target
7375 scroll := damage[0]
7476 if scroll.Kind == renderer.DamageScrollDown {
@@ -78,7 +80,9 @@ func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame,
7880 }
7981 for y := range frame.Height {
8082 if y < target.Y || y >= target.Y+target.Height {
81- frame.WriteRow(y, 0, base.Row(y))
83+ for x := range frame.Width {
84+ frame.Set(x, y, base.Cell(x, y))
85+ }
8286 }
8387 }
8488 paint := func(y int, row []renderer.Cell) {
@@ -66,9 +66,11 @@ func copyViewportDamage(previous, next copyViewportState, frame renderer.Frame)
66 }66 }
67 67
68 // Reuse retained compact rows instead of decoding and interning the whole68 // Reuse retained compact rows instead of decoding and interning the whole
69-// immutable viewport. Both committed frames remain untouched until send succeeds.69+// immutable viewport. The result is built in spare, the alternate viewport
70-func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame {70+// buffer, so both committed frames remain untouched until send succeeds.
71- frame := previous.frame.Clone()71+func composeScrolledCopyViewport(state capturedRenderState, base, spare renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame {
72+ frame := spare
73+ frame.CopyFrom(previous.frame)
72 target := next.target74 target := next.target
73 scroll := damage[0]75 scroll := damage[0]
74 if scroll.Kind == renderer.DamageScrollDown {76 if scroll.Kind == renderer.DamageScrollDown {
@@ -78,7 +80,9 @@ func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame,
78 }80 }
79 for y := range frame.Height {81 for y := range frame.Height {
80 if y < target.Y || y >= target.Y+target.Height {82 if y < target.Y || y >= target.Y+target.Height {
81- frame.WriteRow(y, 0, base.Row(y))83+ for x := range frame.Width {
84+ frame.Set(x, y, base.Cell(x, y))
85+ }
82 }86 }
83 }87 }
84 paint := func(y int, row []renderer.Cell) {88 paint := func(y int, row []renderer.Cell) {
M internal/usecase/daemon/copy_scroll_replay_test.go +21 -0
@@ -66,3 +66,24 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) {
6666 })
6767 }
6868 }
69+
70+// TestCopyWheelFailedScrollKeepsCommittedFrames proves a scroll that fails to
71+// publish writes only the spare buffers: the committed base and viewport
72+// frames are byte-for-byte unchanged.
73+func TestCopyWheelFailedScrollKeepsCommittedFrames(t *testing.T) {
74+ f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200})
75+ f.d.enterCopyMode(f.sess, f.ac)
76+ for _, delta := range []int{-10, -3} {
77+ f.d.copyWheel(f.sess, f.ac, delta)
78+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
79+ }
80+ require.NotZero(t, f.ac.render.spare.copyViewport.frame.Width, "both buffers must hold a viewport page")
81+ before := cloneComposeCache(f.ac.render.cache)
82+ var compositions int
83+ f.ac.renderStages = renderStageHooks{compose: func() { compositions++ }}
84+ f.ac.replaceTransport(cacheFailTransport{})
85+ f.d.copyWheel(f.sess, f.ac, -3)
86+ f.d.paint(f.sess, f.ac, false, nil)
87+ require.Positive(t, compositions, "the failed scroll must be composed")
88+ require.Equal(t, before, cloneComposeCache(f.ac.render.cache))
89+}
@@ -66,3 +66,24 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) {
66 })66 })
67 }67 }
68 }68 }
69+
70+// TestCopyWheelFailedScrollKeepsCommittedFrames proves a scroll that fails to
71+// publish writes only the spare buffers: the committed base and viewport
72+// frames are byte-for-byte unchanged.
73+func TestCopyWheelFailedScrollKeepsCommittedFrames(t *testing.T) {
74+ f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200})
75+ f.d.enterCopyMode(f.sess, f.ac)
76+ for _, delta := range []int{-10, -3} {
77+ f.d.copyWheel(f.sess, f.ac, delta)
78+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
79+ }
80+ require.NotZero(t, f.ac.render.spare.copyViewport.frame.Width, "both buffers must hold a viewport page")
81+ before := cloneComposeCache(f.ac.render.cache)
82+ var compositions int
83+ f.ac.renderStages = renderStageHooks{compose: func() { compositions++ }}
84+ f.ac.replaceTransport(cacheFailTransport{})
85+ f.d.copyWheel(f.sess, f.ac, -3)
86+ f.d.paint(f.sess, f.ac, false, nil)
87+ require.Positive(t, compositions, "the failed scroll must be composed")
88+ require.Equal(t, before, cloneComposeCache(f.ac.render.cache))
89+}
M internal/usecase/daemon/render_pipeline.go +14 -7
@@ -102,10 +102,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
102102 }
103103 if in.frame.Width == width && in.frame.Height == frameHeight {
104104 if state.overlays.copyMode != nil {
105- // Compact Clone preserves page-local style IDs. Re-interning every
106- // cell into scratch costs more than a structural copy while scrolling.
107- // The clone still isolates committed state from failed publications.
108- frame = in.frame.Clone()
105+ // A structural copy preserves page-local style IDs. Re-interning every
106+ // cell costs more while scrolling. Copying into the spare page keeps
107+ // committed state isolated from failed publications without a new page.
108+ frame.CopyFrom(in.frame)
109109 } else {
110110 for y := 0; y < frame.Height; y++ {
111111 for x := range frame.Width {
@@ -207,9 +207,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
207207 }
208208 if overlaysActive {
209209 if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight {
210- frame = composeScrolledCopyViewport(state, baseFrame, in.copyViewport, copyViewport, scrollDamage)
210+ frame = composeScrolledCopyViewport(state, baseFrame, scratch.copyViewport.frame, in.copyViewport, copyViewport, scrollDamage)
211211 } else {
212- if !toastsVisible && !state.floating.visible {
212+ switch {
213+ case copyOnly && !toastsVisible:
214+ // A plain copy viewport becomes the next viewport cache, so build
215+ // it in the spare viewport page, which never shares baseFrame's.
216+ frame = scratch.copyViewport.frame
217+ frame.CopyFrom(baseFrame)
218+ case !toastsVisible && !state.floating.visible:
213219 frame = baseFrame.Clone()
214220 }
215221 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
@@ -241,7 +247,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
241247 cursorInputs := state.cursor
242248 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
243249 cursor := desiredCapturedCursor(cursorInputs, contentY)
244- // A plain copy viewport is painted on a clone, never into baseFrame. Keep
250+ // A plain copy viewport is painted on a clone or the spare viewport page,
251+ // never into baseFrame. Keep
245252 // that unadorned base reusable between wheel events. Modal/floating paths
246253 // retain their conservative invalidation, and copy exit requests a reset.
247254 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}
@@ -102,10 +102,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
102 }102 }
103 if in.frame.Width == width && in.frame.Height == frameHeight {103 if in.frame.Width == width && in.frame.Height == frameHeight {
104 if state.overlays.copyMode != nil {104 if state.overlays.copyMode != nil {
105- // Compact Clone preserves page-local style IDs. Re-interning every105+ // A structural copy preserves page-local style IDs. Re-interning every
106- // cell into scratch costs more than a structural copy while scrolling.106+ // cell costs more while scrolling. Copying into the spare page keeps
107- // The clone still isolates committed state from failed publications.107+ // committed state isolated from failed publications without a new page.
108- frame = in.frame.Clone()108+ frame.CopyFrom(in.frame)
109 } else {109 } else {
110 for y := 0; y < frame.Height; y++ {110 for y := 0; y < frame.Height; y++ {
111 for x := range frame.Width {111 for x := range frame.Width {
@@ -207,9 +207,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
207 }207 }
208 if overlaysActive {208 if overlaysActive {
209 if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight {209 if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight {
210- frame = composeScrolledCopyViewport(state, baseFrame, in.copyViewport, copyViewport, scrollDamage)210+ frame = composeScrolledCopyViewport(state, baseFrame, scratch.copyViewport.frame, in.copyViewport, copyViewport, scrollDamage)
211 } else {211 } else {
212- if !toastsVisible && !state.floating.visible {212+ switch {
213+ case copyOnly && !toastsVisible:
214+ // A plain copy viewport becomes the next viewport cache, so build
215+ // it in the spare viewport page, which never shares baseFrame's.
216+ frame = scratch.copyViewport.frame
217+ frame.CopyFrom(baseFrame)
218+ case !toastsVisible && !state.floating.visible:
213 frame = baseFrame.Clone()219 frame = baseFrame.Clone()
214 }220 }
215 frame, damage = composeCapturedCopyMode(state, frame, damage, content)221 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
@@ -241,7 +247,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
241 cursorInputs := state.cursor247 cursorInputs := state.cursor
242 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive248 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
243 cursor := desiredCapturedCursor(cursorInputs, contentY)249 cursor := desiredCapturedCursor(cursorInputs, contentY)
244- // A plain copy viewport is painted on a clone, never into baseFrame. Keep250+ // A plain copy viewport is painted on a clone or the spare viewport page,
251+ // never into baseFrame. Keep
245 // that unadorned base reusable between wheel events. Modal/floating paths252 // that unadorned base reusable between wheel events. Modal/floating paths
246 // retain their conservative invalidation, and copy exit requests a reset.253 // retain their conservative invalidation, and copy exit requests a reset.
247 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}254 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}
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -0
@@ -340,6 +340,7 @@ func cacheState(title string, generation uint64) capturedRenderState {
340340 func cloneComposeCache(in composeCacheInput) composeCacheInput {
341341 out := in
342342 out.frame = in.frame.Clone()
343+ out.copyViewport.frame = in.copyViewport.frame.Clone()
343344 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
344345 maps.Copy(out.titleGenerations, in.titleGenerations)
345346 out.damage = append([]renderer.Damage(nil), in.damage...)
@@ -340,6 +340,7 @@ func cacheState(title string, generation uint64) capturedRenderState {
340 func cloneComposeCache(in composeCacheInput) composeCacheInput {340 func cloneComposeCache(in composeCacheInput) composeCacheInput {
341 out := in341 out := in
342 out.frame = in.frame.Clone()342 out.frame = in.frame.Clone()
343+ out.copyViewport.frame = in.copyViewport.frame.Clone()
343 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))344 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
344 maps.Copy(out.titleGenerations, in.titleGenerations)345 maps.Copy(out.titleGenerations, in.titleGenerations)
345 out.damage = append([]renderer.Damage(nil), in.damage...)346 out.damage = append([]renderer.Damage(nil), in.damage...)

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