detached 30f99e6

test(daemon): remove duplicated legacy render paths

commit
30f99e6f26831ea6ef4043927bdeadc39ba35280
author
brice · · 2026-07-14 20:01 UTC
parent
8476ebc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +119 -712
M internal/usecase/daemon/attention_test.go +11 -9
@@ -366,11 +366,10 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
366366 require.NotContains(t, string(data), "MIDSCREENMARKER")
367367 }
368368
369-// TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars pins the
370-// same invariant at the composeClientFrameWithState level: with a warm
371-// barCache and no screen damage, changing only bars.attentionFrame must
372-// produce damage confined to row 0 (top bar) and the last row (bottom bar).
373-func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
369+// TestComposeFrameAttentionFrameChangeDamagesOnlyBars pins the same invariant
370+// through the production composition pipeline: with a warm cache and no pane
371+// damage, changing only bars.attentionFrame damages only the chrome rows.
372+func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
374373 d, sess, _, _, releases := newManualTabSession(t, 2)
375374 defer releases[0]()
376375 defer releases[1]()
@@ -381,14 +380,17 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi
381380 sess.mu.Unlock()
382381
383382 tb := sess.tabs[0]
384- var cache barCache
385383 bars := d.barStateFor(sess, "")
386- _, damage := composeClientFrameWithState(bars, tb, true, &cache)
387- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
384+ area := domain.Rect{Width: tb.focusedPane().screen.Frame.Width, Height: tb.focusedPane().screen.Frame.Height}
385+ state := capturedRenderState{reset: true, layout: capturedTabLayout{area: area, valid: true}, bars: bars}
386+ out := composeFrame(state, composeCacheInput{})
387+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
388388 tb.focusedPane().screen.ClearDamage()
389389
390390 bars.attentionFrame++
391- _, damage = composeClientFrameWithState(bars, tb, false, &cache)
391+ state.reset, state.bars = false, bars
392+ out = composeFrame(state, out.cache)
393+ damage := out.damage
392394 require.NotEmpty(t, damage)
393395 lastRow := tb.focusedPane().screen.Frame.Height + 1
394396 for _, dmg := range damage {
@@ -366,11 +366,10 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
366 require.NotContains(t, string(data), "MIDSCREENMARKER")366 require.NotContains(t, string(data), "MIDSCREENMARKER")
367 }367 }
368 368
369-// TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars pins the369+// TestComposeFrameAttentionFrameChangeDamagesOnlyBars pins the same invariant
370-// same invariant at the composeClientFrameWithState level: with a warm370+// through the production composition pipeline: with a warm cache and no pane
371-// barCache and no screen damage, changing only bars.attentionFrame must371+// damage, changing only bars.attentionFrame damages only the chrome rows.
372-// produce damage confined to row 0 (top bar) and the last row (bottom bar).372+func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
373-func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
374 d, sess, _, _, releases := newManualTabSession(t, 2)373 d, sess, _, _, releases := newManualTabSession(t, 2)
375 defer releases[0]()374 defer releases[0]()
376 defer releases[1]()375 defer releases[1]()
@@ -381,14 +380,17 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi
381 sess.mu.Unlock()380 sess.mu.Unlock()
382 381
383 tb := sess.tabs[0]382 tb := sess.tabs[0]
384- var cache barCache
385 bars := d.barStateFor(sess, "")383 bars := d.barStateFor(sess, "")
386- _, damage := composeClientFrameWithState(bars, tb, true, &cache)384+ area := domain.Rect{Width: tb.focusedPane().screen.Frame.Width, Height: tb.focusedPane().screen.Frame.Height}
387- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)385+ state := capturedRenderState{reset: true, layout: capturedTabLayout{area: area, valid: true}, bars: bars}
386+ out := composeFrame(state, composeCacheInput{})
387+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
388 tb.focusedPane().screen.ClearDamage()388 tb.focusedPane().screen.ClearDamage()
389 389
390 bars.attentionFrame++390 bars.attentionFrame++
391- _, damage = composeClientFrameWithState(bars, tb, false, &cache)391+ state.reset, state.bars = false, bars
392+ out = composeFrame(state, out.cache)
393+ damage := out.damage
392 require.NotEmpty(t, damage)394 require.NotEmpty(t, damage)
393 lastRow := tb.focusedPane().screen.Frame.Height + 1395 lastRow := tb.focusedPane().screen.Frame.Height + 1
394 for _, dmg := range damage {396 for _, dmg := range damage {
M internal/usecase/daemon/copymode_test.go +9 -1
@@ -151,7 +151,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
151151 mode := scopy.NewMode(snap)
152152
153153 bars := barState{status: sess.statusSegments(true)}
154- base, _ := composeClientFrameWithState(bars, tb, true)
154+ content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
155+ base := composeFrame(capturedRenderState{
156+ reset: true,
157+ layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
158+ panes: []capturedPaneRenderState{{
159+ id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
160+ }},
161+ bars: bars,
162+ }, composeCacheInput{}).frame
155163 frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
156164
157165 require.Equal(t, 80, frame.Width)
@@ -151,7 +151,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
151 mode := scopy.NewMode(snap)151 mode := scopy.NewMode(snap)
152 152
153 bars := barState{status: sess.statusSegments(true)}153 bars := barState{status: sess.statusSegments(true)}
154- base, _ := composeClientFrameWithState(bars, tb, true)154+ content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
155+ base := composeFrame(capturedRenderState{
156+ reset: true,
157+ layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
158+ panes: []capturedPaneRenderState{{
159+ id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
160+ }},
161+ bars: bars,
162+ }, composeCacheInput{}).frame
155 frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)163 frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
156 164
157 require.Equal(t, 80, frame.Width)165 require.Equal(t, 80, frame.Width)
D internal/usecase/daemon/floating_render_legacy_test.go +0 -72
@@ -1,72 +0,0 @@
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, previousCapture)
38- // This retained legacy test helper models its historical synchronous
39- // consume path; production commits receipts in emitFrame instead.
40- p.screen.AcknowledgeDamage(captured.damageGeneration)
41- if cache != nil {
42- cache.floatingCaptured = captured
43- }
44- p.mu.Unlock()
45- title := captured.title
46- titleGeneration := captured.titleGeneration
47- blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
48- damage := append([]renderer.Damage(nil), baseDamage...)
49- for _, d := range captured.damage {
50- damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
51- }
52-
53- // The committed geometry, rather than the requested config geometry, is
54- // what was actually rendered. Track it so movement (including a same-size
55- // resize) cannot reuse a cached popup at its former position.
56- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
57- titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
58- drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
59- if cache != nil {
60- cache.floating = p
61- cache.floatingGeneration = generation
62- cache.floatingGeometry = geometry
63- cache.floatingTitleGeneration = titleGeneration
64- }
65- if full || popupChanged {
66- return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
67- }
68- if titleChanged && geometry.Bounds.Height >= 3 {
69- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
70- }
71- return frame, damage, geometry
72-}
@@ -1,72 +0,0 @@
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, previousCapture)
38- // This retained legacy test helper models its historical synchronous
39- // consume path; production commits receipts in emitFrame instead.
40- p.screen.AcknowledgeDamage(captured.damageGeneration)
41- if cache != nil {
42- cache.floatingCaptured = captured
43- }
44- p.mu.Unlock()
45- title := captured.title
46- titleGeneration := captured.titleGeneration
47- blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
48- damage := append([]renderer.Damage(nil), baseDamage...)
49- for _, d := range captured.damage {
50- damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
51- }
52-
53- // The committed geometry, rather than the requested config geometry, is
54- // what was actually rendered. Track it so movement (including a same-size
55- // resize) cannot reuse a cached popup at its former position.
56- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
57- titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
58- drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
59- if cache != nil {
60- cache.floating = p
61- cache.floatingGeneration = generation
62- cache.floatingGeometry = geometry
63- cache.floatingTitleGeneration = titleGeneration
64- }
65- if full || popupChanged {
66- return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
67- }
68- if titleChanged && geometry.Bounds.Height >= 3 {
69- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
70- }
71- return frame, damage, geometry
72-}
M internal/usecase/daemon/floating_render_test.go +25 -8
@@ -267,8 +267,17 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
267267 base := renderer.NewFrame(40, 12)
268268 content := domain.Rect{Y: 1, Width: 40, Height: 10}
269269 cfg := domain.FloatingConfig{Width: 80, Height: 80}
270- frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, false)
271- geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg).translate(content.X, content.Y)
270+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
271+ captured := capturedPaneRenderState{frame: renderer.NewFrame(geometry.Inner.Width, geometry.Inner.Height), title: "btop", titleGeneration: 1}
272+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
273+ baseFrame: base,
274+ floating: capturedFloatingRenderState{
275+ visible: true, pane: captured, geometry: geometry, title: captured.title, generation: 1, titleGeneration: captured.titleGeneration,
276+ },
277+ content: content,
278+ theme: themeui.Theme{},
279+ })
280+ geometry = geometry.translate(content.X, content.Y)
272281 var gotTitle strings.Builder
273282 for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
274283 gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
@@ -276,10 +285,14 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
276285 require.Equal(t, "btop", strings.TrimRight(gotTitle.String(), "─"))
277286 }
278287
279-func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
288+func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
280289 p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
281- base := renderer.NewFrame(80, 24)
282- content := domain.Rect{Width: 80, Height: 24}
290+ tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
291+ installTestFloating(tb, p, true)
292+ ac := &attachedClient{}
293+ ac.initOverlays()
294+ sess := &session{tabs: []*tab{tb}, client: ac}
295+ base := barState{}
283296 cfg := domain.FloatingConfig{Width: 100, Height: 100}
284297
285298 start := make(chan struct{})
@@ -289,7 +302,6 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
289302 defer wg.Done()
290303 <-start
291304 for range 500 {
292- // This is the same pane lock used by ptyReader around Screen.Write.
293305 p.mu.Lock()
294306 p.screen.Write([]byte("\x1b[Hreader"))
295307 p.mu.Unlock()
@@ -298,9 +310,14 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
298310 go func() {
299311 defer wg.Done()
300312 <-start
301- cache := &composedFrameCache{}
313+ cache := composeCacheInput{}
302314 for range 500 {
303- composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
315+ ac.sendMu.Lock()
316+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: base, floatingCfg: cfg})
317+ ac.sendMu.Unlock()
318+ require.True(t, ok)
319+ out := composeFrame(*state, cache)
320+ cache = out.cache
304321 }
305322 }()
306323 close(start)
@@ -267,8 +267,17 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
267 base := renderer.NewFrame(40, 12)267 base := renderer.NewFrame(40, 12)
268 content := domain.Rect{Y: 1, Width: 40, Height: 10}268 content := domain.Rect{Y: 1, Width: 40, Height: 10}
269 cfg := domain.FloatingConfig{Width: 80, Height: 80}269 cfg := domain.FloatingConfig{Width: 80, Height: 80}
270- frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, false)270+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
271- geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg).translate(content.X, content.Y)271+ captured := capturedPaneRenderState{frame: renderer.NewFrame(geometry.Inner.Width, geometry.Inner.Height), title: "btop", titleGeneration: 1}
272+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
273+ baseFrame: base,
274+ floating: capturedFloatingRenderState{
275+ visible: true, pane: captured, geometry: geometry, title: captured.title, generation: 1, titleGeneration: captured.titleGeneration,
276+ },
277+ content: content,
278+ theme: themeui.Theme{},
279+ })
280+ geometry = geometry.translate(content.X, content.Y)
272 var gotTitle strings.Builder281 var gotTitle strings.Builder
273 for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {282 for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
274 gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)283 gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
@@ -276,10 +285,14 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
276 require.Equal(t, "btop", strings.TrimRight(gotTitle.String(), "─"))285 require.Equal(t, "btop", strings.TrimRight(gotTitle.String(), "─"))
277 }286 }
278 287
279-func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {288+func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
280 p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})289 p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
281- base := renderer.NewFrame(80, 24)290+ tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
282- content := domain.Rect{Width: 80, Height: 24}291+ installTestFloating(tb, p, true)
292+ ac := &attachedClient{}
293+ ac.initOverlays()
294+ sess := &session{tabs: []*tab{tb}, client: ac}
295+ base := barState{}
283 cfg := domain.FloatingConfig{Width: 100, Height: 100}296 cfg := domain.FloatingConfig{Width: 100, Height: 100}
284 297
285 start := make(chan struct{})298 start := make(chan struct{})
@@ -289,7 +302,6 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
289 defer wg.Done()302 defer wg.Done()
290 <-start303 <-start
291 for range 500 {304 for range 500 {
292- // This is the same pane lock used by ptyReader around Screen.Write.
293 p.mu.Lock()305 p.mu.Lock()
294 p.screen.Write([]byte("\x1b[Hreader"))306 p.screen.Write([]byte("\x1b[Hreader"))
295 p.mu.Unlock()307 p.mu.Unlock()
@@ -298,9 +310,14 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
298 go func() {310 go func() {
299 defer wg.Done()311 defer wg.Done()
300 <-start312 <-start
301- cache := &composedFrameCache{}313+ cache := composeCacheInput{}
302 for range 500 {314 for range 500 {
303- composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, cache, false)315+ ac.sendMu.Lock()
316+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: base, floatingCfg: cfg})
317+ ac.sendMu.Unlock()
318+ require.True(t, ok)
319+ out := composeFrame(*state, cache)
320+ cache = out.cache
304 }321 }
305 }()322 }()
306 close(start)323 close(start)
M internal/usecase/daemon/pane_actions_test.go +10 -1
@@ -16,6 +16,7 @@ import (
1616 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1717 scopy "github.com/bnema/vev/internal/usecase/copy"
1818 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/pkg/renderer"
1920 )
2021
2122 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
@@ -309,7 +310,15 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
309310 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
310311 require.Equal(t, []byte("Z"), <-writes)
311312 tb.focusedPane().screen.Write([]byte("survivor"))
312- frame, _ := composeClientFrame(sess, tb, false, "")
313+ content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
314+ frame := composeFrame(capturedRenderState{
315+ reset: true,
316+ layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
317+ panes: []capturedPaneRenderState{{
318+ id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
319+ }},
320+ bars: barState{status: sess.statusSegments(true)},
321+ }, composeCacheInput{}).frame
313322 require.Contains(t, frameRowString(frame, 1), "survivor")
314323 }
315324
@@ -16,6 +16,7 @@ import (
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 scopy "github.com/bnema/vev/internal/usecase/copy"17 scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/pkg/renderer"
19 )20 )
20 21
21 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {22 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
@@ -309,7 +310,15 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
309 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))310 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
310 require.Equal(t, []byte("Z"), <-writes)311 require.Equal(t, []byte("Z"), <-writes)
311 tb.focusedPane().screen.Write([]byte("survivor"))312 tb.focusedPane().screen.Write([]byte("survivor"))
312- frame, _ := composeClientFrame(sess, tb, false, "")313+ content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
314+ frame := composeFrame(capturedRenderState{
315+ reset: true,
316+ layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
317+ panes: []capturedPaneRenderState{{
318+ id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
319+ }},
320+ bars: barState{status: sess.statusSegments(true)},
321+ }, composeCacheInput{}).frame
313 require.Contains(t, frameRowString(frame, 1), "survivor")322 require.Contains(t, frameRowString(frame, 1), "survivor")
314 }323 }
315 324
D internal/usecase/daemon/render_legacy_test.go +0 -279
@@ -1,279 +0,0 @@
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-// capturePrimaryRenderState -> 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 composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
51- return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
52-}
53-
54-func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
55- return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
56-}
57-
58-func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
59- var cache *barCache
60- if len(caches) > 0 {
61- cache = caches[0]
62- }
63- return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
64-}
65-
66-func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
67- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
68-}
69-
70-func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
71- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
72-}
73-
74-func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
75- p := tb.focusedPane()
76- styles := newThemeStyles(bars.theme)
77- if p == nil {
78- width, screenRows := tb.size.Cols, tb.size.Rows
79- if width <= 0 || screenRows <= 0 {
80- return renderer.NewFrame(0, 0), nil
81- }
82- return renderer.NewFrame(width, screenRows+2), nil
83- }
84- p.mu.Lock()
85- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
86- p.mu.Unlock()
87- if tb.size.Valid() {
88- width, screenRows = tb.size.Cols, tb.size.Rows
89- }
90- cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
91- layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
92- var frame renderer.Frame
93- if cacheValid {
94- frame = composed.frame
95- } else {
96- frame = renderer.NewFrame(width, screenRows+2)
97- if composed != nil {
98- full = true
99- }
100- }
101- if !layoutSame {
102- full = true
103- }
104- contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
105- if cacheValid && !layoutSame {
106- clearFrameRect(frame, contentArea)
107- }
108- topBar := frame.Row(0)
109- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
110- var titleGenerations map[layout.PaneID]uint64
111- if composed != nil {
112- if composed.titleGenerations == nil {
113- composed.titleGenerations = make(map[layout.PaneID]uint64)
114- }
115- titleGenerations = composed.titleGenerations
116- }
117- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
118- bottomBar := frame.Row(screenRows + 1)
119- drawStatusBarState(bottomBar, bars, styles)
120- if composed != nil {
121- composed.frame = frame
122- composed.valid = true
123- composed.layoutSnap = layoutSnap
124- }
125- if full {
126- if cache != nil {
127- cache.capture(topBar, bottomBar)
128- }
129- return frame, []renderer.Damage{renderer.FullRedraw()}
130- }
131- damage := translateDamage(contentDamage, 0, 1)
132- if cache == nil || !sameCells(cache.top, topBar) {
133- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
134- }
135- if cache == nil || !sameCells(cache.bottom, bottomBar) {
136- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
137- }
138- if cache != nil {
139- cache.capture(topBar, bottomBar)
140- }
141- return frame, damage
142-}
143-
144-func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
145- return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
146-}
147-
148-func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
149- frame := renderer.NewFrame(area.Width, area.Height)
150- damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
151- return frame, damage
152-}
153-
154-func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
155- return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
156-}
157-
158-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 {
159- contentArea := domain.Rect{Width: area.Width, Height: area.Height}
160- root := tb.tree.Root
161- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
162- if !ok {
163- placements, ok = layout.Solve(root, contentArea)
164- }
165- var fallback *pane
166- if !ok {
167- fallback = tb.focusedPane()
168- if fallback == nil {
169- return nil
170- }
171- placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
172- }
173- // A valid cache has the same layout, so its title IDs remain valid. Reset
174- // the existing cache only when rebuilding after layout or frame churn.
175- if titleGenerations != nil && !cacheValid {
176- clear(titleGenerations)
177- }
178- if ok && !cacheValid {
179- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
180- }
181- var damage []renderer.Damage
182- for _, pl := range placements {
183- p := tb.panes[pl.ID]
184- if p == nil && fallback != nil && pl.ID == fallback.id {
185- p = fallback
186- }
187- if p == nil {
188- continue
189- }
190- focused := tb.tree.Focus == pl.ID
191- pl = offsetPlacement(pl, area.X, area.Y)
192- if pl.TitleBar.Height > 0 {
193- generation := drawPaneTitleBar(frame, pl, p, focused, theme)
194- if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
195- titleDamage := pl.TitleBar
196- titleDamage.X -= area.X
197- titleDamage.Y -= area.Y
198- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
199- }
200- if titleGenerations != nil {
201- titleGenerations[pl.ID] = generation
202- }
203- }
204- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
205- if consumeDamage {
206- p.mu.Lock()
207- captured := capturePaneRenderStateLocked(p, domain.Rect{})
208- p.screen.AcknowledgeDamage(captured.damageGeneration)
209- p.mu.Unlock()
210- }
211- continue
212- }
213- var paneFrame renderer.Frame
214- var paneDamage []renderer.Damage
215- if consumeDamage {
216- p.mu.Lock()
217- captured := capturePaneRenderStateLocked(p, pl.Content)
218- p.screen.AcknowledgeDamage(captured.damageGeneration)
219- p.mu.Unlock()
220- paneFrame, paneDamage = captured.frame, captured.damage
221- } else {
222- p.mu.Lock()
223- paneDamage = p.screen.Damage()
224- if !cacheValid || len(paneDamage) > 0 {
225- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
226- }
227- for _, d := range paneDamage {
228- localContent := pl.Content
229- localContent.X -= area.X
230- localContent.Y -= area.Y
231- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
232- }
233- p.mu.Unlock()
234- continue
235- }
236- if !cacheValid || len(paneDamage) > 0 {
237- blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
238- }
239- for _, d := range paneDamage {
240- localContent := pl.Content
241- localContent.X -= area.X
242- localContent.Y -= area.Y
243- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
244- }
245- }
246- return damage
247-}
248-
249-func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
250- return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
251-}
252-
253-func clearFrameRect(frame renderer.Frame, r domain.Rect) {
254- blank := renderer.BlankCell()
255- for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
256- for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
257- frame.Set(x, y, blank)
258- }
259- }
260-}
261-
262-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
263- styles := newThemeStyles(theme)
264- style := styles.border
265- if focused {
266- style = styles.statusBar
267- } else {
268- style = themeui.DimStyle(style, theme)
269- }
270- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
271- frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
272- }
273- p.mu.Lock()
274- title := p.displayTitleLocked()
275- generation := p.title.generation
276- p.mu.Unlock()
277- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
278- return generation
279-}
@@ -1,279 +0,0 @@
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-// capturePrimaryRenderState -> 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 composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
51- return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
52-}
53-
54-func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
55- return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
56-}
57-
58-func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
59- var cache *barCache
60- if len(caches) > 0 {
61- cache = caches[0]
62- }
63- return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
64-}
65-
66-func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
67- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
68-}
69-
70-func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
71- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
72-}
73-
74-func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
75- p := tb.focusedPane()
76- styles := newThemeStyles(bars.theme)
77- if p == nil {
78- width, screenRows := tb.size.Cols, tb.size.Rows
79- if width <= 0 || screenRows <= 0 {
80- return renderer.NewFrame(0, 0), nil
81- }
82- return renderer.NewFrame(width, screenRows+2), nil
83- }
84- p.mu.Lock()
85- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
86- p.mu.Unlock()
87- if tb.size.Valid() {
88- width, screenRows = tb.size.Cols, tb.size.Rows
89- }
90- cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
91- layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
92- var frame renderer.Frame
93- if cacheValid {
94- frame = composed.frame
95- } else {
96- frame = renderer.NewFrame(width, screenRows+2)
97- if composed != nil {
98- full = true
99- }
100- }
101- if !layoutSame {
102- full = true
103- }
104- contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
105- if cacheValid && !layoutSame {
106- clearFrameRect(frame, contentArea)
107- }
108- topBar := frame.Row(0)
109- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
110- var titleGenerations map[layout.PaneID]uint64
111- if composed != nil {
112- if composed.titleGenerations == nil {
113- composed.titleGenerations = make(map[layout.PaneID]uint64)
114- }
115- titleGenerations = composed.titleGenerations
116- }
117- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
118- bottomBar := frame.Row(screenRows + 1)
119- drawStatusBarState(bottomBar, bars, styles)
120- if composed != nil {
121- composed.frame = frame
122- composed.valid = true
123- composed.layoutSnap = layoutSnap
124- }
125- if full {
126- if cache != nil {
127- cache.capture(topBar, bottomBar)
128- }
129- return frame, []renderer.Damage{renderer.FullRedraw()}
130- }
131- damage := translateDamage(contentDamage, 0, 1)
132- if cache == nil || !sameCells(cache.top, topBar) {
133- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
134- }
135- if cache == nil || !sameCells(cache.bottom, bottomBar) {
136- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
137- }
138- if cache != nil {
139- cache.capture(topBar, bottomBar)
140- }
141- return frame, damage
142-}
143-
144-func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
145- return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
146-}
147-
148-func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
149- frame := renderer.NewFrame(area.Width, area.Height)
150- damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
151- return frame, damage
152-}
153-
154-func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
155- return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
156-}
157-
158-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 {
159- contentArea := domain.Rect{Width: area.Width, Height: area.Height}
160- root := tb.tree.Root
161- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
162- if !ok {
163- placements, ok = layout.Solve(root, contentArea)
164- }
165- var fallback *pane
166- if !ok {
167- fallback = tb.focusedPane()
168- if fallback == nil {
169- return nil
170- }
171- placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
172- }
173- // A valid cache has the same layout, so its title IDs remain valid. Reset
174- // the existing cache only when rebuilding after layout or frame churn.
175- if titleGenerations != nil && !cacheValid {
176- clear(titleGenerations)
177- }
178- if ok && !cacheValid {
179- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
180- }
181- var damage []renderer.Damage
182- for _, pl := range placements {
183- p := tb.panes[pl.ID]
184- if p == nil && fallback != nil && pl.ID == fallback.id {
185- p = fallback
186- }
187- if p == nil {
188- continue
189- }
190- focused := tb.tree.Focus == pl.ID
191- pl = offsetPlacement(pl, area.X, area.Y)
192- if pl.TitleBar.Height > 0 {
193- generation := drawPaneTitleBar(frame, pl, p, focused, theme)
194- if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
195- titleDamage := pl.TitleBar
196- titleDamage.X -= area.X
197- titleDamage.Y -= area.Y
198- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
199- }
200- if titleGenerations != nil {
201- titleGenerations[pl.ID] = generation
202- }
203- }
204- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
205- if consumeDamage {
206- p.mu.Lock()
207- captured := capturePaneRenderStateLocked(p, domain.Rect{})
208- p.screen.AcknowledgeDamage(captured.damageGeneration)
209- p.mu.Unlock()
210- }
211- continue
212- }
213- var paneFrame renderer.Frame
214- var paneDamage []renderer.Damage
215- if consumeDamage {
216- p.mu.Lock()
217- captured := capturePaneRenderStateLocked(p, pl.Content)
218- p.screen.AcknowledgeDamage(captured.damageGeneration)
219- p.mu.Unlock()
220- paneFrame, paneDamage = captured.frame, captured.damage
221- } else {
222- p.mu.Lock()
223- paneDamage = p.screen.Damage()
224- if !cacheValid || len(paneDamage) > 0 {
225- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
226- }
227- for _, d := range paneDamage {
228- localContent := pl.Content
229- localContent.X -= area.X
230- localContent.Y -= area.Y
231- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
232- }
233- p.mu.Unlock()
234- continue
235- }
236- if !cacheValid || len(paneDamage) > 0 {
237- blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
238- }
239- for _, d := range paneDamage {
240- localContent := pl.Content
241- localContent.X -= area.X
242- localContent.Y -= area.Y
243- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
244- }
245- }
246- return damage
247-}
248-
249-func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
250- return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
251-}
252-
253-func clearFrameRect(frame renderer.Frame, r domain.Rect) {
254- blank := renderer.BlankCell()
255- for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
256- for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
257- frame.Set(x, y, blank)
258- }
259- }
260-}
261-
262-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
263- styles := newThemeStyles(theme)
264- style := styles.border
265- if focused {
266- style = styles.statusBar
267- } else {
268- style = themeui.DimStyle(style, theme)
269- }
270- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
271- frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
272- }
273- p.mu.Lock()
274- title := p.displayTitleLocked()
275- generation := p.title.generation
276- p.mu.Unlock()
277- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
278- return generation
279-}
M internal/usecase/daemon/render_pipeline.go +1 -0
@@ -86,6 +86,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
8686 if titles == nil {
8787 titles = make(map[layout.PaneID]uint64, len(state.panes))
8888 }
89+ clear(titles)
8990 damage := scratch.damage[:0]
9091 for _, pane := range state.panes {
9192 pl := offsetPlacement(pane.placement, 0, 1)
@@ -86,6 +86,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
86 if titles == nil {86 if titles == nil {
87 titles = make(map[layout.PaneID]uint64, len(state.panes))87 titles = make(map[layout.PaneID]uint64, len(state.panes))
88 }88 }
89+ clear(titles)
89 damage := scratch.damage[:0]90 damage := scratch.damage[:0]
90 for _, pane := range state.panes {91 for _, pane := range state.panes {
91 pl := offsetPlacement(pane.placement, 0, 1)92 pl := offsetPlacement(pane.placement, 0, 1)
M internal/usecase/daemon/render_pipeline_test.go +18 -7
@@ -1,6 +1,9 @@
11 package daemon
22
33 import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
47 "io"
58 "os"
69 "path/filepath"
@@ -163,14 +166,22 @@ func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
163166 }
164167
165168 func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
166- for _, name := range []string{"render.go", "floating_render.go", "picker.go"} {
167- data, err := os.ReadFile(filepath.Join(".", name))
169+ files, err := filepath.Glob("*.go")
170+ require.NoError(t, err)
171+ for _, name := range files {
172+ parsed, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
168173 require.NoError(t, err)
169- source := string(data)
170- for _, forbidden := range []string{
171- "func composeClientFrame", "func composeTabFrame", "func composeFloatingFrame",
172- } {
173- require.NotContains(t, source, forbidden, "%s retains duplicate production composition path", name)
174+ for _, declaration := range parsed.Decls {
175+ switch declaration := declaration.(type) {
176+ case *ast.FuncDecl:
177+ forbidden := declaration.Name.Name == "composeFloatingFrame" || declaration.Name.Name == "copyTargetRectLocked" || strings.HasPrefix(declaration.Name.Name, "composeClientFrame") || strings.HasPrefix(declaration.Name.Name, "composeTabFrame")
178+ require.False(t, forbidden, "%s declares forbidden legacy render helper %s", name, declaration.Name.Name)
179+ case *ast.GenDecl:
180+ for _, spec := range declaration.Specs {
181+ typeSpec, ok := spec.(*ast.TypeSpec)
182+ require.False(t, ok && typeSpec.Name.Name == "composedFrameCache", "%s declares forbidden legacy render cache", name)
183+ }
184+ }
174185 }
175186 }
176187 data, err := os.ReadFile("render_pipeline.go")
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
4 "io"7 "io"
5 "os"8 "os"
6 "path/filepath"9 "path/filepath"
@@ -163,14 +166,22 @@ func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
163 }166 }
164 167
165 func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {168 func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
166- for _, name := range []string{"render.go", "floating_render.go", "picker.go"} {169+ files, err := filepath.Glob("*.go")
167- data, err := os.ReadFile(filepath.Join(".", name))170+ require.NoError(t, err)
171+ for _, name := range files {
172+ parsed, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
168 require.NoError(t, err)173 require.NoError(t, err)
169- source := string(data)174+ for _, declaration := range parsed.Decls {
170- for _, forbidden := range []string{175+ switch declaration := declaration.(type) {
171- "func composeClientFrame", "func composeTabFrame", "func composeFloatingFrame",176+ case *ast.FuncDecl:
172- } {177+ forbidden := declaration.Name.Name == "composeFloatingFrame" || declaration.Name.Name == "copyTargetRectLocked" || strings.HasPrefix(declaration.Name.Name, "composeClientFrame") || strings.HasPrefix(declaration.Name.Name, "composeTabFrame")
173- require.NotContains(t, source, forbidden, "%s retains duplicate production composition path", name)178+ require.False(t, forbidden, "%s declares forbidden legacy render helper %s", name, declaration.Name.Name)
179+ case *ast.GenDecl:
180+ for _, spec := range declaration.Specs {
181+ typeSpec, ok := spec.(*ast.TypeSpec)
182+ require.False(t, ok && typeSpec.Name.Name == "composedFrameCache", "%s declares forbidden legacy render cache", name)
183+ }
184+ }
174 }185 }
175 }186 }
176 data, err := os.ReadFile("render_pipeline.go")187 data, err := os.ReadFile("render_pipeline.go")
M internal/usecase/daemon/render_test.go +0 -307
@@ -802,228 +802,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
802802 }
803803 }
804804
805-func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
806- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
807- left := win.focusedPane()
808- left.screen.ClearDamage()
809- left.screen.Write([]byte("L"))
810- left.screen.ClearDamage()
811-
812- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
813- right.screen.Write([]byte("R"))
814- right.screen.ClearDamage()
815- right.screen.Write([]byte("x"))
816-
817- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
818- win.tree.Focus = right.id
819- win.panes[right.id] = right
820-
821- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
822- frame, damage := composeTabFrame(win, domain.Rect{Width: 41, Height: 4}, theme)
823-
824- require.Equal(t, 'L', frame.At(0, 0).Rune)
825- require.Equal(t, '│', frame.At(20, 0).Rune)
826- require.Equal(t, 'R', frame.At(21, 0).Rune)
827- require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "unfocused left pane should be dimmed during blit")
828- require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen")
829- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
830-}
831-
832-func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
833- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
834- left := win.focusedPane()
835- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
836- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
837- win.tree.Focus = right.id
838- win.panes[right.id] = right
839- left.screen.Write([]byte("L"))
840- right.screen.Write([]byte("R"))
841- var bars barCache
842- var composed composedFrameCache
843- sess := &session{id: "s", name: "work", tabs: []*tab{win}}
844- composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, true, solveTabLayoutLocked(win), &bars, &composed)
845- left.screen.ClearDamage()
846- right.screen.ClearDamage()
847- left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
848- right.screen.Write([]byte("x"))
849-
850- frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, false, solveTabLayoutLocked(win), &bars, &composed)
851-
852- require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
853- require.Equal(t, 'x', frame.At(22, 1).Rune)
854- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1})
855-}
856-
857-func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
858- win := newTab(nil, domain.Size{Cols: 20, Rows: 4})
859- p := win.focusedPane()
860- p.screen.Write([]byte("old"))
861- var bars barCache
862- var composed composedFrameCache
863- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
864-
865- _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
866- require.NotEmpty(t, damage)
867- require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame")
868-
869- p.screen.Write([]byte("new"))
870- require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render")
871-
872- _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
873- require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming")
874-}
875-
876-func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
877- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
878- p1 := win.focusedPane()
879- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
880- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
881- win.tree.Focus = p2.id
882- win.panes[p2.id] = p2
883- p1.screen.Write([]byte("hidden"))
884- p2.screen.Write([]byte("shown"))
885- var bars barCache
886- var composed composedFrameCache
887- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
888-
889- _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
890-
891- require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars")
892- require.Empty(t, p2.screen.Damage())
893-}
894-
895-func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
896- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
897- left := win.focusedPane()
898- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
899- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
900- win.panes[right.id] = right
901- left.screen.Write([]byte("L"))
902- right.screen.Write([]byte("R"))
903- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
904- var bars barCache
905- var composed composedFrameCache
906- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true), theme: theme}
907- win.tree.Focus = left.id
908- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
909- left.screen.ClearDamage()
910- right.screen.ClearDamage()
911- leftWasFocused := composed.frame.At(0, 1).Style
912- rightWasDimmed := composed.frame.At(21, 1).Style
913-
914- win.tree.Focus = right.id
915- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
916-
917- require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed")
918- require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed")
919- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
920-}
921-
922-func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
923- win := newTab(nil, domain.Size{Cols: 41, Rows: 5})
924- left := win.focusedPane()
925- right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5})
926- win.panes[right.id] = right
927- left.screen.Write([]byte("L"))
928- right.screen.Write([]byte("R"))
929- var bars barCache
930- var composed composedFrameCache
931- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
932- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
933- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
934- require.Equal(t, '│', composed.frame.At(20, 1).Rune)
935- left.screen.ClearDamage()
936- right.screen.ClearDamage()
937-
938- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
939- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
940-
941- require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes")
942- require.Equal(t, '─', frame.At(20, 3).Rune)
943- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
944-}
945-
946-func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
947- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
948- p := win.focusedPane()
949- p.title.processName = "shell"
950- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
951- layoutSnap := solveTabLayoutLocked(win)
952- frame := renderer.NewFrame(20, 5)
953- titleGenerations := map[layout.PaneID]uint64{p.id: p.title.generation}
954- p.screen.ClearDamage()
955-
956- composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
957- allocs := testing.AllocsPerRun(100, func() {
958- composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
959- })
960- require.Zero(t, allocs, "valid cached title-bar renders must not allocate")
961-}
962-
963-func TestComposeTabFrameStackUsesShellFallback(t *testing.T) {
964- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
965- p := win.focusedPane()
966- p.title.displayFallback = "fish"
967- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
968-
969- frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, themeui.Theme{})
970-
971- require.Equal(t, "fish", rowText(frame.Row(0))[:4])
972-}
973-
974-func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
975- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
976- p1 := win.focusedPane()
977- p1.title.processName = "one"
978- p1.screen.ClearDamage()
979- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
980- p2.title.processName = "two"
981- p2.screen.Write([]byte("T"))
982- p2.screen.ClearDamage()
983-
984- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
985- win.tree.Focus = p2.id
986- win.panes[p2.id] = p2
987-
988- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
989- frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, theme)
990-
991- require.Equal(t, "one", rowText(frame.Row(0))[:3])
992- require.Equal(t, "two", rowText(frame.Row(1))[:3])
993- require.Equal(t, 'T', frame.At(0, 2).Rune)
994- require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "collapsed title bar should use dimmed chrome")
995- require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
996-}
997-
998-func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
999- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
1000- p1 := win.focusedPane()
1001- p1.title.processName = "one"
1002- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
1003- p2.title.processName = "two"
1004- p2.screen.Write([]byte("T"))
1005- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
1006- win.tree.Focus = p2.id
1007- win.panes[p2.id] = p2
1008- var bars barCache
1009- var composed composedFrameCache
1010- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
1011- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
1012- p1.screen.ClearDamage()
1013- p2.screen.ClearDamage()
1014-
1015- p1.title.processName = "renamed"
1016- p1.title.generation++
1017- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
1018-
1019- require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
1020- require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
1021- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
1022-
1023- _, damage = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
1024- require.NotContains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}, "unchanged title must not redraw its row")
1025-}
1026-
1027805 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
1028806 tests := []struct {
1029807 name string
@@ -1170,56 +948,6 @@ func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
1170948 }
1171949 }
1172950
1173-func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
1174- sess, win := newBarCacheTestSession()
1175- var cache barCache
1176-
1177- _, damage := composeClientFrame(sess, win, true, "", &cache)
1178- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1179- win.focusedPane().screen.ClearDamage()
1180- win.focusedPane().screen.Write([]byte("x"))
1181-
1182- _, damage = composeClientFrame(sess, win, false, "", &cache)
1183-
1184- require.NotEmpty(t, damage)
1185- for _, d := range damage {
1186- require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")
1187- require.NotEqual(t, win.focusedPane().screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
1188- }
1189-}
1190-
1191-func TestComposeClientFrameBarCacheDamagesChangedBottomOnly(t *testing.T) {
1192- sess, win := newBarCacheTestSession()
1193- var cache barCache
1194- composeClientFrame(sess, win, true, "", &cache)
1195- win.focusedPane().screen.ClearDamage()
1196-
1197- sess.name = "renamed"
1198- _, damage := composeClientFrame(sess, win, false, "", &cache)
1199-
1200- require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.focusedPane().screen.Frame.Height + 1, Width: win.focusedPane().screen.Frame.Width, Height: 1}}, damage)
1201-}
1202-
1203-func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
1204- sess, win := newBarCacheTestSession()
1205- var cache barCache
1206- cache.top = []renderer.Cell{renderer.BlankCell()}
1207- cache.bottom = []renderer.Cell{renderer.BlankCell()}
1208-
1209- _, damage := composeClientFrame(sess, win, true, "", &cache)
1210- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1211- win.focusedPane().screen.ClearDamage()
1212-
1213- _, damage = composeClientFrame(sess, win, false, "", &cache)
1214- require.Empty(t, damage)
1215-}
1216-
1217-func newBarCacheTestSession() (*session, *tab) {
1218- win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 3})
1219- sess := &session{id: "s", name: "work", tabs: []*tab{win}}
1220- return sess, win
1221-}
1222-
1223951 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1224952 newSize := domain.Size{Cols: 100, Rows: 30}
1225953
@@ -1464,41 +1192,6 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
14641192 require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")
14651193 }
14661194
1467-func TestCopyTargetRectLocked(t *testing.T) {
1468- tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
1469- cfg := domain.Defaults().Floating
1470- contentArea := domain.Rect{Y: 1, Width: 80, Height: 23}
1471- fp := newPane("floating", nil, domain.Size{Cols: 10, Rows: 4})
1472- stray := newPane("stray", nil, domain.Size{Cols: 10, Rows: 4})
1473- tb.mu.Lock()
1474- defer tb.mu.Unlock()
1475- layoutSnap := solveTabLayoutLocked(tb)
1476- main := tb.focusedPane()
1477- fp.popupGeometry = calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg)
1478- floatingFrameGeometry := fp.popupGeometry.translate(contentArea.X, contentArea.Y)
1479-
1480- cases := []struct {
1481- name string
1482- pane *pane
1483- floating *pane
1484- hasFloating bool
1485- want domain.Rect
1486- }{
1487- {name: "floating source targets committed frame-absolute popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg).translate(contentArea.X, contentArea.Y).Inner},
1488- {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}},
1489- {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}},
1490- }
1491- for _, tc := range cases {
1492- t.Run(tc.name, func(t *testing.T) {
1493- got := copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, floatingFrameGeometry)
1494- require.Equal(t, tc.want, got)
1495- if tc.hasFloating {
1496- require.NotEqual(t, fp.popupGeometry.Inner, got, "content-relative geometry must not target the client frame directly")
1497- }
1498- })
1499- }
1500-}
1501-
15021195 func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
15031196 normal, releaseNormal := newBlockingPTY(t)
15041197 floatingPTY, releaseFloating := newBlockingPTY(t)
@@ -802,228 +802,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
802 }802 }
803 }803 }
804 804
805-func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
806- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
807- left := win.focusedPane()
808- left.screen.ClearDamage()
809- left.screen.Write([]byte("L"))
810- left.screen.ClearDamage()
811-
812- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
813- right.screen.Write([]byte("R"))
814- right.screen.ClearDamage()
815- right.screen.Write([]byte("x"))
816-
817- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
818- win.tree.Focus = right.id
819- win.panes[right.id] = right
820-
821- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
822- frame, damage := composeTabFrame(win, domain.Rect{Width: 41, Height: 4}, theme)
823-
824- require.Equal(t, 'L', frame.At(0, 0).Rune)
825- require.Equal(t, '│', frame.At(20, 0).Rune)
826- require.Equal(t, 'R', frame.At(21, 0).Rune)
827- require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "unfocused left pane should be dimmed during blit")
828- require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen")
829- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
830-}
831-
832-func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
833- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
834- left := win.focusedPane()
835- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
836- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
837- win.tree.Focus = right.id
838- win.panes[right.id] = right
839- left.screen.Write([]byte("L"))
840- right.screen.Write([]byte("R"))
841- var bars barCache
842- var composed composedFrameCache
843- sess := &session{id: "s", name: "work", tabs: []*tab{win}}
844- composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, true, solveTabLayoutLocked(win), &bars, &composed)
845- left.screen.ClearDamage()
846- right.screen.ClearDamage()
847- left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
848- right.screen.Write([]byte("x"))
849-
850- frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, false, solveTabLayoutLocked(win), &bars, &composed)
851-
852- require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
853- require.Equal(t, 'x', frame.At(22, 1).Rune)
854- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1})
855-}
856-
857-func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
858- win := newTab(nil, domain.Size{Cols: 20, Rows: 4})
859- p := win.focusedPane()
860- p.screen.Write([]byte("old"))
861- var bars barCache
862- var composed composedFrameCache
863- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
864-
865- _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
866- require.NotEmpty(t, damage)
867- require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame")
868-
869- p.screen.Write([]byte("new"))
870- require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render")
871-
872- _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
873- require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming")
874-}
875-
876-func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
877- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
878- p1 := win.focusedPane()
879- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
880- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
881- win.tree.Focus = p2.id
882- win.panes[p2.id] = p2
883- p1.screen.Write([]byte("hidden"))
884- p2.screen.Write([]byte("shown"))
885- var bars barCache
886- var composed composedFrameCache
887- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
888-
889- _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
890-
891- require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars")
892- require.Empty(t, p2.screen.Damage())
893-}
894-
895-func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
896- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
897- left := win.focusedPane()
898- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
899- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
900- win.panes[right.id] = right
901- left.screen.Write([]byte("L"))
902- right.screen.Write([]byte("R"))
903- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
904- var bars barCache
905- var composed composedFrameCache
906- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true), theme: theme}
907- win.tree.Focus = left.id
908- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
909- left.screen.ClearDamage()
910- right.screen.ClearDamage()
911- leftWasFocused := composed.frame.At(0, 1).Style
912- rightWasDimmed := composed.frame.At(21, 1).Style
913-
914- win.tree.Focus = right.id
915- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
916-
917- require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed")
918- require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed")
919- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
920-}
921-
922-func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
923- win := newTab(nil, domain.Size{Cols: 41, Rows: 5})
924- left := win.focusedPane()
925- right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5})
926- win.panes[right.id] = right
927- left.screen.Write([]byte("L"))
928- right.screen.Write([]byte("R"))
929- var bars barCache
930- var composed composedFrameCache
931- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
932- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
933- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
934- require.Equal(t, '│', composed.frame.At(20, 1).Rune)
935- left.screen.ClearDamage()
936- right.screen.ClearDamage()
937-
938- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
939- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
940-
941- require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes")
942- require.Equal(t, '─', frame.At(20, 3).Rune)
943- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
944-}
945-
946-func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
947- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
948- p := win.focusedPane()
949- p.title.processName = "shell"
950- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
951- layoutSnap := solveTabLayoutLocked(win)
952- frame := renderer.NewFrame(20, 5)
953- titleGenerations := map[layout.PaneID]uint64{p.id: p.title.generation}
954- p.screen.ClearDamage()
955-
956- composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
957- allocs := testing.AllocsPerRun(100, func() {
958- composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
959- })
960- require.Zero(t, allocs, "valid cached title-bar renders must not allocate")
961-}
962-
963-func TestComposeTabFrameStackUsesShellFallback(t *testing.T) {
964- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
965- p := win.focusedPane()
966- p.title.displayFallback = "fish"
967- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
968-
969- frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, themeui.Theme{})
970-
971- require.Equal(t, "fish", rowText(frame.Row(0))[:4])
972-}
973-
974-func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
975- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
976- p1 := win.focusedPane()
977- p1.title.processName = "one"
978- p1.screen.ClearDamage()
979- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
980- p2.title.processName = "two"
981- p2.screen.Write([]byte("T"))
982- p2.screen.ClearDamage()
983-
984- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
985- win.tree.Focus = p2.id
986- win.panes[p2.id] = p2
987-
988- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
989- frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, theme)
990-
991- require.Equal(t, "one", rowText(frame.Row(0))[:3])
992- require.Equal(t, "two", rowText(frame.Row(1))[:3])
993- require.Equal(t, 'T', frame.At(0, 2).Rune)
994- require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "collapsed title bar should use dimmed chrome")
995- require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
996-}
997-
998-func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
999- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
1000- p1 := win.focusedPane()
1001- p1.title.processName = "one"
1002- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
1003- p2.title.processName = "two"
1004- p2.screen.Write([]byte("T"))
1005- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
1006- win.tree.Focus = p2.id
1007- win.panes[p2.id] = p2
1008- var bars barCache
1009- var composed composedFrameCache
1010- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
1011- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
1012- p1.screen.ClearDamage()
1013- p2.screen.ClearDamage()
1014-
1015- p1.title.processName = "renamed"
1016- p1.title.generation++
1017- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
1018-
1019- require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
1020- require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
1021- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
1022-
1023- _, damage = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
1024- require.NotContains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}, "unchanged title must not redraw its row")
1025-}
1026-
1027 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {805 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
1028 tests := []struct {806 tests := []struct {
1029 name string807 name string
@@ -1170,56 +948,6 @@ func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
1170 }948 }
1171 }949 }
1172 950
1173-func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
1174- sess, win := newBarCacheTestSession()
1175- var cache barCache
1176-
1177- _, damage := composeClientFrame(sess, win, true, "", &cache)
1178- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1179- win.focusedPane().screen.ClearDamage()
1180- win.focusedPane().screen.Write([]byte("x"))
1181-
1182- _, damage = composeClientFrame(sess, win, false, "", &cache)
1183-
1184- require.NotEmpty(t, damage)
1185- for _, d := range damage {
1186- require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")
1187- require.NotEqual(t, win.focusedPane().screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
1188- }
1189-}
1190-
1191-func TestComposeClientFrameBarCacheDamagesChangedBottomOnly(t *testing.T) {
1192- sess, win := newBarCacheTestSession()
1193- var cache barCache
1194- composeClientFrame(sess, win, true, "", &cache)
1195- win.focusedPane().screen.ClearDamage()
1196-
1197- sess.name = "renamed"
1198- _, damage := composeClientFrame(sess, win, false, "", &cache)
1199-
1200- require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.focusedPane().screen.Frame.Height + 1, Width: win.focusedPane().screen.Frame.Width, Height: 1}}, damage)
1201-}
1202-
1203-func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
1204- sess, win := newBarCacheTestSession()
1205- var cache barCache
1206- cache.top = []renderer.Cell{renderer.BlankCell()}
1207- cache.bottom = []renderer.Cell{renderer.BlankCell()}
1208-
1209- _, damage := composeClientFrame(sess, win, true, "", &cache)
1210- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1211- win.focusedPane().screen.ClearDamage()
1212-
1213- _, damage = composeClientFrame(sess, win, false, "", &cache)
1214- require.Empty(t, damage)
1215-}
1216-
1217-func newBarCacheTestSession() (*session, *tab) {
1218- win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 3})
1219- sess := &session{id: "s", name: "work", tabs: []*tab{win}}
1220- return sess, win
1221-}
1222-
1223 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {951 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1224 newSize := domain.Size{Cols: 100, Rows: 30}952 newSize := domain.Size{Cols: 100, Rows: 30}
1225 953
@@ -1464,41 +1192,6 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1464 require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")1192 require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")
1465 }1193 }
1466 1194
1467-func TestCopyTargetRectLocked(t *testing.T) {
1468- tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
1469- cfg := domain.Defaults().Floating
1470- contentArea := domain.Rect{Y: 1, Width: 80, Height: 23}
1471- fp := newPane("floating", nil, domain.Size{Cols: 10, Rows: 4})
1472- stray := newPane("stray", nil, domain.Size{Cols: 10, Rows: 4})
1473- tb.mu.Lock()
1474- defer tb.mu.Unlock()
1475- layoutSnap := solveTabLayoutLocked(tb)
1476- main := tb.focusedPane()
1477- fp.popupGeometry = calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg)
1478- floatingFrameGeometry := fp.popupGeometry.translate(contentArea.X, contentArea.Y)
1479-
1480- cases := []struct {
1481- name string
1482- pane *pane
1483- floating *pane
1484- hasFloating bool
1485- want domain.Rect
1486- }{
1487- {name: "floating source targets committed frame-absolute popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg).translate(contentArea.X, contentArea.Y).Inner},
1488- {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}},
1489- {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}},
1490- }
1491- for _, tc := range cases {
1492- t.Run(tc.name, func(t *testing.T) {
1493- got := copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, floatingFrameGeometry)
1494- require.Equal(t, tc.want, got)
1495- if tc.hasFloating {
1496- require.NotEqual(t, fp.popupGeometry.Inner, got, "content-relative geometry must not target the client frame directly")
1497- }
1498- })
1499- }
1500-}
1501-
1502 func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {1195 func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1503 normal, releaseNormal := newBlockingPTY(t)1196 normal, releaseNormal := newBlockingPTY(t)
1504 floatingPTY, releaseFloating := newBlockingPTY(t)1197 floatingPTY, releaseFloating := newBlockingPTY(t)
M internal/usecase/daemon/status_test.go +29 -8
@@ -87,7 +87,16 @@ func TestStatusCompositionGolden(t *testing.T) {
8787 win.size = domain.Size{Cols: 12, Rows: 2}
8888 win.focusedPane().screen.Write([]byte("hello"))
8989
90- frame, damage := composeClientFrame(sess, win, true, "")
90+ frameState := capturedRenderState{
91+ reset: true,
92+ layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true},
93+ panes: []capturedPaneRenderState{{
94+ id: win.focusedPane().id, frame: win.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
95+ }},
96+ bars: barState{status: sess.statusSegments(true)},
97+ }
98+ composed := composeFrame(frameState, composeCacheInput{})
99+ frame, damage := composed.frame, composed.damage
91100
92101 require.Equal(t, 12, frame.Width)
93102 require.Equal(t, 4, frame.Height)
@@ -409,8 +418,10 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
409418 })
410419
411420 bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}
412- frame, damage := composeClientFrameWithState(bars, win, true)
413- out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)
421+ composed := composeFrame(capturedRenderState{
422+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme,
423+ }, composeCacheInput{})
424+ out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage)
414425
415426 require.NoError(t, err)
416427 require.Contains(t, string(out), ";48;2;")
@@ -788,7 +799,9 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
788799 win.focusedPane().screen.Resize(12, 2)
789800 win.size = domain.Size{Cols: 12, Rows: 2}
790801
791- frame, _ := composeClientFrame(sess, win, true, "")
802+ frame := composeFrame(capturedRenderState{
803+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true)},
804+ }, composeCacheInput{}).frame
792805
793806 require.Equal(t, " 1 (sh) ", rowText(frame.Row(0)))
794807 require.Equal(t, " 0* ", rowText(frame.Row(3)))
@@ -853,13 +866,19 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
853866 win.focusedPane().screen.Resize(30, 2)
854867 win.size = domain.Size{Cols: 30, Rows: 2}
855868
856- frame, _ := composeClientFrame(sess, win, true, "ok")
869+ frame := composeFrame(capturedRenderState{
870+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "ok"},
871+ }, composeCacheInput{}).frame
857872 require.Equal(t, " work ok", rowText(frame.Row(3)))
858873
859- frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
874+ frame = composeFrame(capturedRenderState{
875+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "1234567890123456789"},
876+ }, composeCacheInput{}).frame
860877 require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))
861878
862- frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
879+ frame = composeFrame(capturedRenderState{
880+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "selection too large to copy"},
881+ }, composeCacheInput{}).frame
863882 require.Equal(t, " work ", rowText(frame.Row(3)))
864883 }
865884
@@ -923,7 +942,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
923942 sess.tabs[1].attentionAt = time.Unix(10, 0)
924943 sess.mu.Unlock()
925944
926- frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
945+ frame := composeFrame(capturedRenderState{
946+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 18, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), attentionFrame: 1},
947+ }, composeCacheInput{}).frame
927948
928949 // Tab 2's label is enriched with its focused pane's title ("sh", the
929950 // default shell fallback) and truncated to fit alongside the bell. The
@@ -87,7 +87,16 @@ func TestStatusCompositionGolden(t *testing.T) {
87 win.size = domain.Size{Cols: 12, Rows: 2}87 win.size = domain.Size{Cols: 12, Rows: 2}
88 win.focusedPane().screen.Write([]byte("hello"))88 win.focusedPane().screen.Write([]byte("hello"))
89 89
90- frame, damage := composeClientFrame(sess, win, true, "")90+ frameState := capturedRenderState{
91+ reset: true,
92+ layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true},
93+ panes: []capturedPaneRenderState{{
94+ id: win.focusedPane().id, frame: win.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
95+ }},
96+ bars: barState{status: sess.statusSegments(true)},
97+ }
98+ composed := composeFrame(frameState, composeCacheInput{})
99+ frame, damage := composed.frame, composed.damage
91 100
92 require.Equal(t, 12, frame.Width)101 require.Equal(t, 12, frame.Width)
93 require.Equal(t, 4, frame.Height)102 require.Equal(t, 4, frame.Height)
@@ -409,8 +418,10 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
409 })418 })
410 419
411 bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}420 bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}
412- frame, damage := composeClientFrameWithState(bars, win, true)421+ composed := composeFrame(capturedRenderState{
413- out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)422+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme,
423+ }, composeCacheInput{})
424+ out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage)
414 425
415 require.NoError(t, err)426 require.NoError(t, err)
416 require.Contains(t, string(out), ";48;2;")427 require.Contains(t, string(out), ";48;2;")
@@ -788,7 +799,9 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
788 win.focusedPane().screen.Resize(12, 2)799 win.focusedPane().screen.Resize(12, 2)
789 win.size = domain.Size{Cols: 12, Rows: 2}800 win.size = domain.Size{Cols: 12, Rows: 2}
790 801
791- frame, _ := composeClientFrame(sess, win, true, "")802+ frame := composeFrame(capturedRenderState{
803+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true)},
804+ }, composeCacheInput{}).frame
792 805
793 require.Equal(t, " 1 (sh) ", rowText(frame.Row(0)))806 require.Equal(t, " 1 (sh) ", rowText(frame.Row(0)))
794 require.Equal(t, " 0* ", rowText(frame.Row(3)))807 require.Equal(t, " 0* ", rowText(frame.Row(3)))
@@ -853,13 +866,19 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
853 win.focusedPane().screen.Resize(30, 2)866 win.focusedPane().screen.Resize(30, 2)
854 win.size = domain.Size{Cols: 30, Rows: 2}867 win.size = domain.Size{Cols: 30, Rows: 2}
855 868
856- frame, _ := composeClientFrame(sess, win, true, "ok")869+ frame := composeFrame(capturedRenderState{
870+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "ok"},
871+ }, composeCacheInput{}).frame
857 require.Equal(t, " work ok", rowText(frame.Row(3)))872 require.Equal(t, " work ok", rowText(frame.Row(3)))
858 873
859- frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")874+ frame = composeFrame(capturedRenderState{
875+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "1234567890123456789"},
876+ }, composeCacheInput{}).frame
860 require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))877 require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))
861 878
862- frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")879+ frame = composeFrame(capturedRenderState{
880+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "selection too large to copy"},
881+ }, composeCacheInput{}).frame
863 require.Equal(t, " work ", rowText(frame.Row(3)))882 require.Equal(t, " work ", rowText(frame.Row(3)))
864 }883 }
865 884
@@ -923,7 +942,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
923 sess.tabs[1].attentionAt = time.Unix(10, 0)942 sess.tabs[1].attentionAt = time.Unix(10, 0)
924 sess.mu.Unlock()943 sess.mu.Unlock()
925 944
926- frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)945+ frame := composeFrame(capturedRenderState{
946+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 18, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), attentionFrame: 1},
947+ }, composeCacheInput{}).frame
927 948
928 // Tab 2's label is enriched with its focused pane's title ("sh", the949 // Tab 2's label is enriched with its focused pane's title ("sh", the
929 // default shell fallback) and truncated to fit alongside the bell. The950 // default shell fallback) and truncated to fit alongside the bell. The
M internal/usecase/daemon/title_cache_regression_test.go +16 -20
@@ -10,28 +10,24 @@ import (
1010 "github.com/bnema/vev/internal/usecase/layout"
1111 )
1212
13-func TestComposeClientFrameCachePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {
14- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
15- first := win.focusedPane()
16- var bars barCache
17- var composed composedFrameCache
18- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
13+func TestComposeFramePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {
14+ committed := composeCacheInput{}
15+ scratch := composeCacheInput{}
16+ first := layout.PaneID("pane-1")
1917
20- for i := 0; i < 8; i++ {
18+ for i := range 8 {
2119 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))
22- replacement := newPane(id, nil, domain.Size{Cols: 20, Rows: 3})
23- win.panes = map[layout.PaneID]*pane{first.id: first, id: replacement}
24- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{
25- layout.NewLeaf(first.id),
26- layout.NewLeaf(id),
27- }, Expanded: id}
28- win.tree.Focus = id
20+ state := capturedRenderState{
21+ reset: i == 0,
22+ layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, fingerprint: fmt.Sprintf("layout-%d", i), valid: true},
23+ panes: []capturedPaneRenderState{
24+ {id: first, title: "first", titleGeneration: 1, placement: layout.Placement{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true}},
25+ {id: id, title: "replacement", titleGeneration: 1, placement: layout.Placement{ID: id, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Collapsed: true}},
26+ },
27+ }
28+ out := composeFrame(state, committed, scratch)
29+ scratch, committed = committed, out.cache
2930
30- composeClientFrameWithLayoutCached(state, win, i == 0, solveTabLayoutLocked(win), &bars, &composed)
31-
32- require.Equal(t, map[layout.PaneID]uint64{
33- first.id: composed.titleGenerations[first.id],
34- id: composed.titleGenerations[id],
35- }, composed.titleGenerations)
31+ require.Equal(t, map[layout.PaneID]uint64{first: 1, id: 1}, committed.titleGenerations)
3632 }
3733 }
@@ -10,28 +10,24 @@ import (
10 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
11 )11 )
12 12
13-func TestComposeClientFrameCachePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {13+func TestComposeFramePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {
14- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})14+ committed := composeCacheInput{}
15- first := win.focusedPane()15+ scratch := composeCacheInput{}
16- var bars barCache16+ first := layout.PaneID("pane-1")
17- var composed composedFrameCache
18- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
19 17
20- for i := 0; i < 8; i++ {18+ for i := range 8 {
21 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))19 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))
22- replacement := newPane(id, nil, domain.Size{Cols: 20, Rows: 3})20+ state := capturedRenderState{
23- win.panes = map[layout.PaneID]*pane{first.id: first, id: replacement}21+ reset: i == 0,
24- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{22+ layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, fingerprint: fmt.Sprintf("layout-%d", i), valid: true},
25- layout.NewLeaf(first.id),23+ panes: []capturedPaneRenderState{
26- layout.NewLeaf(id),24+ {id: first, title: "first", titleGeneration: 1, placement: layout.Placement{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true}},
27- }, Expanded: id}25+ {id: id, title: "replacement", titleGeneration: 1, placement: layout.Placement{ID: id, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Collapsed: true}},
28- win.tree.Focus = id26+ },
27+ }
28+ out := composeFrame(state, committed, scratch)
29+ scratch, committed = committed, out.cache
29 30
30- composeClientFrameWithLayoutCached(state, win, i == 0, solveTabLayoutLocked(win), &bars, &composed)31+ require.Equal(t, map[layout.PaneID]uint64{first: 1, id: 1}, committed.titleGenerations)
31-
32- require.Equal(t, map[layout.PaneID]uint64{
33- first.id: composed.titleGenerations[first.id],
34- id: composed.titleGenerations[id],
35- }, composed.titleGenerations)
36 }32 }
37 }33 }

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