detached ef057e1

refactor(daemon): enforce single-owner render emission

commit
ef057e1ffec61e5f4639774c296bf976a5efce65
author
brice · · 2026-07-12 05:19 UTC
parent
1dd8eef
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +549 -391
M internal/usecase/daemon/client.go +4 -6
@@ -31,12 +31,10 @@ type attachedClient struct {
3131 // exact coordinator incarnation. paint rechecks it under sendMu.
3232 coordinatorReadyEpoch atomic.Uint64
3333 echoAck atomic.Uint64
34- bars barCache // only touched while sendMu is held
35- composed composedFrameCache // test-only legacy composition cache
36- pipelineCache composeCacheInput // only touched while sendMu is held
37- resizePaint pendingByteTimer // guarded by sendMu
38- resizePaintGeneration uint64 // guarded by sendMu
39- resizePaintPending bool // guarded by sendMu
34+ pipelineCache composeCacheInput // only touched while sendMu is held
35+ resizePaint pendingByteTimer // guarded by sendMu
36+ resizePaintGeneration uint64 // guarded by sendMu
37+ resizePaintPending bool // guarded by sendMu
4038 size domain.Size
4139 keys *keys.Router
4240 sess Guarded[*session]
@@ -31,12 +31,10 @@ type attachedClient struct {
31 // exact coordinator incarnation. paint rechecks it under sendMu.31 // exact coordinator incarnation. paint rechecks it under sendMu.
32 coordinatorReadyEpoch atomic.Uint6432 coordinatorReadyEpoch atomic.Uint64
33 echoAck atomic.Uint6433 echoAck atomic.Uint64
34- bars barCache // only touched while sendMu is held34+ pipelineCache composeCacheInput // only touched while sendMu is held
35- composed composedFrameCache // test-only legacy composition cache35+ resizePaint pendingByteTimer // guarded by sendMu
36- pipelineCache composeCacheInput // only touched while sendMu is held36+ resizePaintGeneration uint64 // guarded by sendMu
37- resizePaint pendingByteTimer // guarded by sendMu37+ resizePaintPending bool // guarded by sendMu
38- resizePaintGeneration uint64 // guarded by sendMu
39- resizePaintPending bool // guarded by sendMu
40 size domain.Size38 size domain.Size
41 keys *keys.Router39 keys *keys.Router
42 sess Guarded[*session]40 sess Guarded[*session]
M internal/usecase/daemon/floating_render.go +0 -62
@@ -85,68 +85,6 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
8585 }
8686 }
8787
88-// composeFloatingFrame applies the popup to a copy of the normal composed
89-// destination. In particular it never writes the VT screen frame: that frame
90-// is shared with the PTY reader and is the source for future renders.
91-func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
92- var frame renderer.Frame
93- if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
94- frame = cache.floatingFrame
95- for y := 0; y < base.Height; y++ {
96- copy(frame.Row(y), base.Row(y))
97- }
98- } else {
99- frame = base.Clone()
100- if cache != nil {
101- cache.floatingFrame = frame
102- }
103- }
104- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
105- // The PTY reader mutates both the frame and its damage under p.mu. Keep
106- // every read of them, including the blit, in the same critical section.
107- // In particular, Frame is not safe to retain as an alias after unlocking.
108- p.mu.Lock()
109- // Committed popup geometry is content-relative; translate it exactly once
110- // while holding the same lock that protects the committed state and frame.
111- geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
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)
124- damage := append([]renderer.Damage(nil), baseDamage...)
125- for _, d := range captured.damage {
126- damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
127- }
128-
129- // The committed geometry, rather than the requested config geometry, is
130- // what was actually rendered. Track it so movement (including a same-size
131- // resize) cannot reuse a cached popup at its former position.
132- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
133- titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
134- drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
135- if cache != nil {
136- cache.floating = p
137- cache.floatingGeneration = generation
138- cache.floatingGeometry = geometry
139- cache.floatingTitleGeneration = titleGeneration
140- }
141- if full || popupChanged {
142- return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
143- }
144- if titleChanged && geometry.Bounds.Height >= 3 {
145- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
146- }
147- return frame, damage, geometry
148-}
149-
15088 func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) {
15189 frame := base.Clone()
15290 legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
@@ -85,68 +85,6 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
85 }85 }
86 }86 }
87 87
88-// composeFloatingFrame applies the popup to a copy of the normal composed
89-// destination. In particular it never writes the VT screen frame: that frame
90-// is shared with the PTY reader and is the source for future renders.
91-func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
92- var frame renderer.Frame
93- if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
94- frame = cache.floatingFrame
95- for y := 0; y < base.Height; y++ {
96- copy(frame.Row(y), base.Row(y))
97- }
98- } else {
99- frame = base.Clone()
100- if cache != nil {
101- cache.floatingFrame = frame
102- }
103- }
104- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
105- // The PTY reader mutates both the frame and its damage under p.mu. Keep
106- // every read of them, including the blit, in the same critical section.
107- // In particular, Frame is not safe to retain as an alias after unlocking.
108- p.mu.Lock()
109- // Committed popup geometry is content-relative; translate it exactly once
110- // while holding the same lock that protects the committed state and frame.
111- geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
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)
124- damage := append([]renderer.Damage(nil), baseDamage...)
125- for _, d := range captured.damage {
126- damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
127- }
128-
129- // The committed geometry, rather than the requested config geometry, is
130- // what was actually rendered. Track it so movement (including a same-size
131- // resize) cannot reuse a cached popup at its former position.
132- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
133- titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
134- drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
135- if cache != nil {
136- cache.floating = p
137- cache.floatingGeneration = generation
138- cache.floatingGeometry = geometry
139- cache.floatingTitleGeneration = titleGeneration
140- }
141- if full || popupChanged {
142- return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
143- }
144- if titleChanged && geometry.Bounds.Height >= 3 {
145- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
146- }
147- return frame, damage, geometry
148-}
149-
150 func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) {88 func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) {
151 frame := base.Clone()89 frame := base.Clone()
152 legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}90 legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
A internal/usecase/daemon/floating_render_legacy_test.go +69 -0
@@ -0,0 +1,69 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// composeFloatingFrame applies the popup to a copy of the normal composed
10+// destination. In particular it never writes the VT screen frame: that frame
11+// is shared with the PTY reader and is the source for future renders.
12+func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
13+ var frame renderer.Frame
14+ if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
15+ frame = cache.floatingFrame
16+ for y := 0; y < base.Height; y++ {
17+ copy(frame.Row(y), base.Row(y))
18+ }
19+ } else {
20+ frame = base.Clone()
21+ if cache != nil {
22+ cache.floatingFrame = frame
23+ }
24+ }
25+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
26+ // The PTY reader mutates both the frame and its damage under p.mu. Keep
27+ // every read of them, including the blit, in the same critical section.
28+ // In particular, Frame is not safe to retain as an alias after unlocking.
29+ p.mu.Lock()
30+ // Committed popup geometry is content-relative; translate it exactly once
31+ // while holding the same lock that protects the committed state and frame.
32+ geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
33+ previousCapture := capturedPaneRenderState{}
34+ if cache != nil {
35+ previousCapture = cache.floatingCaptured
36+ }
37+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)
38+ if cache != nil {
39+ cache.floatingCaptured = captured
40+ }
41+ p.mu.Unlock()
42+ title := captured.title
43+ titleGeneration := captured.titleGeneration
44+ blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
45+ damage := append([]renderer.Damage(nil), baseDamage...)
46+ for _, d := range captured.damage {
47+ damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
48+ }
49+
50+ // The committed geometry, rather than the requested config geometry, is
51+ // what was actually rendered. Track it so movement (including a same-size
52+ // resize) cannot reuse a cached popup at its former position.
53+ popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
54+ titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
55+ drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
56+ if cache != nil {
57+ cache.floating = p
58+ cache.floatingGeneration = generation
59+ cache.floatingGeometry = geometry
60+ cache.floatingTitleGeneration = titleGeneration
61+ }
62+ if full || popupChanged {
63+ return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
64+ }
65+ if titleChanged && geometry.Bounds.Height >= 3 {
66+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
67+ }
68+ return frame, damage, geometry
69+}
@@ -0,0 +1,69 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// composeFloatingFrame applies the popup to a copy of the normal composed
10+// destination. In particular it never writes the VT screen frame: that frame
11+// is shared with the PTY reader and is the source for future renders.
12+func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
13+ var frame renderer.Frame
14+ if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
15+ frame = cache.floatingFrame
16+ for y := 0; y < base.Height; y++ {
17+ copy(frame.Row(y), base.Row(y))
18+ }
19+ } else {
20+ frame = base.Clone()
21+ if cache != nil {
22+ cache.floatingFrame = frame
23+ }
24+ }
25+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
26+ // The PTY reader mutates both the frame and its damage under p.mu. Keep
27+ // every read of them, including the blit, in the same critical section.
28+ // In particular, Frame is not safe to retain as an alias after unlocking.
29+ p.mu.Lock()
30+ // Committed popup geometry is content-relative; translate it exactly once
31+ // while holding the same lock that protects the committed state and frame.
32+ geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
33+ previousCapture := capturedPaneRenderState{}
34+ if cache != nil {
35+ previousCapture = cache.floatingCaptured
36+ }
37+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)
38+ if cache != nil {
39+ cache.floatingCaptured = captured
40+ }
41+ p.mu.Unlock()
42+ title := captured.title
43+ titleGeneration := captured.titleGeneration
44+ blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
45+ damage := append([]renderer.Damage(nil), baseDamage...)
46+ for _, d := range captured.damage {
47+ damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
48+ }
49+
50+ // The committed geometry, rather than the requested config geometry, is
51+ // what was actually rendered. Track it so movement (including a same-size
52+ // resize) cannot reuse a cached popup at its former position.
53+ popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
54+ titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
55+ drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
56+ if cache != nil {
57+ cache.floating = p
58+ cache.floatingGeneration = generation
59+ cache.floatingGeometry = geometry
60+ cache.floatingTitleGeneration = titleGeneration
61+ }
62+ if full || popupChanged {
63+ return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
64+ }
65+ if titleChanged && geometry.Bounds.Height >= 3 {
66+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
67+ }
68+ return frame, damage, geometry
69+}
M internal/usecase/daemon/picker.go +44 -20
@@ -9,7 +9,6 @@ import (
99 "github.com/bnema/vev/internal/usecase/keys"
1010 "github.com/bnema/vev/internal/usecase/layout"
1111 "github.com/bnema/vev/internal/usecase/picker"
12- themeui "github.com/bnema/vev/internal/usecase/theme"
1312 "github.com/bnema/vev/internal/usecase/ui"
1413 "github.com/bnema/vev/pkg/renderer"
1514 )
@@ -517,29 +516,54 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
517516 return picker.Preview{}
518517 }
519518 tb.mu.Lock()
520- defer tb.mu.Unlock()
521- return pickerPreviewFromLockedTab(tb)
519+ layoutSnap := solveTabLayoutLocked(tb)
520+ if !layoutSnap.ok {
521+ p := tb.focusedPane()
522+ if p == nil {
523+ tb.mu.Unlock()
524+ return picker.Preview{}
525+ }
526+ p.mu.Lock()
527+ preview := pickerPreviewFromLockedPane(p)
528+ p.mu.Unlock()
529+ tb.mu.Unlock()
530+ return preview
531+ }
532+ state := capturedRenderState{layout: capturedTabLayout{
533+ root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus,
534+ placements: append([]layout.Placement(nil), layoutSnap.placements...),
535+ fingerprint: layoutSnap.fingerprint, valid: true,
536+ }}
537+ for _, placement := range layoutSnap.placements {
538+ p := tb.panes[placement.ID]
539+ if p == nil {
540+ continue
541+ }
542+ visible := placement.Content
543+ if placement.Collapsed {
544+ visible = domain.Rect{}
545+ }
546+ p.mu.Lock()
547+ captured := capturePaneRenderStateLocked(p, visible, damageCapturePreview)
548+ p.mu.Unlock()
549+ captured.placement = placement
550+ captured.focused = placement.ID == layoutSnap.focus
551+ state.panes = append(state.panes, captured)
552+ }
553+ tb.mu.Unlock()
554+ return pickerPreviewFromCapturedRender(state)
522555 }
523556
524-func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
525- p := tb.focusedPane()
526- if p == nil {
527- return picker.Preview{}
528- }
529- if tb.tree == nil || tb.tree.Root == nil || tb.tree.Root.Kind == layout.Leaf {
530- p.mu.Lock()
531- defer p.mu.Unlock()
532- return pickerPreviewFromLockedPane(p)
557+func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
558+ composed := composeFrame(state, composeCacheInput{}).frame
559+ if composed.Height < 2 {
560+ return pickerPreviewFromFrame(composed)
533561 }
534-
535- area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
536- if area.Width <= 0 || area.Height <= 0 {
537- p.mu.Lock()
538- defer p.mu.Unlock()
539- return pickerPreviewFromLockedPane(p)
562+ rows := make([][]renderer.Cell, composed.Height-2)
563+ for y := range rows {
564+ rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...)
540565 }
541- frame, _ := composeTabFrame(tb, area, themeui.Theme{})
542- return pickerPreviewFromFrame(frame)
566+ return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
543567 }
544568
545569 func pickerPreviewFromLockedPane(p *pane) picker.Preview {
@@ -9,7 +9,6 @@ import (
9 "github.com/bnema/vev/internal/usecase/keys"9 "github.com/bnema/vev/internal/usecase/keys"
10 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
12- themeui "github.com/bnema/vev/internal/usecase/theme"
13 "github.com/bnema/vev/internal/usecase/ui"12 "github.com/bnema/vev/internal/usecase/ui"
14 "github.com/bnema/vev/pkg/renderer"13 "github.com/bnema/vev/pkg/renderer"
15 )14 )
@@ -517,29 +516,54 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
517 return picker.Preview{}516 return picker.Preview{}
518 }517 }
519 tb.mu.Lock()518 tb.mu.Lock()
520- defer tb.mu.Unlock()519+ layoutSnap := solveTabLayoutLocked(tb)
521- return pickerPreviewFromLockedTab(tb)520+ if !layoutSnap.ok {
521+ p := tb.focusedPane()
522+ if p == nil {
523+ tb.mu.Unlock()
524+ return picker.Preview{}
525+ }
526+ p.mu.Lock()
527+ preview := pickerPreviewFromLockedPane(p)
528+ p.mu.Unlock()
529+ tb.mu.Unlock()
530+ return preview
531+ }
532+ state := capturedRenderState{layout: capturedTabLayout{
533+ root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus,
534+ placements: append([]layout.Placement(nil), layoutSnap.placements...),
535+ fingerprint: layoutSnap.fingerprint, valid: true,
536+ }}
537+ for _, placement := range layoutSnap.placements {
538+ p := tb.panes[placement.ID]
539+ if p == nil {
540+ continue
541+ }
542+ visible := placement.Content
543+ if placement.Collapsed {
544+ visible = domain.Rect{}
545+ }
546+ p.mu.Lock()
547+ captured := capturePaneRenderStateLocked(p, visible, damageCapturePreview)
548+ p.mu.Unlock()
549+ captured.placement = placement
550+ captured.focused = placement.ID == layoutSnap.focus
551+ state.panes = append(state.panes, captured)
552+ }
553+ tb.mu.Unlock()
554+ return pickerPreviewFromCapturedRender(state)
522 }555 }
523 556
524-func pickerPreviewFromLockedTab(tb *tab) picker.Preview {557+func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
525- p := tb.focusedPane()558+ composed := composeFrame(state, composeCacheInput{}).frame
526- if p == nil {559+ if composed.Height < 2 {
527- return picker.Preview{}560+ return pickerPreviewFromFrame(composed)
528- }
529- if tb.tree == nil || tb.tree.Root == nil || tb.tree.Root.Kind == layout.Leaf {
530- p.mu.Lock()
531- defer p.mu.Unlock()
532- return pickerPreviewFromLockedPane(p)
533 }561 }
534-562+ rows := make([][]renderer.Cell, composed.Height-2)
535- area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}563+ for y := range rows {
536- if area.Width <= 0 || area.Height <= 0 {564+ rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...)
537- p.mu.Lock()
538- defer p.mu.Unlock()
539- return pickerPreviewFromLockedPane(p)
540 }565 }
541- frame, _ := composeTabFrame(tb, area, themeui.Theme{})566+ return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
542- return pickerPreviewFromFrame(frame)
543 }567 }
544 568
545 func pickerPreviewFromLockedPane(p *pane) picker.Preview {569 func pickerPreviewFromLockedPane(p *pane) picker.Preview {
M internal/usecase/daemon/render.go +1 -280
@@ -8,7 +8,6 @@ import (
88 "github.com/bnema/vev/internal/ports"
99 "github.com/bnema/vev/internal/usecase/layout"
1010 themeui "github.com/bnema/vev/internal/usecase/theme"
11- "github.com/bnema/vev/internal/usecase/ui"
1211 "github.com/bnema/vev/pkg/renderer"
1312 )
1413
@@ -200,30 +199,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
200199 // paint draws the composed client frame (active tab plus status bar) and
201200 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
202201 // such as switch/create/close/rename/resize so the repaint is complete.
203-// copyTargetRectLocked maps a captured copy source into the already-composed
204-// client frame. The caller holds tb.mu, preserving the layout/floating
205-// snapshot while the rectangle is chosen.
206-func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
207- if p == nil {
208- return domain.Rect{}
209- }
210- if hasFloating && p == floating {
211- return floatingFrameGeometry.Inner
212- }
213- for _, placement := range layoutSnap.placements {
214- if placement.ID == p.id && !placement.Collapsed {
215- r := placement.Content
216- r.X += contentArea.X
217- r.Y += contentArea.Y
218- return r
219- }
220- }
221- p.mu.Lock()
222- width, height := p.screen.Frame.Width, p.screen.Frame.Height
223- p.mu.Unlock()
224- return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
225-}
226-
227202 func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
228203 if !ok {
229204 return nil
@@ -360,7 +335,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
360335 previewState := *state
361336 previewState.overlays = capturedOverlayRenderState{}
362337 previewState.reset = true
363- state.preview = pickerPreviewFromFrame(composeFrame(previewState, composeCacheInput{}).frame)
338+ state.preview = pickerPreviewFromCapturedRender(previewState)
364339 }
365340 captureOverlayLayers(state, overlays, paletteCfg)
366341 composed := composeFrame(*state, ac.pipelineCache)
@@ -430,232 +405,6 @@ func (c *barCache) Reset() {
430405 c.bottom = nil
431406 }
432407
433-type composedFrameCache struct {
434- frame renderer.Frame
435- valid bool
436- layoutSnap tabLayoutSnapshot
437- titleGenerations map[layout.PaneID]uint64
438- floating *pane
439- floatingFrame renderer.Frame
440- floatingGeneration uint64
441- floatingGeometry floatingGeometry
442- floatingTitleGeneration uint64
443- floatingCaptured capturedPaneRenderState
444-}
445-
446-func (c *composedFrameCache) invalidate() {
447- c.valid = false
448- c.floating = nil
449- c.floatingGeneration = 0
450- c.floatingGeometry = floatingGeometry{}
451- c.floatingTitleGeneration = 0
452-}
453-
454-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
455- return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
456-}
457-
458-func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
459- return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
460-}
461-
462-func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
463- var cache *barCache
464- if len(caches) > 0 {
465- cache = caches[0]
466- }
467- return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
468-}
469-
470-func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
471- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
472-}
473-
474-func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
475- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
476-}
477-
478-func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
479- p := tb.focusedPane()
480- styles := newThemeStyles(bars.theme)
481- if p == nil {
482- width, screenRows := tb.size.Cols, tb.size.Rows
483- if width <= 0 || screenRows <= 0 {
484- return renderer.NewFrame(0, 0), nil
485- }
486- return renderer.NewFrame(width, screenRows+2), nil
487- }
488- p.mu.Lock()
489- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
490- p.mu.Unlock()
491- if tb.size.Valid() {
492- width, screenRows = tb.size.Cols, tb.size.Rows
493- }
494- cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
495- layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
496- var frame renderer.Frame
497- if cacheValid {
498- frame = composed.frame
499- } else {
500- frame = renderer.NewFrame(width, screenRows+2)
501- if composed != nil {
502- full = true
503- }
504- }
505- if !layoutSame {
506- full = true
507- }
508- contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
509- if cacheValid && !layoutSame {
510- clearFrameRect(frame, contentArea)
511- }
512- topBar := frame.Row(0)
513- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
514- var titleGenerations map[layout.PaneID]uint64
515- if composed != nil {
516- if composed.titleGenerations == nil {
517- composed.titleGenerations = make(map[layout.PaneID]uint64)
518- }
519- titleGenerations = composed.titleGenerations
520- }
521- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
522- bottomBar := frame.Row(screenRows + 1)
523- drawStatusBarState(bottomBar, bars, styles)
524- if composed != nil {
525- composed.frame = frame
526- composed.valid = true
527- composed.layoutSnap = layoutSnap
528- }
529- if full {
530- if cache != nil {
531- cache.capture(topBar, bottomBar)
532- }
533- return frame, []renderer.Damage{renderer.FullRedraw()}
534- }
535- damage := translateDamage(contentDamage, 0, 1)
536- if cache == nil || !sameCells(cache.top, topBar) {
537- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
538- }
539- if cache == nil || !sameCells(cache.bottom, bottomBar) {
540- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
541- }
542- if cache != nil {
543- cache.capture(topBar, bottomBar)
544- }
545- return frame, damage
546-}
547-
548-func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
549- return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
550-}
551-
552-func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
553- frame := renderer.NewFrame(area.Width, area.Height)
554- damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
555- return frame, damage
556-}
557-
558-func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
559- return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
560-}
561-
562-func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
563- contentArea := domain.Rect{Width: area.Width, Height: area.Height}
564- root := tb.tree.Root
565- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
566- if !ok {
567- placements, ok = layout.Solve(root, contentArea)
568- }
569- var fallback *pane
570- if !ok {
571- fallback = tb.focusedPane()
572- if fallback == nil {
573- return nil
574- }
575- placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
576- }
577- // A valid cache has the same layout, so its title IDs remain valid. Reset
578- // the existing cache only when rebuilding after layout or frame churn.
579- if titleGenerations != nil && !cacheValid {
580- clear(titleGenerations)
581- }
582- if ok && !cacheValid {
583- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
584- }
585- var damage []renderer.Damage
586- for _, pl := range placements {
587- p := tb.panes[pl.ID]
588- if p == nil && fallback != nil && pl.ID == fallback.id {
589- p = fallback
590- }
591- if p == nil {
592- continue
593- }
594- focused := tb.tree.Focus == pl.ID
595- pl = offsetPlacement(pl, area.X, area.Y)
596- if pl.TitleBar.Height > 0 {
597- generation := drawPaneTitleBar(frame, pl, p, focused, theme)
598- if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
599- titleDamage := pl.TitleBar
600- titleDamage.X -= area.X
601- titleDamage.Y -= area.Y
602- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
603- }
604- if titleGenerations != nil {
605- titleGenerations[pl.ID] = generation
606- }
607- }
608- mode := damageCapturePreview
609- if consumeDamage {
610- mode = damageCaptureConsume
611- }
612- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
613- if consumeDamage {
614- p.mu.Lock()
615- _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode)
616- p.mu.Unlock()
617- }
618- continue
619- }
620- var paneFrame renderer.Frame
621- var paneDamage []renderer.Damage
622- if consumeDamage {
623- p.mu.Lock()
624- captured := capturePaneRenderStateLocked(p, pl.Content, mode)
625- p.mu.Unlock()
626- paneFrame, paneDamage = captured.frame, captured.damage
627- } else {
628- p.mu.Lock()
629- paneDamage = p.screen.Damage()
630- if !cacheValid || len(paneDamage) > 0 {
631- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
632- }
633- for _, d := range paneDamage {
634- localContent := pl.Content
635- localContent.X -= area.X
636- localContent.Y -= area.Y
637- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
638- }
639- p.mu.Unlock()
640- continue
641- }
642- if !cacheValid || len(paneDamage) > 0 {
643- blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
644- }
645- for _, d := range paneDamage {
646- localContent := pl.Content
647- localContent.X -= area.X
648- localContent.Y -= area.Y
649- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
650- }
651- }
652- return damage
653-}
654-
655-func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
656- return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
657-}
658-
659408 func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
660409 pl.Content.X += dx
661410 pl.Content.Y += dy
@@ -664,15 +413,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
664413 return pl
665414 }
666415
667-func clearFrameRect(frame renderer.Frame, r domain.Rect) {
668- blank := renderer.BlankCell()
669- for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
670- for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
671- frame.Set(x, y, blank)
672- }
673- }
674-}
675-
676416 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
677417 rows := min(r.Height, src.Height)
678418 cols := min(r.Width, src.Width)
@@ -687,25 +427,6 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
687427 }
688428 }
689429
690-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
691- styles := newThemeStyles(theme)
692- style := styles.border
693- if focused {
694- style = styles.statusBar
695- } else {
696- style = themeui.DimStyle(style, theme)
697- }
698- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
699- frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
700- }
701- p.mu.Lock()
702- title := p.displayTitleLocked()
703- generation := p.title.generation
704- p.mu.Unlock()
705- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
706- return generation
707-}
708-
709430 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
710431 if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {
711432 return
@@ -8,7 +8,6 @@ import (
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
10 themeui "github.com/bnema/vev/internal/usecase/theme"10 themeui "github.com/bnema/vev/internal/usecase/theme"
11- "github.com/bnema/vev/internal/usecase/ui"
12 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
13 )12 )
14 13
@@ -200,30 +199,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
200 // paint draws the composed client frame (active tab plus status bar) and199 // paint draws the composed client frame (active tab plus status bar) and
201 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations200 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
202 // such as switch/create/close/rename/resize so the repaint is complete.201 // such as switch/create/close/rename/resize so the repaint is complete.
203-// copyTargetRectLocked maps a captured copy source into the already-composed
204-// client frame. The caller holds tb.mu, preserving the layout/floating
205-// snapshot while the rectangle is chosen.
206-func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
207- if p == nil {
208- return domain.Rect{}
209- }
210- if hasFloating && p == floating {
211- return floatingFrameGeometry.Inner
212- }
213- for _, placement := range layoutSnap.placements {
214- if placement.ID == p.id && !placement.Collapsed {
215- r := placement.Content
216- r.X += contentArea.X
217- r.Y += contentArea.Y
218- return r
219- }
220- }
221- p.mu.Lock()
222- width, height := p.screen.Frame.Width, p.screen.Frame.Height
223- p.mu.Unlock()
224- return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
225-}
226-
227 func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {202 func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
228 if !ok {203 if !ok {
229 return nil204 return nil
@@ -360,7 +335,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
360 previewState := *state335 previewState := *state
361 previewState.overlays = capturedOverlayRenderState{}336 previewState.overlays = capturedOverlayRenderState{}
362 previewState.reset = true337 previewState.reset = true
363- state.preview = pickerPreviewFromFrame(composeFrame(previewState, composeCacheInput{}).frame)338+ state.preview = pickerPreviewFromCapturedRender(previewState)
364 }339 }
365 captureOverlayLayers(state, overlays, paletteCfg)340 captureOverlayLayers(state, overlays, paletteCfg)
366 composed := composeFrame(*state, ac.pipelineCache)341 composed := composeFrame(*state, ac.pipelineCache)
@@ -430,232 +405,6 @@ func (c *barCache) Reset() {
430 c.bottom = nil405 c.bottom = nil
431 }406 }
432 407
433-type composedFrameCache struct {
434- frame renderer.Frame
435- valid bool
436- layoutSnap tabLayoutSnapshot
437- titleGenerations map[layout.PaneID]uint64
438- floating *pane
439- floatingFrame renderer.Frame
440- floatingGeneration uint64
441- floatingGeometry floatingGeometry
442- floatingTitleGeneration uint64
443- floatingCaptured capturedPaneRenderState
444-}
445-
446-func (c *composedFrameCache) invalidate() {
447- c.valid = false
448- c.floating = nil
449- c.floatingGeneration = 0
450- c.floatingGeometry = floatingGeometry{}
451- c.floatingTitleGeneration = 0
452-}
453-
454-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
455- return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
456-}
457-
458-func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
459- return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
460-}
461-
462-func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
463- var cache *barCache
464- if len(caches) > 0 {
465- cache = caches[0]
466- }
467- return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
468-}
469-
470-func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
471- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
472-}
473-
474-func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
475- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
476-}
477-
478-func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
479- p := tb.focusedPane()
480- styles := newThemeStyles(bars.theme)
481- if p == nil {
482- width, screenRows := tb.size.Cols, tb.size.Rows
483- if width <= 0 || screenRows <= 0 {
484- return renderer.NewFrame(0, 0), nil
485- }
486- return renderer.NewFrame(width, screenRows+2), nil
487- }
488- p.mu.Lock()
489- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
490- p.mu.Unlock()
491- if tb.size.Valid() {
492- width, screenRows = tb.size.Cols, tb.size.Rows
493- }
494- cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
495- layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
496- var frame renderer.Frame
497- if cacheValid {
498- frame = composed.frame
499- } else {
500- frame = renderer.NewFrame(width, screenRows+2)
501- if composed != nil {
502- full = true
503- }
504- }
505- if !layoutSame {
506- full = true
507- }
508- contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
509- if cacheValid && !layoutSame {
510- clearFrameRect(frame, contentArea)
511- }
512- topBar := frame.Row(0)
513- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
514- var titleGenerations map[layout.PaneID]uint64
515- if composed != nil {
516- if composed.titleGenerations == nil {
517- composed.titleGenerations = make(map[layout.PaneID]uint64)
518- }
519- titleGenerations = composed.titleGenerations
520- }
521- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
522- bottomBar := frame.Row(screenRows + 1)
523- drawStatusBarState(bottomBar, bars, styles)
524- if composed != nil {
525- composed.frame = frame
526- composed.valid = true
527- composed.layoutSnap = layoutSnap
528- }
529- if full {
530- if cache != nil {
531- cache.capture(topBar, bottomBar)
532- }
533- return frame, []renderer.Damage{renderer.FullRedraw()}
534- }
535- damage := translateDamage(contentDamage, 0, 1)
536- if cache == nil || !sameCells(cache.top, topBar) {
537- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
538- }
539- if cache == nil || !sameCells(cache.bottom, bottomBar) {
540- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
541- }
542- if cache != nil {
543- cache.capture(topBar, bottomBar)
544- }
545- return frame, damage
546-}
547-
548-func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
549- return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
550-}
551-
552-func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
553- frame := renderer.NewFrame(area.Width, area.Height)
554- damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
555- return frame, damage
556-}
557-
558-func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
559- return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
560-}
561-
562-func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
563- contentArea := domain.Rect{Width: area.Width, Height: area.Height}
564- root := tb.tree.Root
565- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
566- if !ok {
567- placements, ok = layout.Solve(root, contentArea)
568- }
569- var fallback *pane
570- if !ok {
571- fallback = tb.focusedPane()
572- if fallback == nil {
573- return nil
574- }
575- placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
576- }
577- // A valid cache has the same layout, so its title IDs remain valid. Reset
578- // the existing cache only when rebuilding after layout or frame churn.
579- if titleGenerations != nil && !cacheValid {
580- clear(titleGenerations)
581- }
582- if ok && !cacheValid {
583- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
584- }
585- var damage []renderer.Damage
586- for _, pl := range placements {
587- p := tb.panes[pl.ID]
588- if p == nil && fallback != nil && pl.ID == fallback.id {
589- p = fallback
590- }
591- if p == nil {
592- continue
593- }
594- focused := tb.tree.Focus == pl.ID
595- pl = offsetPlacement(pl, area.X, area.Y)
596- if pl.TitleBar.Height > 0 {
597- generation := drawPaneTitleBar(frame, pl, p, focused, theme)
598- if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
599- titleDamage := pl.TitleBar
600- titleDamage.X -= area.X
601- titleDamage.Y -= area.Y
602- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
603- }
604- if titleGenerations != nil {
605- titleGenerations[pl.ID] = generation
606- }
607- }
608- mode := damageCapturePreview
609- if consumeDamage {
610- mode = damageCaptureConsume
611- }
612- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
613- if consumeDamage {
614- p.mu.Lock()
615- _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode)
616- p.mu.Unlock()
617- }
618- continue
619- }
620- var paneFrame renderer.Frame
621- var paneDamage []renderer.Damage
622- if consumeDamage {
623- p.mu.Lock()
624- captured := capturePaneRenderStateLocked(p, pl.Content, mode)
625- p.mu.Unlock()
626- paneFrame, paneDamage = captured.frame, captured.damage
627- } else {
628- p.mu.Lock()
629- paneDamage = p.screen.Damage()
630- if !cacheValid || len(paneDamage) > 0 {
631- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
632- }
633- for _, d := range paneDamage {
634- localContent := pl.Content
635- localContent.X -= area.X
636- localContent.Y -= area.Y
637- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
638- }
639- p.mu.Unlock()
640- continue
641- }
642- if !cacheValid || len(paneDamage) > 0 {
643- blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
644- }
645- for _, d := range paneDamage {
646- localContent := pl.Content
647- localContent.X -= area.X
648- localContent.Y -= area.Y
649- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
650- }
651- }
652- return damage
653-}
654-
655-func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
656- return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
657-}
658-
659 func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {408 func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
660 pl.Content.X += dx409 pl.Content.X += dx
661 pl.Content.Y += dy410 pl.Content.Y += dy
@@ -664,15 +413,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
664 return pl413 return pl
665 }414 }
666 415
667-func clearFrameRect(frame renderer.Frame, r domain.Rect) {
668- blank := renderer.BlankCell()
669- for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
670- for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
671- frame.Set(x, y, blank)
672- }
673- }
674-}
675-
676 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {416 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
677 rows := min(r.Height, src.Height)417 rows := min(r.Height, src.Height)
678 cols := min(r.Width, src.Width)418 cols := min(r.Width, src.Width)
@@ -687,25 +427,6 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
687 }427 }
688 }428 }
689 429
690-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
691- styles := newThemeStyles(theme)
692- style := styles.border
693- if focused {
694- style = styles.statusBar
695- } else {
696- style = themeui.DimStyle(style, theme)
697- }
698- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
699- frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
700- }
701- p.mu.Lock()
702- title := p.displayTitleLocked()
703- generation := p.title.generation
704- p.mu.Unlock()
705- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
706- return generation
707-}
708-
709 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {430 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
710 if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {431 if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {
711 return432 return
A internal/usecase/daemon/render_legacy_test.go +289 -0
@@ -0,0 +1,289 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+ themeui "github.com/bnema/vev/internal/usecase/theme"
7+ "github.com/bnema/vev/internal/usecase/ui"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+// Legacy composition adapters are test-only. Production rendering is owned by
12+// captureRenderState -> composeFrame -> emitFrame.
13+// copyTargetRectLocked maps a captured copy source into the already-composed
14+// client frame. The caller holds tb.mu, preserving the layout/floating
15+// snapshot while the rectangle is chosen.
16+func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
17+ if p == nil {
18+ return domain.Rect{}
19+ }
20+ if hasFloating && p == floating {
21+ return floatingFrameGeometry.Inner
22+ }
23+ for _, placement := range layoutSnap.placements {
24+ if placement.ID == p.id && !placement.Collapsed {
25+ r := placement.Content
26+ r.X += contentArea.X
27+ r.Y += contentArea.Y
28+ return r
29+ }
30+ }
31+ p.mu.Lock()
32+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
33+ p.mu.Unlock()
34+ return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
35+}
36+
37+type composedFrameCache struct {
38+ frame renderer.Frame
39+ valid bool
40+ layoutSnap tabLayoutSnapshot
41+ titleGenerations map[layout.PaneID]uint64
42+ floating *pane
43+ floatingFrame renderer.Frame
44+ floatingGeneration uint64
45+ floatingGeometry floatingGeometry
46+ floatingTitleGeneration uint64
47+ floatingCaptured capturedPaneRenderState
48+}
49+
50+func (c *composedFrameCache) invalidate() {
51+ c.valid = false
52+ c.floating = nil
53+ c.floatingGeneration = 0
54+ c.floatingGeometry = floatingGeometry{}
55+ c.floatingTitleGeneration = 0
56+}
57+
58+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
59+ return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
60+}
61+
62+func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
63+ return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
64+}
65+
66+func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
67+ var cache *barCache
68+ if len(caches) > 0 {
69+ cache = caches[0]
70+ }
71+ return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
72+}
73+
74+func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
75+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
76+}
77+
78+func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
79+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
80+}
81+
82+func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
83+ p := tb.focusedPane()
84+ styles := newThemeStyles(bars.theme)
85+ if p == nil {
86+ width, screenRows := tb.size.Cols, tb.size.Rows
87+ if width <= 0 || screenRows <= 0 {
88+ return renderer.NewFrame(0, 0), nil
89+ }
90+ return renderer.NewFrame(width, screenRows+2), nil
91+ }
92+ p.mu.Lock()
93+ width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
94+ p.mu.Unlock()
95+ if tb.size.Valid() {
96+ width, screenRows = tb.size.Cols, tb.size.Rows
97+ }
98+ cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
99+ layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
100+ var frame renderer.Frame
101+ if cacheValid {
102+ frame = composed.frame
103+ } else {
104+ frame = renderer.NewFrame(width, screenRows+2)
105+ if composed != nil {
106+ full = true
107+ }
108+ }
109+ if !layoutSame {
110+ full = true
111+ }
112+ contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
113+ if cacheValid && !layoutSame {
114+ clearFrameRect(frame, contentArea)
115+ }
116+ topBar := frame.Row(0)
117+ drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
118+ var titleGenerations map[layout.PaneID]uint64
119+ if composed != nil {
120+ if composed.titleGenerations == nil {
121+ composed.titleGenerations = make(map[layout.PaneID]uint64)
122+ }
123+ titleGenerations = composed.titleGenerations
124+ }
125+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
126+ bottomBar := frame.Row(screenRows + 1)
127+ drawStatusBarState(bottomBar, bars, styles)
128+ if composed != nil {
129+ composed.frame = frame
130+ composed.valid = true
131+ composed.layoutSnap = layoutSnap
132+ }
133+ if full {
134+ if cache != nil {
135+ cache.capture(topBar, bottomBar)
136+ }
137+ return frame, []renderer.Damage{renderer.FullRedraw()}
138+ }
139+ damage := translateDamage(contentDamage, 0, 1)
140+ if cache == nil || !sameCells(cache.top, topBar) {
141+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
142+ }
143+ if cache == nil || !sameCells(cache.bottom, bottomBar) {
144+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
145+ }
146+ if cache != nil {
147+ cache.capture(topBar, bottomBar)
148+ }
149+ return frame, damage
150+}
151+
152+func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
153+ return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
154+}
155+
156+func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
157+ frame := renderer.NewFrame(area.Width, area.Height)
158+ damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
159+ return frame, damage
160+}
161+
162+func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
163+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
164+}
165+
166+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
167+ contentArea := domain.Rect{Width: area.Width, Height: area.Height}
168+ root := tb.tree.Root
169+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
170+ if !ok {
171+ placements, ok = layout.Solve(root, contentArea)
172+ }
173+ var fallback *pane
174+ if !ok {
175+ fallback = tb.focusedPane()
176+ if fallback == nil {
177+ return nil
178+ }
179+ placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
180+ }
181+ // A valid cache has the same layout, so its title IDs remain valid. Reset
182+ // the existing cache only when rebuilding after layout or frame churn.
183+ if titleGenerations != nil && !cacheValid {
184+ clear(titleGenerations)
185+ }
186+ if ok && !cacheValid {
187+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
188+ }
189+ var damage []renderer.Damage
190+ for _, pl := range placements {
191+ p := tb.panes[pl.ID]
192+ if p == nil && fallback != nil && pl.ID == fallback.id {
193+ p = fallback
194+ }
195+ if p == nil {
196+ continue
197+ }
198+ focused := tb.tree.Focus == pl.ID
199+ pl = offsetPlacement(pl, area.X, area.Y)
200+ if pl.TitleBar.Height > 0 {
201+ generation := drawPaneTitleBar(frame, pl, p, focused, theme)
202+ if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
203+ titleDamage := pl.TitleBar
204+ titleDamage.X -= area.X
205+ titleDamage.Y -= area.Y
206+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
207+ }
208+ if titleGenerations != nil {
209+ titleGenerations[pl.ID] = generation
210+ }
211+ }
212+ mode := damageCapturePreview
213+ if consumeDamage {
214+ mode = damageCaptureConsume
215+ }
216+ if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
217+ if consumeDamage {
218+ p.mu.Lock()
219+ _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode)
220+ p.mu.Unlock()
221+ }
222+ continue
223+ }
224+ var paneFrame renderer.Frame
225+ var paneDamage []renderer.Damage
226+ if consumeDamage {
227+ p.mu.Lock()
228+ captured := capturePaneRenderStateLocked(p, pl.Content, mode)
229+ p.mu.Unlock()
230+ paneFrame, paneDamage = captured.frame, captured.damage
231+ } else {
232+ p.mu.Lock()
233+ paneDamage = p.screen.Damage()
234+ if !cacheValid || len(paneDamage) > 0 {
235+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
236+ }
237+ for _, d := range paneDamage {
238+ localContent := pl.Content
239+ localContent.X -= area.X
240+ localContent.Y -= area.Y
241+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
242+ }
243+ p.mu.Unlock()
244+ continue
245+ }
246+ if !cacheValid || len(paneDamage) > 0 {
247+ blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
248+ }
249+ for _, d := range paneDamage {
250+ localContent := pl.Content
251+ localContent.X -= area.X
252+ localContent.Y -= area.Y
253+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
254+ }
255+ }
256+ return damage
257+}
258+
259+func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
260+ return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
261+}
262+
263+func clearFrameRect(frame renderer.Frame, r domain.Rect) {
264+ blank := renderer.BlankCell()
265+ for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
266+ for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
267+ frame.Set(x, y, blank)
268+ }
269+ }
270+}
271+
272+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
273+ styles := newThemeStyles(theme)
274+ style := styles.border
275+ if focused {
276+ style = styles.statusBar
277+ } else {
278+ style = themeui.DimStyle(style, theme)
279+ }
280+ for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
281+ frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
282+ }
283+ p.mu.Lock()
284+ title := p.displayTitleLocked()
285+ generation := p.title.generation
286+ p.mu.Unlock()
287+ ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
288+ return generation
289+}
@@ -0,0 +1,289 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+ themeui "github.com/bnema/vev/internal/usecase/theme"
7+ "github.com/bnema/vev/internal/usecase/ui"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+// Legacy composition adapters are test-only. Production rendering is owned by
12+// captureRenderState -> composeFrame -> emitFrame.
13+// copyTargetRectLocked maps a captured copy source into the already-composed
14+// client frame. The caller holds tb.mu, preserving the layout/floating
15+// snapshot while the rectangle is chosen.
16+func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
17+ if p == nil {
18+ return domain.Rect{}
19+ }
20+ if hasFloating && p == floating {
21+ return floatingFrameGeometry.Inner
22+ }
23+ for _, placement := range layoutSnap.placements {
24+ if placement.ID == p.id && !placement.Collapsed {
25+ r := placement.Content
26+ r.X += contentArea.X
27+ r.Y += contentArea.Y
28+ return r
29+ }
30+ }
31+ p.mu.Lock()
32+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
33+ p.mu.Unlock()
34+ return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
35+}
36+
37+type composedFrameCache struct {
38+ frame renderer.Frame
39+ valid bool
40+ layoutSnap tabLayoutSnapshot
41+ titleGenerations map[layout.PaneID]uint64
42+ floating *pane
43+ floatingFrame renderer.Frame
44+ floatingGeneration uint64
45+ floatingGeometry floatingGeometry
46+ floatingTitleGeneration uint64
47+ floatingCaptured capturedPaneRenderState
48+}
49+
50+func (c *composedFrameCache) invalidate() {
51+ c.valid = false
52+ c.floating = nil
53+ c.floatingGeneration = 0
54+ c.floatingGeometry = floatingGeometry{}
55+ c.floatingTitleGeneration = 0
56+}
57+
58+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
59+ return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
60+}
61+
62+func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
63+ return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
64+}
65+
66+func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
67+ var cache *barCache
68+ if len(caches) > 0 {
69+ cache = caches[0]
70+ }
71+ return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
72+}
73+
74+func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
75+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
76+}
77+
78+func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
79+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
80+}
81+
82+func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
83+ p := tb.focusedPane()
84+ styles := newThemeStyles(bars.theme)
85+ if p == nil {
86+ width, screenRows := tb.size.Cols, tb.size.Rows
87+ if width <= 0 || screenRows <= 0 {
88+ return renderer.NewFrame(0, 0), nil
89+ }
90+ return renderer.NewFrame(width, screenRows+2), nil
91+ }
92+ p.mu.Lock()
93+ width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
94+ p.mu.Unlock()
95+ if tb.size.Valid() {
96+ width, screenRows = tb.size.Cols, tb.size.Rows
97+ }
98+ cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
99+ layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
100+ var frame renderer.Frame
101+ if cacheValid {
102+ frame = composed.frame
103+ } else {
104+ frame = renderer.NewFrame(width, screenRows+2)
105+ if composed != nil {
106+ full = true
107+ }
108+ }
109+ if !layoutSame {
110+ full = true
111+ }
112+ contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
113+ if cacheValid && !layoutSame {
114+ clearFrameRect(frame, contentArea)
115+ }
116+ topBar := frame.Row(0)
117+ drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
118+ var titleGenerations map[layout.PaneID]uint64
119+ if composed != nil {
120+ if composed.titleGenerations == nil {
121+ composed.titleGenerations = make(map[layout.PaneID]uint64)
122+ }
123+ titleGenerations = composed.titleGenerations
124+ }
125+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
126+ bottomBar := frame.Row(screenRows + 1)
127+ drawStatusBarState(bottomBar, bars, styles)
128+ if composed != nil {
129+ composed.frame = frame
130+ composed.valid = true
131+ composed.layoutSnap = layoutSnap
132+ }
133+ if full {
134+ if cache != nil {
135+ cache.capture(topBar, bottomBar)
136+ }
137+ return frame, []renderer.Damage{renderer.FullRedraw()}
138+ }
139+ damage := translateDamage(contentDamage, 0, 1)
140+ if cache == nil || !sameCells(cache.top, topBar) {
141+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
142+ }
143+ if cache == nil || !sameCells(cache.bottom, bottomBar) {
144+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
145+ }
146+ if cache != nil {
147+ cache.capture(topBar, bottomBar)
148+ }
149+ return frame, damage
150+}
151+
152+func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
153+ return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
154+}
155+
156+func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
157+ frame := renderer.NewFrame(area.Width, area.Height)
158+ damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
159+ return frame, damage
160+}
161+
162+func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
163+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
164+}
165+
166+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
167+ contentArea := domain.Rect{Width: area.Width, Height: area.Height}
168+ root := tb.tree.Root
169+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
170+ if !ok {
171+ placements, ok = layout.Solve(root, contentArea)
172+ }
173+ var fallback *pane
174+ if !ok {
175+ fallback = tb.focusedPane()
176+ if fallback == nil {
177+ return nil
178+ }
179+ placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
180+ }
181+ // A valid cache has the same layout, so its title IDs remain valid. Reset
182+ // the existing cache only when rebuilding after layout or frame churn.
183+ if titleGenerations != nil && !cacheValid {
184+ clear(titleGenerations)
185+ }
186+ if ok && !cacheValid {
187+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
188+ }
189+ var damage []renderer.Damage
190+ for _, pl := range placements {
191+ p := tb.panes[pl.ID]
192+ if p == nil && fallback != nil && pl.ID == fallback.id {
193+ p = fallback
194+ }
195+ if p == nil {
196+ continue
197+ }
198+ focused := tb.tree.Focus == pl.ID
199+ pl = offsetPlacement(pl, area.X, area.Y)
200+ if pl.TitleBar.Height > 0 {
201+ generation := drawPaneTitleBar(frame, pl, p, focused, theme)
202+ if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
203+ titleDamage := pl.TitleBar
204+ titleDamage.X -= area.X
205+ titleDamage.Y -= area.Y
206+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
207+ }
208+ if titleGenerations != nil {
209+ titleGenerations[pl.ID] = generation
210+ }
211+ }
212+ mode := damageCapturePreview
213+ if consumeDamage {
214+ mode = damageCaptureConsume
215+ }
216+ if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
217+ if consumeDamage {
218+ p.mu.Lock()
219+ _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode)
220+ p.mu.Unlock()
221+ }
222+ continue
223+ }
224+ var paneFrame renderer.Frame
225+ var paneDamage []renderer.Damage
226+ if consumeDamage {
227+ p.mu.Lock()
228+ captured := capturePaneRenderStateLocked(p, pl.Content, mode)
229+ p.mu.Unlock()
230+ paneFrame, paneDamage = captured.frame, captured.damage
231+ } else {
232+ p.mu.Lock()
233+ paneDamage = p.screen.Damage()
234+ if !cacheValid || len(paneDamage) > 0 {
235+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
236+ }
237+ for _, d := range paneDamage {
238+ localContent := pl.Content
239+ localContent.X -= area.X
240+ localContent.Y -= area.Y
241+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
242+ }
243+ p.mu.Unlock()
244+ continue
245+ }
246+ if !cacheValid || len(paneDamage) > 0 {
247+ blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
248+ }
249+ for _, d := range paneDamage {
250+ localContent := pl.Content
251+ localContent.X -= area.X
252+ localContent.Y -= area.Y
253+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
254+ }
255+ }
256+ return damage
257+}
258+
259+func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
260+ return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
261+}
262+
263+func clearFrameRect(frame renderer.Frame, r domain.Rect) {
264+ blank := renderer.BlankCell()
265+ for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
266+ for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
267+ frame.Set(x, y, blank)
268+ }
269+ }
270+}
271+
272+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
273+ styles := newThemeStyles(theme)
274+ style := styles.border
275+ if focused {
276+ style = styles.statusBar
277+ } else {
278+ style = themeui.DimStyle(style, theme)
279+ }
280+ for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
281+ frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
282+ }
283+ p.mu.Lock()
284+ title := p.displayTitleLocked()
285+ generation := p.title.generation
286+ p.mu.Unlock()
287+ ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
288+ return generation
289+}
M internal/usecase/daemon/render_pipeline.go +6 -9
@@ -86,13 +86,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
8686 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
8787 }
8888 }
89+ baseFrame := frame.Clone()
8990 frame, damage = composeCapturedOverlays(state, frame, damage, content)
9091 if full || state.overlays.active() {
9192 damage = []renderer.Damage{renderer.FullRedraw()}
9293 }
93- cursor := desiredCapturedCursor(state.cursor)
94- outCache := composeCacheInput{valid: true, frame: frame.Clone(), layoutFingerprint: state.layout.fingerprint, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration}
95- outCache.bars.capture(frame.Row(0), frame.Row(rows+1))
94+ cursorInputs := state.cursor
95+ cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
96+ cursor := desiredCapturedCursor(cursorInputs)
97+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration}
98+ outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
9699 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
97100 }
98101
@@ -270,12 +273,6 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
270273 }
271274 if sendErr == nil {
272275 ac.pipelineCache = composed.cache
273- if state.overlays.active() {
274- ac.composed.invalidate()
275- } else {
276- ac.composed.frame = composed.frame.Clone()
277- ac.composed.valid = true
278- }
279276 }
280277 ac.sess.mu.Unlock()
281278 ac.sendMu.Unlock()
@@ -86,13 +86,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
86 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})86 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
87 }87 }
88 }88 }
89+ baseFrame := frame.Clone()
89 frame, damage = composeCapturedOverlays(state, frame, damage, content)90 frame, damage = composeCapturedOverlays(state, frame, damage, content)
90 if full || state.overlays.active() {91 if full || state.overlays.active() {
91 damage = []renderer.Damage{renderer.FullRedraw()}92 damage = []renderer.Damage{renderer.FullRedraw()}
92 }93 }
93- cursor := desiredCapturedCursor(state.cursor)94+ cursorInputs := state.cursor
94- outCache := composeCacheInput{valid: true, frame: frame.Clone(), layoutFingerprint: state.layout.fingerprint, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration}95+ cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
95- outCache.bars.capture(frame.Row(0), frame.Row(rows+1))96+ cursor := desiredCapturedCursor(cursorInputs)
97+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration}
98+ outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
96 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}99 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
97 }100 }
98 101
@@ -270,12 +273,6 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
270 }273 }
271 if sendErr == nil {274 if sendErr == nil {
272 ac.pipelineCache = composed.cache275 ac.pipelineCache = composed.cache
273- if state.overlays.active() {
274- ac.composed.invalidate()
275- } else {
276- ac.composed.frame = composed.frame.Clone()
277- ac.composed.valid = true
278- }
279 }276 }
280 ac.sess.mu.Unlock()277 ac.sess.mu.Unlock()
281 ac.sendMu.Unlock()278 ac.sendMu.Unlock()
M internal/usecase/daemon/render_pipeline_test.go +123 -0
@@ -1,11 +1,16 @@
11 package daemon
22
33 import (
4+ "os"
5+ "path/filepath"
6+ "strings"
47 "testing"
58
69 "github.com/bnema/vev/internal/domain"
710 "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/internal/usecase/ui"
812 "github.com/bnema/vev/pkg/renderer"
13+ "github.com/bnema/vev/pkg/vt"
914 "github.com/stretchr/testify/require"
1015 )
1116
@@ -115,3 +120,121 @@ func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
115120 require.Equal(t, "old ", rowText(out.frame.Row(1)))
116121 require.False(t, out.cursor.hidden)
117122 }
123+
124+func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
125+ for _, name := range []string{"render.go", "floating_render.go", "picker.go"} {
126+ data, err := os.ReadFile(filepath.Join(".", name))
127+ require.NoError(t, err)
128+ source := string(data)
129+ for _, forbidden := range []string{
130+ "func composeClientFrame", "func composeTabFrame", "func composeFloatingFrame",
131+ } {
132+ require.NotContains(t, source, forbidden, "%s retains duplicate production composition path", name)
133+ }
134+ }
135+ data, err := os.ReadFile("render_pipeline.go")
136+ require.NoError(t, err)
137+ body := string(data)
138+ start := strings.Index(body, "func composeFrame(")
139+ require.NotEqual(t, -1, start)
140+ end := strings.Index(body[start:], "\nfunc ")
141+ if end > 0 {
142+ body = body[start : start+end]
143+ } else {
144+ body = body[start:]
145+ }
146+ for _, forbidden := range []string{".Lock()", ".Unlock()", ".Send(", ".prepare("} {
147+ require.NotContains(t, body, forbidden, "composeFrame must remain ownership- and transport-free")
148+ }
149+}
150+
151+func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
152+ paneFrame := renderer.NewFrame(12, 5)
153+ for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
154+ for x, r := range text {
155+ paneFrame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
156+ }
157+ }
158+ floatingFrame := renderer.NewFrame(4, 1)
159+ for x, r := range "FLOAT"[:4] {
160+ floatingFrame.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
161+ }
162+ modalInner := renderer.NewFrame(6, 1)
163+ for x, r := range "PROMPT" {
164+ modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
165+ }
166+ state := capturedRenderState{
167+ reset: true,
168+ layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
169+ panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
170+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
171+ bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
172+ overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},
173+ cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
174+ }
175+ composed := composeFrame(state, composeCacheInput{})
176+ require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))
177+ require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
178+
179+ stream := newOutputStateStream()
180+ prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
181+ require.NoError(t, err)
182+ terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
183+ require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
184+ client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
185+ client.Write(terminalBytes)
186+ require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
187+ again, err := stream.renderer.Draw(composed.frame, nil)
188+ require.NoError(t, err)
189+ require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
190+}
191+
192+func frameRows(frame renderer.Frame) []string {
193+ rows := make([]string, frame.Height)
194+ for y := range rows {
195+ rows[y] = rowText(frame.Row(y))
196+ }
197+ return rows
198+}
199+
200+func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
201+ for _, tt := range []struct {
202+ name string
203+ placement layout.Placement
204+ initial []string
205+ scrolled []string
206+ damage []renderer.Damage
207+ wantBytes string
208+ }{
209+ {name: "safe full width", placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 3}}, initial: []string{"AAAA", "BBBB", "CCCC"}, scrolled: []string{"BBBB", "CCCC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}}, wantBytes: "\x1b[0m\x1b[2;4r\x1b[4;1H\n\x1b[r\x1b[4;1HN\x1b[K\x1b[0m"},
210+ {name: "unsafe partial width", placement: layout.Placement{ID: "p", Content: domain.Rect{X: 2, Width: 2, Height: 3}}, initial: []string{"AA", "BB", "CC"}, scrolled: []string{"BB", "CC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageText, X: 2, Y: 0, Width: 2, Height: 3}}, wantBytes: "\x1b[2;3HBB\x1b[3;3HCC\x1b[4;3HN \x1b[0m"},
211+ } {
212+ t.Run(tt.name, func(t *testing.T) {
213+ makeState := func(rows []string, damage []renderer.Damage, reset bool) capturedRenderState {
214+ f := renderer.NewFrame(tt.placement.Content.Width, tt.placement.Content.Height)
215+ for y, text := range rows {
216+ for x, r := range text {
217+ f.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
218+ }
219+ }
220+ return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 3}, valid: true, focus: "p", placements: []layout.Placement{tt.placement}}, panes: []capturedPaneRenderState{{id: "p", frame: f, placement: tt.placement, focused: true, damage: damage}}, cursor: capturedCursorInputs{visible: false}}
221+ }
222+ stream := newOutputStateStream()
223+ first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
224+ firstBytes, err := stream.render(first.frame, first.damage, first.reset)
225+ require.NoError(t, err)
226+ client := vt.NewScreen(4, 5)
227+ client.Write(firstBytes)
228+
229+ second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
230+ secondBytes, err := stream.render(second.frame, second.damage, second.reset)
231+ require.NoError(t, err)
232+ require.Equal(t, tt.wantBytes, string(secondBytes))
233+ client.Write(secondBytes)
234+ require.Equal(t, frameRows(second.frame), frameRows(client.Frame))
235+ again, err := stream.renderer.Draw(second.frame, nil)
236+ require.NoError(t, err)
237+ require.Empty(t, again, "renderer shadow must equal replayed terminal state")
238+ })
239+ }
240+}
@@ -1,11 +1,16 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "os"
5+ "path/filepath"
6+ "strings"
4 "testing"7 "testing"
5 8
6 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/internal/usecase/ui"
8 "github.com/bnema/vev/pkg/renderer"12 "github.com/bnema/vev/pkg/renderer"
13+ "github.com/bnema/vev/pkg/vt"
9 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
10 )15 )
11 16
@@ -115,3 +120,121 @@ func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
115 require.Equal(t, "old ", rowText(out.frame.Row(1)))120 require.Equal(t, "old ", rowText(out.frame.Row(1)))
116 require.False(t, out.cursor.hidden)121 require.False(t, out.cursor.hidden)
117 }122 }
123+
124+func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
125+ for _, name := range []string{"render.go", "floating_render.go", "picker.go"} {
126+ data, err := os.ReadFile(filepath.Join(".", name))
127+ require.NoError(t, err)
128+ source := string(data)
129+ for _, forbidden := range []string{
130+ "func composeClientFrame", "func composeTabFrame", "func composeFloatingFrame",
131+ } {
132+ require.NotContains(t, source, forbidden, "%s retains duplicate production composition path", name)
133+ }
134+ }
135+ data, err := os.ReadFile("render_pipeline.go")
136+ require.NoError(t, err)
137+ body := string(data)
138+ start := strings.Index(body, "func composeFrame(")
139+ require.NotEqual(t, -1, start)
140+ end := strings.Index(body[start:], "\nfunc ")
141+ if end > 0 {
142+ body = body[start : start+end]
143+ } else {
144+ body = body[start:]
145+ }
146+ for _, forbidden := range []string{".Lock()", ".Unlock()", ".Send(", ".prepare("} {
147+ require.NotContains(t, body, forbidden, "composeFrame must remain ownership- and transport-free")
148+ }
149+}
150+
151+func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
152+ paneFrame := renderer.NewFrame(12, 5)
153+ for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
154+ for x, r := range text {
155+ paneFrame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
156+ }
157+ }
158+ floatingFrame := renderer.NewFrame(4, 1)
159+ for x, r := range "FLOAT"[:4] {
160+ floatingFrame.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
161+ }
162+ modalInner := renderer.NewFrame(6, 1)
163+ for x, r := range "PROMPT" {
164+ modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
165+ }
166+ state := capturedRenderState{
167+ reset: true,
168+ layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
169+ panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
170+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
171+ bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
172+ overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},
173+ cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
174+ }
175+ composed := composeFrame(state, composeCacheInput{})
176+ require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))
177+ require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
178+
179+ stream := newOutputStateStream()
180+ prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
181+ require.NoError(t, err)
182+ terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
183+ require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
184+ client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
185+ client.Write(terminalBytes)
186+ require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
187+ again, err := stream.renderer.Draw(composed.frame, nil)
188+ require.NoError(t, err)
189+ require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
190+}
191+
192+func frameRows(frame renderer.Frame) []string {
193+ rows := make([]string, frame.Height)
194+ for y := range rows {
195+ rows[y] = rowText(frame.Row(y))
196+ }
197+ return rows
198+}
199+
200+func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
201+ for _, tt := range []struct {
202+ name string
203+ placement layout.Placement
204+ initial []string
205+ scrolled []string
206+ damage []renderer.Damage
207+ wantBytes string
208+ }{
209+ {name: "safe full width", placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 3}}, initial: []string{"AAAA", "BBBB", "CCCC"}, scrolled: []string{"BBBB", "CCCC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}}, wantBytes: "\x1b[0m\x1b[2;4r\x1b[4;1H\n\x1b[r\x1b[4;1HN\x1b[K\x1b[0m"},
210+ {name: "unsafe partial width", placement: layout.Placement{ID: "p", Content: domain.Rect{X: 2, Width: 2, Height: 3}}, initial: []string{"AA", "BB", "CC"}, scrolled: []string{"BB", "CC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageText, X: 2, Y: 0, Width: 2, Height: 3}}, wantBytes: "\x1b[2;3HBB\x1b[3;3HCC\x1b[4;3HN \x1b[0m"},
211+ } {
212+ t.Run(tt.name, func(t *testing.T) {
213+ makeState := func(rows []string, damage []renderer.Damage, reset bool) capturedRenderState {
214+ f := renderer.NewFrame(tt.placement.Content.Width, tt.placement.Content.Height)
215+ for y, text := range rows {
216+ for x, r := range text {
217+ f.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
218+ }
219+ }
220+ return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 3}, valid: true, focus: "p", placements: []layout.Placement{tt.placement}}, panes: []capturedPaneRenderState{{id: "p", frame: f, placement: tt.placement, focused: true, damage: damage}}, cursor: capturedCursorInputs{visible: false}}
221+ }
222+ stream := newOutputStateStream()
223+ first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
224+ firstBytes, err := stream.render(first.frame, first.damage, first.reset)
225+ require.NoError(t, err)
226+ client := vt.NewScreen(4, 5)
227+ client.Write(firstBytes)
228+
229+ second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
230+ secondBytes, err := stream.render(second.frame, second.damage, second.reset)
231+ require.NoError(t, err)
232+ require.Equal(t, tt.wantBytes, string(secondBytes))
233+ client.Write(secondBytes)
234+ require.Equal(t, frameRows(second.frame), frameRows(client.Frame))
235+ again, err := stream.renderer.Draw(second.frame, nil)
236+ require.NoError(t, err)
237+ require.Empty(t, again, "renderer shadow must equal replayed terminal state")
238+ })
239+ }
240+}
M internal/usecase/daemon/render_test.go +6 -6
@@ -952,21 +952,21 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
952952
953953 d.paint(sess, ac, true)
954954 _ = mustOutputData(t, sends)
955- require.True(t, ac.composed.valid)
956- require.NotContains(t, frameText(ac.composed.frame), "Rename session")
955+ require.True(t, ac.pipelineCache.valid)
956+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
957957
958958 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
959959 shown := string(mustOutputData(t, sends))
960960 require.Contains(t, shown, "Rename session")
961- require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
962- require.NotContains(t, frameText(ac.composed.frame), "Rename session")
961+ require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
962+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
963963
964964 d.closePrompt(ac)
965965 d.paint(sess, ac, false)
966966 restored := string(mustOutputData(t, sends))
967967 require.NotContains(t, restored, "Rename session")
968- require.True(t, ac.composed.valid)
969- cached := frameText(ac.composed.frame)
968+ require.True(t, ac.pipelineCache.valid)
969+ cached := frameText(ac.pipelineCache.frame)
970970 require.Contains(t, cached, "live")
971971 require.NotContains(t, cached, "Rename session")
972972 }
@@ -952,21 +952,21 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
952 952
953 d.paint(sess, ac, true)953 d.paint(sess, ac, true)
954 _ = mustOutputData(t, sends)954 _ = mustOutputData(t, sends)
955- require.True(t, ac.composed.valid)955+ require.True(t, ac.pipelineCache.valid)
956- require.NotContains(t, frameText(ac.composed.frame), "Rename session")956+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
957 957
958 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })958 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
959 shown := string(mustOutputData(t, sends))959 shown := string(mustOutputData(t, sends))
960 require.Contains(t, shown, "Rename session")960 require.Contains(t, shown, "Rename session")
961- require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache")961+ require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
962- require.NotContains(t, frameText(ac.composed.frame), "Rename session")962+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
963 963
964 d.closePrompt(ac)964 d.closePrompt(ac)
965 d.paint(sess, ac, false)965 d.paint(sess, ac, false)
966 restored := string(mustOutputData(t, sends))966 restored := string(mustOutputData(t, sends))
967 require.NotContains(t, restored, "Rename session")967 require.NotContains(t, restored, "Rename session")
968- require.True(t, ac.composed.valid)968+ require.True(t, ac.pipelineCache.valid)
969- cached := frameText(ac.composed.frame)969+ cached := frameText(ac.pipelineCache.frame)
970 require.Contains(t, cached, "live")970 require.Contains(t, cached, "live")
971 require.NotContains(t, cached, "Rename session")971 require.NotContains(t, cached, "Rename session")
972 }972 }
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
@@ -55,7 +55,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
5555 <-wakeDone
5656 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
5757 ac.sendMu.Lock()
58- staleWakeComposed := ac.composed.valid
58+ staleWakeComposed := ac.pipelineCache.valid
5959 ac.sendMu.Unlock()
6060 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
6161
@@ -55,7 +55,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
55 <-wakeDone55 <-wakeDone
56 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")56 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
57 ac.sendMu.Lock()57 ac.sendMu.Lock()
58- staleWakeComposed := ac.composed.valid58+ staleWakeComposed := ac.pipelineCache.valid
59 ac.sendMu.Unlock()59 ac.sendMu.Unlock()
60 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")60 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
61 61
M internal/usecase/daemon/status_test.go +6 -6
@@ -52,9 +52,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
5252
5353 d.paint(sess, ac, true)
5454 ac.sendMu.Lock()
55- require.True(t, ac.composed.valid)
56- lightDimmedPane := ac.composed.frame.At(21, 1).Style
57- lightDivider := ac.composed.frame.At(20, 1).Style
55+ require.True(t, ac.pipelineCache.valid)
56+ lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style
57+ lightDivider := ac.pipelineCache.frame.At(20, 1).Style
5858 ac.sendMu.Unlock()
5959 left.mu.Lock()
6060 left.screen.ClearDamage()
@@ -67,9 +67,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
6767
6868 ac.sendMu.Lock()
6969 defer ac.sendMu.Unlock()
70- require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache")
71- require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
72- require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
70+ require.True(t, ac.pipelineCache.valid, "reset=false config repaint should rebuild the composed cache")
71+ require.NotEqual(t, lightDimmedPane, ac.pipelineCache.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
72+ require.NotEqual(t, lightDivider, ac.pipelineCache.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
7373 }
7474
7575 func TestStatusCompositionGolden(t *testing.T) {
@@ -52,9 +52,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
52 52
53 d.paint(sess, ac, true)53 d.paint(sess, ac, true)
54 ac.sendMu.Lock()54 ac.sendMu.Lock()
55- require.True(t, ac.composed.valid)55+ require.True(t, ac.pipelineCache.valid)
56- lightDimmedPane := ac.composed.frame.At(21, 1).Style56+ lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style
57- lightDivider := ac.composed.frame.At(20, 1).Style57+ lightDivider := ac.pipelineCache.frame.At(20, 1).Style
58 ac.sendMu.Unlock()58 ac.sendMu.Unlock()
59 left.mu.Lock()59 left.mu.Lock()
60 left.screen.ClearDamage()60 left.screen.ClearDamage()
@@ -67,9 +67,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
67 67
68 ac.sendMu.Lock()68 ac.sendMu.Lock()
69 defer ac.sendMu.Unlock()69 defer ac.sendMu.Unlock()
70- require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache")70+ require.True(t, ac.pipelineCache.valid, "reset=false config repaint should rebuild the composed cache")
71- require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")71+ require.NotEqual(t, lightDimmedPane, ac.pipelineCache.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
72- require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")72+ require.NotEqual(t, lightDivider, ac.pipelineCache.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
73 }73 }
74 74
75 func TestStatusCompositionGolden(t *testing.T) {75 func TestStatusCompositionGolden(t *testing.T) {
M internal/usecase/daemon/theme_resolve.go +0 -1
@@ -28,7 +28,6 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
2828
2929 if ac != nil {
3030 ac.setTheme(t)
31- ac.composed.invalidate()
3231 }
3332 for _, tb := range tabs {
3433 tb.mu.Lock()
@@ -28,7 +28,6 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
28 28
29 if ac != nil {29 if ac != nil {
30 ac.setTheme(t)30 ac.setTheme(t)
31- ac.composed.invalidate()
32 }31 }
33 for _, tb := range tabs {32 for _, tb := range tabs {
34 tb.mu.Lock()33 tb.mu.Lock()

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