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