test(daemon): remove duplicated legacy render paths
- commit
- 30f99e6f26831ea6ef4043927bdeadc39ba35280
- author
- brice · · 2026-07-14 20:01 UTC
- parent
- 8476ebc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 366 | 366 | require.NotContains(t, string(data), "MIDSCREENMARKER") | |
| 367 | 367 | } | |
| 368 | 368 | ||
| 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) { | |
| 374 | 373 | d, sess, _, _, releases := newManualTabSession(t, 2) | |
| 375 | 374 | defer releases[0]() | |
| 376 | 375 | defer releases[1]() | |
| @@ -381,14 +380,17 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi | |||
| 381 | 380 | sess.mu.Unlock() | |
| 382 | 381 | ||
| 383 | 382 | tb := sess.tabs[0] | |
| 384 | - | var cache barCache | |
| 385 | 383 | 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) | |
| 388 | 388 | tb.focusedPane().screen.ClearDamage() | |
| 389 | 389 | ||
| 390 | 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 | 394 | require.NotEmpty(t, damage) | |
| 393 | 395 | lastRow := tb.focusedPane().screen.Frame.Height + 1 | |
| 394 | 396 | 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 the | 369 | + | // TestComposeFrameAttentionFrameChangeDamagesOnlyBars pins the same invariant |
| 370 | - | // same invariant at the composeClientFrameWithState level: with a warm | 370 | + | // through the production composition pipeline: with a warm cache and no pane |
| 371 | - | // barCache and no screen damage, changing only bars.attentionFrame must | 371 | + | // 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 + 1 | 395 | 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) { | |||
| 151 | 151 | mode := scopy.NewMode(snap) | |
| 152 | 152 | ||
| 153 | 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 | 163 | frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | |
| 156 | 164 | ||
| 157 | 165 | 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) { | |||
| 267 | 267 | base := renderer.NewFrame(40, 12) | |
| 268 | 268 | content := domain.Rect{Y: 1, Width: 40, Height: 10} | |
| 269 | 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) | |
| 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) | |
| 272 | 281 | var gotTitle strings.Builder | |
| 273 | 282 | for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ { | |
| 274 | 283 | gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune) | |
| @@ -276,10 +285,14 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) { | |||
| 276 | 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 | 289 | 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{} | |
| 283 | 296 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 284 | 297 | ||
| 285 | 298 | start := make(chan struct{}) | |
| @@ -289,7 +302,6 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 289 | 302 | defer wg.Done() | |
| 290 | 303 | <-start | |
| 291 | 304 | for range 500 { | |
| 292 | - | // This is the same pane lock used by ptyReader around Screen.Write. | |
| 293 | 305 | p.mu.Lock() | |
| 294 | 306 | p.screen.Write([]byte("\x1b[Hreader")) | |
| 295 | 307 | p.mu.Unlock() | |
| @@ -298,9 +310,14 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 298 | 310 | go func() { | |
| 299 | 311 | defer wg.Done() | |
| 300 | 312 | <-start | |
| 301 | - | cache := &composedFrameCache{} | |
| 313 | + | cache := composeCacheInput{} | |
| 302 | 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 | 323 | 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.Builder | 281 | 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 | <-start | 303 | <-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 | <-start | 312 | <-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 ( | |||
| 16 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | 17 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | + | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| 21 | 22 | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |
| @@ -309,7 +310,15 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) { | |||
| 309 | 310 | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z")) | |
| 310 | 311 | require.Equal(t, []byte("Z"), <-writes) | |
| 311 | 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 | 322 | require.Contains(t, frameRowString(frame, 1), "survivor") | |
| 314 | 323 | } | |
| 315 | 324 | ||
| @@ -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 ... | |||
| 86 | 86 | if titles == nil { | |
| 87 | 87 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | |
| 88 | 88 | } | |
| 89 | + | clear(titles) | |
| 89 | 90 | damage := scratch.damage[:0] | |
| 90 | 91 | for _, pane := range state.panes { | |
| 91 | 92 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "go/ast" | |
| 5 | + | "go/parser" | |
| 6 | + | "go/token" | |
| 4 | 7 | "io" | |
| 5 | 8 | "os" | |
| 6 | 9 | "path/filepath" | |
| @@ -163,14 +166,22 @@ func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) { | |||
| 163 | 166 | } | |
| 164 | 167 | ||
| 165 | 168 | 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) | |
| 168 | 173 | 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 | + | } | |
| 174 | 185 | } | |
| 175 | 186 | } | |
| 176 | 187 | data, err := os.ReadFile("render_pipeline.go") | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package daemon | 1 | 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) { | |||
| 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 | 805 | func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |
| 1028 | 806 | tests := []struct { | |
| 1029 | 807 | 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 | 951 | func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |
| 1224 | 952 | newSize := domain.Size{Cols: 100, Rows: 30} | |
| 1225 | 953 | ||
| @@ -1464,41 +1192,6 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1464 | 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 | 1195 | func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |
| 1503 | 1196 | normal, releaseNormal := newBlockingPTY(t) | |
| 1504 | 1197 | 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 string | 807 | 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) { | |||
| 87 | 87 | win.size = domain.Size{Cols: 12, Rows: 2} | |
| 88 | 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 | 101 | require.Equal(t, 12, frame.Width) | |
| 93 | 102 | require.Equal(t, 4, frame.Height) | |
| @@ -409,8 +418,10 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||
| 409 | 418 | }) | |
| 410 | 419 | ||
| 411 | 420 | 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) | |
| 414 | 425 | ||
| 415 | 426 | require.NoError(t, err) | |
| 416 | 427 | require.Contains(t, string(out), ";48;2;") | |
| @@ -788,7 +799,9 @@ func TestStatusMarksEphemeralSession(t *testing.T) { | |||
| 788 | 799 | win.focusedPane().screen.Resize(12, 2) | |
| 789 | 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 | 806 | require.Equal(t, " 1 (sh) ", rowText(frame.Row(0))) | |
| 794 | 807 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | |
| @@ -853,13 +866,19 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | |||
| 853 | 866 | win.focusedPane().screen.Resize(30, 2) | |
| 854 | 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 | 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 | 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 | 882 | require.Equal(t, " work ", rowText(frame.Row(3))) | |
| 864 | 883 | } | |
| 865 | 884 | ||
| @@ -923,7 +942,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 923 | 942 | sess.tabs[1].attentionAt = time.Unix(10, 0) | |
| 924 | 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 | 949 | // Tab 2's label is enriched with its focused pane's title ("sh", the | |
| 929 | 950 | // 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", the | 949 | // 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. The | 950 | // 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 ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 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") | |
| 19 | 17 | ||
| 20 | - | for i := 0; i < 8; i++ { | |
| 18 | + | for i := range 8 { | |
| 21 | 19 | 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 | |
| 29 | 30 | ||
| 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) | |
| 36 | 32 | } | |
| 37 | 33 | } | |
| @@ -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 barCache | 16 | + | 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 = id | 26 | + | }, |
| 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 | } | ||