detached eb37c83

feat(daemon): establish render capture ownership

commit
eb37c8312302fd2a738155fa5fe30918f746f165
author
brice · · 2026-07-12 05:00 UTC
parent
c405747
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +425 -23
M internal/usecase/daemon/floating_render.go +13 -7
@@ -109,16 +109,22 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
109109 // Committed popup geometry is content-relative; translate it exactly once
110110 // while holding the same lock that protects the committed state and frame.
111111 geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
112- title := p.displayTitleLocked()
113- titleGeneration := p.title.generation
114- screenDamage := p.screen.Damage()
115- blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme)
112+ previousCapture := capturedPaneRenderState{}
113+ if cache != nil {
114+ previousCapture = cache.floatingCaptured
115+ }
116+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)
117+ if cache != nil {
118+ cache.floatingCaptured = captured
119+ }
120+ p.mu.Unlock()
121+ title := captured.title
122+ titleGeneration := captured.titleGeneration
123+ blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
116124 damage := append([]renderer.Damage(nil), baseDamage...)
117- for _, d := range screenDamage {
125+ for _, d := range captured.damage {
118126 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
119127 }
120- p.screen.ClearDamage()
121- p.mu.Unlock()
122128
123129 // The committed geometry, rather than the requested config geometry, is
124130 // what was actually rendered. Track it so movement (including a same-size
@@ -109,16 +109,22 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
109 // Committed popup geometry is content-relative; translate it exactly once109 // Committed popup geometry is content-relative; translate it exactly once
110 // while holding the same lock that protects the committed state and frame.110 // while holding the same lock that protects the committed state and frame.
111 geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)111 geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
112- title := p.displayTitleLocked()112+ previousCapture := capturedPaneRenderState{}
113- titleGeneration := p.title.generation113+ if cache != nil {
114- screenDamage := p.screen.Damage()114+ previousCapture = cache.floatingCaptured
115- blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme)115+ }
116+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)
117+ if cache != nil {
118+ cache.floatingCaptured = captured
119+ }
120+ p.mu.Unlock()
121+ title := captured.title
122+ titleGeneration := captured.titleGeneration
123+ blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
116 damage := append([]renderer.Damage(nil), baseDamage...)124 damage := append([]renderer.Damage(nil), baseDamage...)
117- for _, d := range screenDamage {125+ for _, d := range captured.damage {
118 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)126 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
119 }127 }
120- p.screen.ClearDamage()
121- p.mu.Unlock()
122 128
123 // The committed geometry, rather than the requested config geometry, is129 // The committed geometry, rather than the requested config geometry, is
124 // what was actually rendered. Track it so movement (including a same-size130 // what was actually rendered. Track it so movement (including a same-size
M internal/usecase/daemon/render.go +36 -8
@@ -292,6 +292,13 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
292292 ac.sendMu.Unlock()
293293 return
294294 }
295+ // Capacity is checked before any destructive capture. The coordinator is
296+ // the normal gate, but this ownership check also protects direct resize and
297+ // test paint paths from consuming damage that cannot be emitted.
298+ if ac.output != nil && ac.output.atCapacity() {
299+ ac.sendMu.Unlock()
300+ return
301+ }
295302 if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
296303 ac.sendMu.Unlock()
297304 return
@@ -598,6 +605,7 @@ type composedFrameCache struct {
598605 floatingGeneration uint64
599606 floatingGeometry floatingGeometry
600607 floatingTitleGeneration uint64
608+ floatingCaptured capturedPaneRenderState
601609 }
602610
603611 func (c *composedFrameCache) invalidate() {
@@ -762,18 +770,42 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
762770 titleGenerations[pl.ID] = generation
763771 }
764772 }
773+ mode := damageCapturePreview
774+ if consumeDamage {
775+ mode = damageCaptureConsume
776+ }
765777 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
766778 if consumeDamage {
767779 p.mu.Lock()
768- p.screen.ClearDamage()
780+ _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode)
769781 p.mu.Unlock()
770782 }
771783 continue
772784 }
773- p.mu.Lock()
774- paneDamage := p.screen.Damage()
785+ var paneFrame renderer.Frame
786+ var paneDamage []renderer.Damage
787+ if consumeDamage {
788+ p.mu.Lock()
789+ captured := capturePaneRenderStateLocked(p, pl.Content, mode)
790+ p.mu.Unlock()
791+ paneFrame, paneDamage = captured.frame, captured.damage
792+ } else {
793+ p.mu.Lock()
794+ paneDamage = p.screen.Damage()
795+ if !cacheValid || len(paneDamage) > 0 {
796+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
797+ }
798+ for _, d := range paneDamage {
799+ localContent := pl.Content
800+ localContent.X -= area.X
801+ localContent.Y -= area.Y
802+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
803+ }
804+ p.mu.Unlock()
805+ continue
806+ }
775807 if !cacheValid || len(paneDamage) > 0 {
776- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
808+ blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
777809 }
778810 for _, d := range paneDamage {
779811 localContent := pl.Content
@@ -781,10 +813,6 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
781813 localContent.Y -= area.Y
782814 damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
783815 }
784- if consumeDamage {
785- p.screen.ClearDamage()
786- }
787- p.mu.Unlock()
788816 }
789817 return damage
790818 }
@@ -292,6 +292,13 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
292 ac.sendMu.Unlock()292 ac.sendMu.Unlock()
293 return293 return
294 }294 }
295+ // Capacity is checked before any destructive capture. The coordinator is
296+ // the normal gate, but this ownership check also protects direct resize and
297+ // test paint paths from consuming damage that cannot be emitted.
298+ if ac.output != nil && ac.output.atCapacity() {
299+ ac.sendMu.Unlock()
300+ return
301+ }
295 if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {302 if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
296 ac.sendMu.Unlock()303 ac.sendMu.Unlock()
297 return304 return
@@ -598,6 +605,7 @@ type composedFrameCache struct {
598 floatingGeneration uint64605 floatingGeneration uint64
599 floatingGeometry floatingGeometry606 floatingGeometry floatingGeometry
600 floatingTitleGeneration uint64607 floatingTitleGeneration uint64
608+ floatingCaptured capturedPaneRenderState
601 }609 }
602 610
603 func (c *composedFrameCache) invalidate() {611 func (c *composedFrameCache) invalidate() {
@@ -762,18 +770,42 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
762 titleGenerations[pl.ID] = generation770 titleGenerations[pl.ID] = generation
763 }771 }
764 }772 }
773+ mode := damageCapturePreview
774+ if consumeDamage {
775+ mode = damageCaptureConsume
776+ }
765 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {777 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
766 if consumeDamage {778 if consumeDamage {
767 p.mu.Lock()779 p.mu.Lock()
768- p.screen.ClearDamage()780+ _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode)
769 p.mu.Unlock()781 p.mu.Unlock()
770 }782 }
771 continue783 continue
772 }784 }
773- p.mu.Lock()785+ var paneFrame renderer.Frame
774- paneDamage := p.screen.Damage()786+ var paneDamage []renderer.Damage
787+ if consumeDamage {
788+ p.mu.Lock()
789+ captured := capturePaneRenderStateLocked(p, pl.Content, mode)
790+ p.mu.Unlock()
791+ paneFrame, paneDamage = captured.frame, captured.damage
792+ } else {
793+ p.mu.Lock()
794+ paneDamage = p.screen.Damage()
795+ if !cacheValid || len(paneDamage) > 0 {
796+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
797+ }
798+ for _, d := range paneDamage {
799+ localContent := pl.Content
800+ localContent.X -= area.X
801+ localContent.Y -= area.Y
802+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
803+ }
804+ p.mu.Unlock()
805+ continue
806+ }
775 if !cacheValid || len(paneDamage) > 0 {807 if !cacheValid || len(paneDamage) > 0 {
776- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)808+ blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
777 }809 }
778 for _, d := range paneDamage {810 for _, d := range paneDamage {
779 localContent := pl.Content811 localContent := pl.Content
@@ -781,10 +813,6 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
781 localContent.Y -= area.Y813 localContent.Y -= area.Y
782 damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)814 damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
783 }815 }
784- if consumeDamage {
785- p.screen.ClearDamage()
786- }
787- p.mu.Unlock()
788 }816 }
789 return damage817 return damage
790 }818 }
A internal/usecase/daemon/render_capture.go +215 -0
@@ -0,0 +1,215 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+ "github.com/bnema/vev/internal/usecase/picker"
7+ themeui "github.com/bnema/vev/internal/usecase/theme"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+// damageCaptureMode makes damage ownership explicit. Primary active-session
12+// capture is destructive; picker preview observes the same VT state without
13+// acknowledging it.
14+type damageCaptureMode uint8
15+
16+const (
17+ damageCapturePreview damageCaptureMode = iota
18+ damageCaptureConsume
19+)
20+
21+// capturedRenderState is the immutable hand-off from authoritative daemon
22+// state to composition. Every field is a value or an owned bounded copy; the
23+// composer must not retain or consult session, tab, pane, or overlay owners.
24+type capturedRenderState struct {
25+ attachment *attachedClient // identity only; never dereferenced by composition
26+ attachmentEpoch uint64
27+ reset bool
28+ layout capturedTabLayout
29+ panes []capturedPaneRenderState
30+ floating capturedFloatingRenderState
31+ bars barState
32+ theme themeui.Theme
33+ overlays capturedOverlayRenderState
34+ preview picker.Preview
35+ cursor capturedCursorInputs
36+ tabGeneration uint64
37+ floatingGeneration uint64
38+}
39+
40+type capturedTabLayout struct {
41+ area domain.Rect
42+ focus layout.PaneID
43+ placements []layout.Placement
44+ fingerprint string
45+ valid bool
46+}
47+
48+type capturedPaneRenderState struct {
49+ id layout.PaneID
50+ frame renderer.Frame
51+ rawDamage []renderer.Damage
52+ damage []renderer.Damage
53+ damageGeneration uint64
54+ title string
55+ titleGeneration uint64
56+ placement layout.Placement
57+ focused bool
58+}
59+
60+type capturedFloatingRenderState struct {
61+ visible bool
62+ pane capturedPaneRenderState
63+ geometry floatingGeometry
64+ title string
65+ generation uint64
66+ titleGeneration uint64
67+}
68+
69+type capturedOverlayRenderState struct {
70+ copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
71+ copyTarget domain.Rect
72+ copyFeedback string
73+ paletteGuidance string
74+}
75+
76+type capturedCursorInputs struct {
77+ row, col, style int
78+ hasStyle, visible, renderable, hiddenByOverlay bool
79+ content domain.Rect
80+}
81+
82+// capturePaneRenderStateLocked copies only the visible rectangle required by
83+// composition. It never copies scrollback/history and never lets the mutable
84+// VT frame's Cells or row-offset slices escape pane.mu.
85+func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptureMode) capturedPaneRenderState {
86+ return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{})
87+}
88+
89+func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {
90+ out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
91+ damage := p.screen.CaptureDamage()
92+ out.damageGeneration = damage.Generation
93+ out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
94+
95+ width := min(max(visible.Width, 0), p.screen.Frame.Width)
96+ height := min(max(visible.Height, 0), p.screen.Frame.Height)
97+ if out.frame.Width != width || out.frame.Height != height {
98+ out.frame = renderer.NewFrame(width, height)
99+ }
100+ for y := range height {
101+ copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
102+ }
103+
104+ if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
105+ out.damage = []renderer.Damage{renderer.FullRedraw()}
106+ } else {
107+ out.damage = append(out.damage[:0], damage.Damage...)
108+ }
109+ if mode == damageCaptureConsume {
110+ if !p.screen.AcknowledgeDamage(damage.Generation) {
111+ out.damage = []renderer.Damage{renderer.FullRedraw()}
112+ }
113+ }
114+ return out
115+}
116+
117+func uncertainDamage(damage []renderer.Damage, width, height int) bool {
118+ for _, d := range damage {
119+ if d.Kind == renderer.DamageFullRedraw {
120+ if d != renderer.FullRedraw() {
121+ return true
122+ }
123+ continue
124+ }
125+ if d.Kind < renderer.DamageText || d.Kind > renderer.DamageScrollUp || d.X < 0 || d.Y < 0 || d.Width <= 0 || d.Height <= 0 || d.X+d.Width > width || d.Y+d.Height > height {
126+ return true
127+ }
128+ if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
129+ return true
130+ }
131+ }
132+ return false
133+}
134+
135+// captureRenderState is the ownership boundary for a render transaction.
136+// Callers hold attachment sendMu; this function then follows session -> tab ->
137+// pane lock order. ACK-blocked primary capture returns before touching VT
138+// damage. Preview mode is always non-destructive.
139+func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) {
140+ if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) {
141+ return nil, false
142+ }
143+ sess.mu.Lock()
144+ if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) {
145+ sess.mu.Unlock()
146+ return nil, false
147+ }
148+ tb := sess.tabs[sess.active]
149+ epoch := ac.coordinatorEpoch.Load()
150+ sess.mu.Unlock()
151+
152+ bars.status.tabs = append([]statusTab(nil), bars.status.tabs...)
153+ bars.mru = append([]recentSession(nil), bars.mru...)
154+ bars.rankedRecent = append([]rankedRecent(nil), bars.rankedRecent...)
155+ preview = clonePickerPreview(preview)
156+
157+ tb.mu.Lock()
158+ defer tb.mu.Unlock()
159+ layoutSnap := solveTabLayoutLocked(tb)
160+ state := &capturedRenderState{
161+ attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme,
162+ overlays: overlays, preview: preview,
163+ layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: append([]layout.Placement(nil), layoutSnap.placements...), fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
164+ floatingGeneration: tb.floating.generation,
165+ }
166+ state.tabGeneration = uint64(len(layoutSnap.fingerprint))
167+ for _, placement := range layoutSnap.placements {
168+ p := tb.panes[placement.ID]
169+ if p == nil {
170+ continue
171+ }
172+ visible := placement.Content
173+ if placement.Collapsed {
174+ visible = domain.Rect{}
175+ }
176+ p.mu.Lock()
177+ captured := capturePaneRenderStateLocked(p, visible, mode)
178+ captured.placement = placement
179+ captured.focused = placement.ID == layoutSnap.focus
180+ if captured.focused {
181+ state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
182+ }
183+ p.mu.Unlock()
184+ translated := make([]renderer.Damage, 0, len(captured.damage))
185+ for _, damage := range captured.damage {
186+ translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
187+ }
188+ captured.damage = translated
189+ state.panes = append(state.panes, captured)
190+ }
191+ if tb.floating.state == floatingVisible && tb.floating.pane != nil {
192+ p := tb.floating.pane
193+ p.mu.Lock()
194+ geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, domain.FloatingConfig{Width: 100, Height: 100}))
195+ captured := capturePaneRenderStateLocked(p, geometry.Inner, mode)
196+ state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
197+ state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
198+ p.mu.Unlock()
199+ }
200+ return state, true
201+}
202+
203+func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
204+ style, hasStyle := p.screen.CursorStyle()
205+ hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
206+ return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
207+}
208+
209+func clonePickerPreview(in picker.Preview) picker.Preview {
210+ out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))}
211+ for i := range in.Rows {
212+ out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...)
213+ }
214+ return out
215+}
@@ -0,0 +1,215 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+ "github.com/bnema/vev/internal/usecase/picker"
7+ themeui "github.com/bnema/vev/internal/usecase/theme"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+// damageCaptureMode makes damage ownership explicit. Primary active-session
12+// capture is destructive; picker preview observes the same VT state without
13+// acknowledging it.
14+type damageCaptureMode uint8
15+
16+const (
17+ damageCapturePreview damageCaptureMode = iota
18+ damageCaptureConsume
19+)
20+
21+// capturedRenderState is the immutable hand-off from authoritative daemon
22+// state to composition. Every field is a value or an owned bounded copy; the
23+// composer must not retain or consult session, tab, pane, or overlay owners.
24+type capturedRenderState struct {
25+ attachment *attachedClient // identity only; never dereferenced by composition
26+ attachmentEpoch uint64
27+ reset bool
28+ layout capturedTabLayout
29+ panes []capturedPaneRenderState
30+ floating capturedFloatingRenderState
31+ bars barState
32+ theme themeui.Theme
33+ overlays capturedOverlayRenderState
34+ preview picker.Preview
35+ cursor capturedCursorInputs
36+ tabGeneration uint64
37+ floatingGeneration uint64
38+}
39+
40+type capturedTabLayout struct {
41+ area domain.Rect
42+ focus layout.PaneID
43+ placements []layout.Placement
44+ fingerprint string
45+ valid bool
46+}
47+
48+type capturedPaneRenderState struct {
49+ id layout.PaneID
50+ frame renderer.Frame
51+ rawDamage []renderer.Damage
52+ damage []renderer.Damage
53+ damageGeneration uint64
54+ title string
55+ titleGeneration uint64
56+ placement layout.Placement
57+ focused bool
58+}
59+
60+type capturedFloatingRenderState struct {
61+ visible bool
62+ pane capturedPaneRenderState
63+ geometry floatingGeometry
64+ title string
65+ generation uint64
66+ titleGeneration uint64
67+}
68+
69+type capturedOverlayRenderState struct {
70+ copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
71+ copyTarget domain.Rect
72+ copyFeedback string
73+ paletteGuidance string
74+}
75+
76+type capturedCursorInputs struct {
77+ row, col, style int
78+ hasStyle, visible, renderable, hiddenByOverlay bool
79+ content domain.Rect
80+}
81+
82+// capturePaneRenderStateLocked copies only the visible rectangle required by
83+// composition. It never copies scrollback/history and never lets the mutable
84+// VT frame's Cells or row-offset slices escape pane.mu.
85+func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptureMode) capturedPaneRenderState {
86+ return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{})
87+}
88+
89+func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {
90+ out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
91+ damage := p.screen.CaptureDamage()
92+ out.damageGeneration = damage.Generation
93+ out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
94+
95+ width := min(max(visible.Width, 0), p.screen.Frame.Width)
96+ height := min(max(visible.Height, 0), p.screen.Frame.Height)
97+ if out.frame.Width != width || out.frame.Height != height {
98+ out.frame = renderer.NewFrame(width, height)
99+ }
100+ for y := range height {
101+ copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
102+ }
103+
104+ if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
105+ out.damage = []renderer.Damage{renderer.FullRedraw()}
106+ } else {
107+ out.damage = append(out.damage[:0], damage.Damage...)
108+ }
109+ if mode == damageCaptureConsume {
110+ if !p.screen.AcknowledgeDamage(damage.Generation) {
111+ out.damage = []renderer.Damage{renderer.FullRedraw()}
112+ }
113+ }
114+ return out
115+}
116+
117+func uncertainDamage(damage []renderer.Damage, width, height int) bool {
118+ for _, d := range damage {
119+ if d.Kind == renderer.DamageFullRedraw {
120+ if d != renderer.FullRedraw() {
121+ return true
122+ }
123+ continue
124+ }
125+ if d.Kind < renderer.DamageText || d.Kind > renderer.DamageScrollUp || d.X < 0 || d.Y < 0 || d.Width <= 0 || d.Height <= 0 || d.X+d.Width > width || d.Y+d.Height > height {
126+ return true
127+ }
128+ if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
129+ return true
130+ }
131+ }
132+ return false
133+}
134+
135+// captureRenderState is the ownership boundary for a render transaction.
136+// Callers hold attachment sendMu; this function then follows session -> tab ->
137+// pane lock order. ACK-blocked primary capture returns before touching VT
138+// damage. Preview mode is always non-destructive.
139+func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) {
140+ if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) {
141+ return nil, false
142+ }
143+ sess.mu.Lock()
144+ if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) {
145+ sess.mu.Unlock()
146+ return nil, false
147+ }
148+ tb := sess.tabs[sess.active]
149+ epoch := ac.coordinatorEpoch.Load()
150+ sess.mu.Unlock()
151+
152+ bars.status.tabs = append([]statusTab(nil), bars.status.tabs...)
153+ bars.mru = append([]recentSession(nil), bars.mru...)
154+ bars.rankedRecent = append([]rankedRecent(nil), bars.rankedRecent...)
155+ preview = clonePickerPreview(preview)
156+
157+ tb.mu.Lock()
158+ defer tb.mu.Unlock()
159+ layoutSnap := solveTabLayoutLocked(tb)
160+ state := &capturedRenderState{
161+ attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme,
162+ overlays: overlays, preview: preview,
163+ layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: append([]layout.Placement(nil), layoutSnap.placements...), fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
164+ floatingGeneration: tb.floating.generation,
165+ }
166+ state.tabGeneration = uint64(len(layoutSnap.fingerprint))
167+ for _, placement := range layoutSnap.placements {
168+ p := tb.panes[placement.ID]
169+ if p == nil {
170+ continue
171+ }
172+ visible := placement.Content
173+ if placement.Collapsed {
174+ visible = domain.Rect{}
175+ }
176+ p.mu.Lock()
177+ captured := capturePaneRenderStateLocked(p, visible, mode)
178+ captured.placement = placement
179+ captured.focused = placement.ID == layoutSnap.focus
180+ if captured.focused {
181+ state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
182+ }
183+ p.mu.Unlock()
184+ translated := make([]renderer.Damage, 0, len(captured.damage))
185+ for _, damage := range captured.damage {
186+ translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
187+ }
188+ captured.damage = translated
189+ state.panes = append(state.panes, captured)
190+ }
191+ if tb.floating.state == floatingVisible && tb.floating.pane != nil {
192+ p := tb.floating.pane
193+ p.mu.Lock()
194+ geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, domain.FloatingConfig{Width: 100, Height: 100}))
195+ captured := capturePaneRenderStateLocked(p, geometry.Inner, mode)
196+ state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
197+ state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
198+ p.mu.Unlock()
199+ }
200+ return state, true
201+}
202+
203+func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
204+ style, hasStyle := p.screen.CursorStyle()
205+ hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
206+ return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
207+}
208+
209+func clonePickerPreview(in picker.Preview) picker.Preview {
210+ out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))}
211+ for i := range in.Rows {
212+ out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...)
213+ }
214+ return out
215+}
A internal/usecase/daemon/render_pipeline_test.go +93 -0
@@ -0,0 +1,93 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestCapturePaneRenderStateOwnsVisibleFrameAndConsumesDamage(t *testing.T) {
12+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
13+ p.screen.ClearDamage()
14+ p.screen.Write([]byte("old"))
15+
16+ p.mu.Lock()
17+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1}, damageCaptureConsume)
18+ p.mu.Unlock()
19+
20+ require.Equal(t, 3, captured.frame.Width)
21+ require.Equal(t, 1, captured.frame.Height)
22+ require.Empty(t, p.screen.Damage())
23+ p.mu.Lock()
24+ p.screen.Write([]byte("new"))
25+ p.mu.Unlock()
26+ require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
27+}
28+
29+func TestCapturePaneRenderStatePreviewIsNonDestructive(t *testing.T) {
30+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
31+ p.screen.ClearDamage()
32+ p.screen.Write([]byte("preview"))
33+
34+ p.mu.Lock()
35+ _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCapturePreview)
36+ p.mu.Unlock()
37+
38+ require.NotEmpty(t, p.screen.Damage())
39+}
40+
41+func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) {
42+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
43+ p.screen.ClearDamage()
44+ p.screen.Write([]byte("x"))
45+ p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
46+
47+ p.mu.Lock()
48+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCaptureConsume)
49+ p.mu.Unlock()
50+
51+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
52+}
53+
54+func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) {
55+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
56+ p.screen.ClearDamage()
57+ p.screen.Write([]byte("hidden"))
58+
59+ p.mu.Lock()
60+ captured := capturePaneRenderStateLocked(p, domain.Rect{}, damageCaptureConsume)
61+ p.mu.Unlock()
62+
63+ require.Zero(t, captured.frame.Width)
64+ require.Zero(t, captured.frame.Height)
65+ require.Empty(t, p.screen.Damage(), "capture owner cleans pending hidden-pane damage")
66+}
67+
68+func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
69+ pty, release := newBlockingPTY(t)
70+ t.Cleanup(release)
71+ d, sess, _, sends := newManualSessionWithPTYs(t, pty)
72+ ac := sess.client
73+ ac.sendMu.Lock()
74+ ac.output.next = ac.output.maxOutstanding
75+ ac.output.acked = 0
76+ ac.sendMu.Unlock()
77+ p := sess.tabs[0].focusedPane()
78+ p.mu.Lock()
79+ p.screen.ClearDamage()
80+ p.screen.Write([]byte("blocked"))
81+ p.mu.Unlock()
82+
83+ d.paint(sess, ac, false)
84+
85+ p.mu.Lock()
86+ require.NotEmpty(t, p.screen.Damage())
87+ p.mu.Unlock()
88+ select {
89+ case frame := <-sends:
90+ t.Fatalf("ACK-blocked paint sent %#v", frame)
91+ default:
92+ }
93+}
@@ -0,0 +1,93 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestCapturePaneRenderStateOwnsVisibleFrameAndConsumesDamage(t *testing.T) {
12+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
13+ p.screen.ClearDamage()
14+ p.screen.Write([]byte("old"))
15+
16+ p.mu.Lock()
17+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1}, damageCaptureConsume)
18+ p.mu.Unlock()
19+
20+ require.Equal(t, 3, captured.frame.Width)
21+ require.Equal(t, 1, captured.frame.Height)
22+ require.Empty(t, p.screen.Damage())
23+ p.mu.Lock()
24+ p.screen.Write([]byte("new"))
25+ p.mu.Unlock()
26+ require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
27+}
28+
29+func TestCapturePaneRenderStatePreviewIsNonDestructive(t *testing.T) {
30+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
31+ p.screen.ClearDamage()
32+ p.screen.Write([]byte("preview"))
33+
34+ p.mu.Lock()
35+ _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCapturePreview)
36+ p.mu.Unlock()
37+
38+ require.NotEmpty(t, p.screen.Damage())
39+}
40+
41+func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) {
42+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
43+ p.screen.ClearDamage()
44+ p.screen.Write([]byte("x"))
45+ p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
46+
47+ p.mu.Lock()
48+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCaptureConsume)
49+ p.mu.Unlock()
50+
51+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
52+}
53+
54+func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) {
55+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
56+ p.screen.ClearDamage()
57+ p.screen.Write([]byte("hidden"))
58+
59+ p.mu.Lock()
60+ captured := capturePaneRenderStateLocked(p, domain.Rect{}, damageCaptureConsume)
61+ p.mu.Unlock()
62+
63+ require.Zero(t, captured.frame.Width)
64+ require.Zero(t, captured.frame.Height)
65+ require.Empty(t, p.screen.Damage(), "capture owner cleans pending hidden-pane damage")
66+}
67+
68+func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
69+ pty, release := newBlockingPTY(t)
70+ t.Cleanup(release)
71+ d, sess, _, sends := newManualSessionWithPTYs(t, pty)
72+ ac := sess.client
73+ ac.sendMu.Lock()
74+ ac.output.next = ac.output.maxOutstanding
75+ ac.output.acked = 0
76+ ac.sendMu.Unlock()
77+ p := sess.tabs[0].focusedPane()
78+ p.mu.Lock()
79+ p.screen.ClearDamage()
80+ p.screen.Write([]byte("blocked"))
81+ p.mu.Unlock()
82+
83+ d.paint(sess, ac, false)
84+
85+ p.mu.Lock()
86+ require.NotEmpty(t, p.screen.Damage())
87+ p.mu.Unlock()
88+ select {
89+ case frame := <-sends:
90+ t.Fatalf("ACK-blocked paint sent %#v", frame)
91+ default:
92+ }
93+}
M pkg/vt/screen.go +35 -8
@@ -86,10 +86,11 @@ type Screen struct {
8686 defaultColorsKnown bool
8787 terminalTitle string
8888
89- damage []renderer.Damage
90- escapeBuf []byte
91- csiScratch []int
92- sgrScratch []int
89+ damage []renderer.Damage
90+ damageGeneration uint64
91+ escapeBuf []byte
92+ csiScratch []int
93+ sgrScratch []int
9394
9495 scrollTop int
9596 scrollBottom int
@@ -112,10 +113,11 @@ type Screen struct {
112113
113114 func NewScreen(width, height int) *Screen {
114115 s := &Screen{
115- Frame: renderer.NewFrame(width, height),
116- Style: renderer.DefaultStyle(),
117- damage: []renderer.Damage{renderer.FullRedraw()},
118- cursorVisible: true,
116+ Frame: renderer.NewFrame(width, height),
117+ Style: renderer.DefaultStyle(),
118+ damage: []renderer.Damage{renderer.FullRedraw()},
119+ damageGeneration: 1,
120+ cursorVisible: true,
119121 }
120122 s.resetScrollRegion()
121123 return s
@@ -162,11 +164,34 @@ func (s *Screen) Resize(width, height int) {
162164 s.fullRedraw()
163165 }
164166
167+// DamageCapture is an immutable copy of pending damage at one screen generation.
168+type DamageCapture struct {
169+ Damage []renderer.Damage
170+ Generation uint64
171+}
172+
165173 // Damage returns the current damage list. The caller must not modify the
166174 // returned slice; ClearDamage must be called after the damage is consumed.
167175 func (s *Screen) Damage() []renderer.Damage { return s.damage }
168176 func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }
169177
178+// CaptureDamage snapshots pending damage without consuming it.
179+func (s *Screen) CaptureDamage() DamageCapture {
180+ return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
181+}
182+
183+// AcknowledgeDamage consumes a capture only if no screen mutation occurred
184+// since it was taken. A stale acknowledgement conservatively requests a full
185+// redraw, ensuring intervening writes remain visible to the next capture.
186+func (s *Screen) AcknowledgeDamage(generation uint64) bool {
187+ if generation != s.damageGeneration {
188+ s.fullRedraw()
189+ return false
190+ }
191+ s.ClearDamage()
192+ return true
193+}
194+
170195 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
171196 // is currently enabled by the child process.
172197 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
@@ -599,6 +624,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
599624 }
600625
601626 func (s *Screen) record(d renderer.Damage) {
627+ s.damageGeneration++
602628 // Replace FullRedraw with the first concrete damage item.
603629 if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
604630 s.damage[0] = d
@@ -616,6 +642,7 @@ func (s *Screen) record(d renderer.Damage) {
616642 }
617643
618644 func (s *Screen) fullRedraw() {
645+ s.damageGeneration++
619646 s.damage = []renderer.Damage{renderer.FullRedraw()}
620647 }
621648
@@ -86,10 +86,11 @@ type Screen struct {
86 defaultColorsKnown bool86 defaultColorsKnown bool
87 terminalTitle string87 terminalTitle string
88 88
89- damage []renderer.Damage89+ damage []renderer.Damage
90- escapeBuf []byte90+ damageGeneration uint64
91- csiScratch []int91+ escapeBuf []byte
92- sgrScratch []int92+ csiScratch []int
93+ sgrScratch []int
93 94
94 scrollTop int95 scrollTop int
95 scrollBottom int96 scrollBottom int
@@ -112,10 +113,11 @@ type Screen struct {
112 113
113 func NewScreen(width, height int) *Screen {114 func NewScreen(width, height int) *Screen {
114 s := &Screen{115 s := &Screen{
115- Frame: renderer.NewFrame(width, height),116+ Frame: renderer.NewFrame(width, height),
116- Style: renderer.DefaultStyle(),117+ Style: renderer.DefaultStyle(),
117- damage: []renderer.Damage{renderer.FullRedraw()},118+ damage: []renderer.Damage{renderer.FullRedraw()},
118- cursorVisible: true,119+ damageGeneration: 1,
120+ cursorVisible: true,
119 }121 }
120 s.resetScrollRegion()122 s.resetScrollRegion()
121 return s123 return s
@@ -162,11 +164,34 @@ func (s *Screen) Resize(width, height int) {
162 s.fullRedraw()164 s.fullRedraw()
163 }165 }
164 166
167+// DamageCapture is an immutable copy of pending damage at one screen generation.
168+type DamageCapture struct {
169+ Damage []renderer.Damage
170+ Generation uint64
171+}
172+
165 // Damage returns the current damage list. The caller must not modify the173 // Damage returns the current damage list. The caller must not modify the
166 // returned slice; ClearDamage must be called after the damage is consumed.174 // returned slice; ClearDamage must be called after the damage is consumed.
167 func (s *Screen) Damage() []renderer.Damage { return s.damage }175 func (s *Screen) Damage() []renderer.Damage { return s.damage }
168 func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }176 func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }
169 177
178+// CaptureDamage snapshots pending damage without consuming it.
179+func (s *Screen) CaptureDamage() DamageCapture {
180+ return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
181+}
182+
183+// AcknowledgeDamage consumes a capture only if no screen mutation occurred
184+// since it was taken. A stale acknowledgement conservatively requests a full
185+// redraw, ensuring intervening writes remain visible to the next capture.
186+func (s *Screen) AcknowledgeDamage(generation uint64) bool {
187+ if generation != s.damageGeneration {
188+ s.fullRedraw()
189+ return false
190+ }
191+ s.ClearDamage()
192+ return true
193+}
194+
170 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)195 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
171 // is currently enabled by the child process.196 // is currently enabled by the child process.
172 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }197 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
@@ -599,6 +624,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
599 }624 }
600 625
601 func (s *Screen) record(d renderer.Damage) {626 func (s *Screen) record(d renderer.Damage) {
627+ s.damageGeneration++
602 // Replace FullRedraw with the first concrete damage item.628 // Replace FullRedraw with the first concrete damage item.
603 if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {629 if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
604 s.damage[0] = d630 s.damage[0] = d
@@ -616,6 +642,7 @@ func (s *Screen) record(d renderer.Damage) {
616 }642 }
617 643
618 func (s *Screen) fullRedraw() {644 func (s *Screen) fullRedraw() {
645+ s.damageGeneration++
619 s.damage = []renderer.Damage{renderer.FullRedraw()}646 s.damage = []renderer.Damage{renderer.FullRedraw()}
620 }647 }
621 648
M pkg/vt/screen_test.go +33 -0
@@ -3284,3 +3284,36 @@ func TestScreenAltScreenActiveAccessor(t *testing.T) {
32843284 t.Fatal("alt screen should be inactive after exit")
32853285 }
32863286 }
3287+
3288+func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) {
3289+ s := NewScreen(8, 2)
3290+ s.ClearDamage()
3291+ s.Write([]byte("a"))
3292+
3293+ captured := s.CaptureDamage()
3294+ require.NotEmpty(t, captured.Damage)
3295+ s.Write([]byte("b"))
3296+
3297+ require.False(t, s.AcknowledgeDamage(captured.Generation))
3298+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively")
3299+}
3300+
3301+func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) {
3302+ s := NewScreen(8, 2)
3303+ s.ClearDamage()
3304+ s.Write([]byte("a"))
3305+
3306+ captured := s.CaptureDamage()
3307+ require.True(t, s.AcknowledgeDamage(captured.Generation))
3308+ require.Empty(t, s.Damage())
3309+}
3310+
3311+func TestDamageCaptureOwnsDamageSlice(t *testing.T) {
3312+ s := NewScreen(8, 2)
3313+ s.ClearDamage()
3314+ s.Write([]byte("a"))
3315+
3316+ captured := s.CaptureDamage()
3317+ s.ClearDamage()
3318+ require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage")
3319+}
@@ -3284,3 +3284,36 @@ func TestScreenAltScreenActiveAccessor(t *testing.T) {
3284 t.Fatal("alt screen should be inactive after exit")3284 t.Fatal("alt screen should be inactive after exit")
3285 }3285 }
3286 }3286 }
3287+
3288+func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) {
3289+ s := NewScreen(8, 2)
3290+ s.ClearDamage()
3291+ s.Write([]byte("a"))
3292+
3293+ captured := s.CaptureDamage()
3294+ require.NotEmpty(t, captured.Damage)
3295+ s.Write([]byte("b"))
3296+
3297+ require.False(t, s.AcknowledgeDamage(captured.Generation))
3298+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively")
3299+}
3300+
3301+func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) {
3302+ s := NewScreen(8, 2)
3303+ s.ClearDamage()
3304+ s.Write([]byte("a"))
3305+
3306+ captured := s.CaptureDamage()
3307+ require.True(t, s.AcknowledgeDamage(captured.Generation))
3308+ require.Empty(t, s.Damage())
3309+}
3310+
3311+func TestDamageCaptureOwnsDamageSlice(t *testing.T) {
3312+ s := NewScreen(8, 2)
3313+ s.ClearDamage()
3314+ s.Write([]byte("a"))
3315+
3316+ captured := s.CaptureDamage()
3317+ s.ClearDamage()
3318+ require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage")
3319+}

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