fix(daemon): reuse bounded render snapshots
- commit
- dc9a821d361977e86dd8bb068d4c505d9f26704a
- author
- brice · · 2026-07-12 05:52 UTC
- parent
- f9d041e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +45 -29
M internal/usecase/daemon/client.go +6 -4
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 18 | 19 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 19 | 20 | ) | |
| @@ -35,10 +36,11 @@ type attachedClient struct { | |||
| 35 | 36 | // exact coordinator incarnation. paint rechecks it under sendMu. | |
| 36 | 37 | coordinatorReadyEpoch atomic.Uint64 | |
| 37 | 38 | echoAck atomic.Uint64 | |
| 38 | - | pipelineCache composeCacheInput // only touched while sendMu is held | |
| 39 | - | resizePaint pendingByteTimer // guarded by sendMu | |
| 40 | - | resizePaintGeneration uint64 // guarded by sendMu | |
| 41 | - | resizePaintPending bool // guarded by sendMu | |
| 39 | + | pipelineCache composeCacheInput // only touched while sendMu is held | |
| 40 | + | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | |
| 41 | + | resizePaint pendingByteTimer // guarded by sendMu | |
| 42 | + | resizePaintGeneration uint64 // guarded by sendMu | |
| 43 | + | resizePaintPending bool // guarded by sendMu | |
| 42 | 44 | size domain.Size | |
| 43 | 45 | keys *keys.Router | |
| 44 | 46 | sess Guarded[*session] | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 16 | "github.com/bnema/vev/internal/usecase/keys" | 16 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 17 | "github.com/bnema/vev/internal/usecase/mouse" | 18 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | 19 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 19 | ) | 20 | ) | ||
| @@ -35,10 +36,11 @@ type attachedClient struct { | |||||
| 35 | // exact coordinator incarnation. paint rechecks it under sendMu. | 36 | // exact coordinator incarnation. paint rechecks it under sendMu. | ||
| 36 | coordinatorReadyEpoch atomic.Uint64 | 37 | coordinatorReadyEpoch atomic.Uint64 | ||
| 37 | echoAck atomic.Uint64 | 38 | echoAck atomic.Uint64 | ||
| 38 | - | pipelineCache composeCacheInput // only touched while sendMu is held | 39 | + | pipelineCache composeCacheInput // only touched while sendMu is held |
| 39 | - | resizePaint pendingByteTimer // guarded by sendMu | 40 | + | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held |
| 40 | - | resizePaintGeneration uint64 // guarded by sendMu | 41 | + | resizePaint pendingByteTimer // guarded by sendMu |
| 41 | - | resizePaintPending bool // guarded by sendMu | 42 | + | resizePaintGeneration uint64 // guarded by sendMu |
| 43 | + | resizePaintPending bool // guarded by sendMu | |||
| 42 | size domain.Size | 44 | size domain.Size | ||
| 43 | keys *keys.Router | 45 | keys *keys.Router | ||
| 44 | sess Guarded[*session] | 46 | sess Guarded[*session] | ||
M internal/usecase/daemon/copymode.go +0 -5
| @@ -402,11 +402,6 @@ func isCopyEscapePrefix(data []byte) bool { | |||
| 402 | 402 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | |
| 403 | 403 | } | |
| 404 | 404 | ||
| 405 | - | func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 406 | - | styleSet := resolveThemeStyles(styles) | |
| 407 | - | return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render) | |
| 408 | - | } | |
| 409 | - | ||
| 410 | 405 | func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | |
| 411 | 406 | if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | |
| 412 | 407 | return frame, nil | |
| @@ -402,11 +402,6 @@ func isCopyEscapePrefix(data []byte) bool { | |||||
| 402 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | 402 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | ||
| 403 | } | 403 | } | ||
| 404 | 404 | ||||
| 405 | - | func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |||
| 406 | - | styleSet := resolveThemeStyles(styles) | |||
| 407 | - | return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render) | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | 405 | func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | ||
| 411 | if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | 406 | if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | ||
| 412 | return frame, nil | 407 | return frame, nil | ||
M internal/usecase/daemon/mouse_panes.go +0 -4
| @@ -64,10 +64,6 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot { | |||
| 64 | 64 | return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok} | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | - | func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool { | |
| 68 | - | return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root) | |
| 69 | - | } | |
| 70 | - | ||
| 71 | 67 | func layoutFingerprint(root *layout.Node) string { | |
| 72 | 68 | var b strings.Builder | |
| 73 | 69 | writeLayoutFingerprint(&b, root) | |
| @@ -64,10 +64,6 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot { | |||||
| 64 | return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok} | 64 | return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok} | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | - | func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool { | |||
| 68 | - | return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root) | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | func layoutFingerprint(root *layout.Node) string { | 67 | func layoutFingerprint(root *layout.Node) string { | ||
| 72 | var b strings.Builder | 68 | var b strings.Builder | ||
| 73 | writeLayoutFingerprint(&b, root) | 69 | writeLayoutFingerprint(&b, root) | ||
M internal/usecase/daemon/prompt.go +0 -6
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 8 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 11 | func promptModalFor(title string) ui.Modal { | |
| @@ -95,8 +94,3 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 95 | 94 | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 96 | 95 | } | |
| 97 | 96 | } | |
| 98 | - | ||
| 99 | - | func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 100 | - | styleSet := resolveThemeStyles(styles) | |
| 101 | - | return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render) | |
| 102 | - | } | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | ||
| 8 | "github.com/bnema/vev/internal/usecase/ui" | 8 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | ) | 9 | ) | ||
| 11 | 10 | ||||
| 12 | func promptModalFor(title string) ui.Modal { | 11 | func promptModalFor(title string) ui.Modal { | ||
| @@ -95,8 +94,3 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||||
| 95 | d.invalidateRender(sess, ac, true, "prompt.go") | 94 | d.invalidateRender(sess, ac, true, "prompt.go") | ||
| 96 | } | 95 | } | ||
| 97 | } | 96 | } | ||
| 98 | - | ||||
| 99 | - | func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |||
| 100 | - | styleSet := resolveThemeStyles(styles) | |||
| 101 | - | return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render) | |||
| 102 | - | } | |||
M internal/usecase/daemon/render_capture.go +18 -5
| @@ -74,7 +74,6 @@ type capturedOverlayRenderState struct { | |||
| 74 | 74 | copyMode *scopy.Mode | |
| 75 | 75 | copySnapshot *scopy.Snapshot | |
| 76 | 76 | copyPaneID layout.PaneID | |
| 77 | - | copyTarget domain.Rect | |
| 78 | 77 | copyFeedback string | |
| 79 | 78 | paletteGuidance string | |
| 80 | 79 | copySearch, picker, palette, prompt capturedModal | |
| @@ -110,11 +109,17 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageC | |||
| 110 | 109 | ||
| 111 | 110 | width := min(max(visible.Width, 0), p.screen.Frame.Width) | |
| 112 | 111 | height := min(max(visible.Height, 0), p.screen.Frame.Height) | |
| 112 | + | needsFrame := len(damage.Damage) > 0 | |
| 113 | 113 | if out.frame.Width != width || out.frame.Height != height { | |
| 114 | 114 | out.frame = renderer.NewFrame(width, height) | |
| 115 | + | needsFrame = true | |
| 115 | 116 | } | |
| 116 | - | for y := range height { | |
| 117 | - | copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width]) | |
| 117 | + | // A retained attachment snapshot is already an immutable copy of this | |
| 118 | + | // pane. With no VT damage, retain it rather than copying inactive panes. | |
| 119 | + | if needsFrame { | |
| 120 | + | for y := range height { | |
| 121 | + | copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width]) | |
| 122 | + | } | |
| 118 | 123 | } | |
| 119 | 124 | ||
| 120 | 125 | if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | |
| @@ -195,7 +200,10 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 195 | 200 | visible = domain.Rect{} | |
| 196 | 201 | } | |
| 197 | 202 | p.mu.Lock() | |
| 198 | - | captured := capturePaneRenderStateLocked(p, visible, mode) | |
| 203 | + | if ac.captureFrames == nil { | |
| 204 | + | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |
| 205 | + | } | |
| 206 | + | captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id]) | |
| 199 | 207 | captured.placement = placement | |
| 200 | 208 | captured.focused = placement.ID == layoutSnap.focus | |
| 201 | 209 | if captured.focused { | |
| @@ -207,13 +215,18 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 207 | 215 | translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...) | |
| 208 | 216 | } | |
| 209 | 217 | captured.damage = translated | |
| 218 | + | ac.captureFrames[captured.id] = captured | |
| 210 | 219 | state.panes = append(state.panes, captured) | |
| 211 | 220 | } | |
| 212 | 221 | if tb.floating.state == floatingVisible && tb.floating.pane != nil { | |
| 213 | 222 | p := tb.floating.pane | |
| 214 | 223 | p.mu.Lock() | |
| 215 | 224 | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) | |
| 216 | - | captured := capturePaneRenderStateLocked(p, geometry.Inner, mode) | |
| 225 | + | if ac.captureFrames == nil { | |
| 226 | + | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |
| 227 | + | } | |
| 228 | + | captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id]) | |
| 229 | + | ac.captureFrames[captured.id] = captured | |
| 217 | 230 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | |
| 218 | 231 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | |
| 219 | 232 | p.mu.Unlock() | |
| @@ -74,7 +74,6 @@ type capturedOverlayRenderState struct { | |||||
| 74 | copyMode *scopy.Mode | 74 | copyMode *scopy.Mode | ||
| 75 | copySnapshot *scopy.Snapshot | 75 | copySnapshot *scopy.Snapshot | ||
| 76 | copyPaneID layout.PaneID | 76 | copyPaneID layout.PaneID | ||
| 77 | - | copyTarget domain.Rect | |||
| 78 | copyFeedback string | 77 | copyFeedback string | ||
| 79 | paletteGuidance string | 78 | paletteGuidance string | ||
| 80 | copySearch, picker, palette, prompt capturedModal | 79 | copySearch, picker, palette, prompt capturedModal | ||
| @@ -110,11 +109,17 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageC | |||||
| 110 | 109 | ||||
| 111 | width := min(max(visible.Width, 0), p.screen.Frame.Width) | 110 | width := min(max(visible.Width, 0), p.screen.Frame.Width) | ||
| 112 | height := min(max(visible.Height, 0), p.screen.Frame.Height) | 111 | height := min(max(visible.Height, 0), p.screen.Frame.Height) | ||
| 112 | + | needsFrame := len(damage.Damage) > 0 | |||
| 113 | if out.frame.Width != width || out.frame.Height != height { | 113 | if out.frame.Width != width || out.frame.Height != height { | ||
| 114 | out.frame = renderer.NewFrame(width, height) | 114 | out.frame = renderer.NewFrame(width, height) | ||
| 115 | + | needsFrame = true | |||
| 115 | } | 116 | } | ||
| 116 | - | for y := range height { | 117 | + | // A retained attachment snapshot is already an immutable copy of this |
| 117 | - | copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width]) | 118 | + | // pane. With no VT damage, retain it rather than copying inactive panes. |
| 119 | + | if needsFrame { | |||
| 120 | + | for y := range height { | |||
| 121 | + | copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width]) | |||
| 122 | + | } | |||
| 118 | } | 123 | } | ||
| 119 | 124 | ||||
| 120 | if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | 125 | if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | ||
| @@ -195,7 +200,10 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 195 | visible = domain.Rect{} | 200 | visible = domain.Rect{} | ||
| 196 | } | 201 | } | ||
| 197 | p.mu.Lock() | 202 | p.mu.Lock() | ||
| 198 | - | captured := capturePaneRenderStateLocked(p, visible, mode) | 203 | + | if ac.captureFrames == nil { |
| 204 | + | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |||
| 205 | + | } | |||
| 206 | + | captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id]) | |||
| 199 | captured.placement = placement | 207 | captured.placement = placement | ||
| 200 | captured.focused = placement.ID == layoutSnap.focus | 208 | captured.focused = placement.ID == layoutSnap.focus | ||
| 201 | if captured.focused { | 209 | if captured.focused { | ||
| @@ -207,13 +215,18 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 207 | translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...) | 215 | translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...) | ||
| 208 | } | 216 | } | ||
| 209 | captured.damage = translated | 217 | captured.damage = translated | ||
| 218 | + | ac.captureFrames[captured.id] = captured | |||
| 210 | state.panes = append(state.panes, captured) | 219 | state.panes = append(state.panes, captured) | ||
| 211 | } | 220 | } | ||
| 212 | if tb.floating.state == floatingVisible && tb.floating.pane != nil { | 221 | if tb.floating.state == floatingVisible && tb.floating.pane != nil { | ||
| 213 | p := tb.floating.pane | 222 | p := tb.floating.pane | ||
| 214 | p.mu.Lock() | 223 | p.mu.Lock() | ||
| 215 | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) | 224 | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) | ||
| 216 | - | captured := capturePaneRenderStateLocked(p, geometry.Inner, mode) | 225 | + | if ac.captureFrames == nil { |
| 226 | + | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |||
| 227 | + | } | |||
| 228 | + | captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id]) | |||
| 229 | + | ac.captureFrames[captured.id] = captured | |||
| 217 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | 230 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | ||
| 218 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | 231 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | ||
| 219 | p.mu.Unlock() | 232 | p.mu.Unlock() | ||
M internal/usecase/daemon/render_pipeline.go +21 -5
| @@ -25,6 +25,7 @@ type composeCacheInput struct { | |||
| 25 | 25 | floatingGeometry floatingGeometry | |
| 26 | 26 | floatingTitleGeneration uint64 | |
| 27 | 27 | bars barCache | |
| 28 | + | theme themeui.Theme | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | type composedRenderFrame struct { | |
| @@ -42,7 +43,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||
| 42 | 43 | if width <= 0 || rows < 0 { | |
| 43 | 44 | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | |
| 44 | 45 | } | |
| 45 | - | frame := renderer.NewFrame(width, rows+2) | |
| 46 | + | // The attachment-owned cache is safe to reuse while sendMu serializes this | |
| 47 | + | // transaction. It contains only prior composed cells, never VT-owned cells. | |
| 48 | + | // Reusing it avoids cloning a client-sized frame on every incremental paint. | |
| 49 | + | canReuseFrame := in.frame.Width == width && in.frame.Height == rows+2 && in.layoutFingerprint == state.layout.fingerprint | |
| 50 | + | frame := in.frame | |
| 51 | + | if !canReuseFrame { | |
| 52 | + | frame = renderer.NewFrame(width, rows+2) | |
| 53 | + | } | |
| 46 | 54 | styles := newThemeStyles(state.theme) | |
| 47 | 55 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 48 | 56 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| @@ -51,7 +59,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||
| 51 | 59 | drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme)) | |
| 52 | 60 | } | |
| 53 | 61 | ||
| 54 | - | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint | |
| 62 | + | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme | |
| 55 | 63 | titles := make(map[layout.PaneID]uint64, len(state.panes)) | |
| 56 | 64 | var damage []renderer.Damage | |
| 57 | 65 | for _, pane := range state.panes { | |
| @@ -63,7 +71,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||
| 63 | 71 | } | |
| 64 | 72 | titles[pane.id] = pane.titleGeneration | |
| 65 | 73 | } | |
| 66 | - | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 74 | + | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) { | |
| 67 | 75 | blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme) | |
| 68 | 76 | } | |
| 69 | 77 | for _, d := range pane.damage { | |
| @@ -86,15 +94,23 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||
| 86 | 94 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | |
| 87 | 95 | } | |
| 88 | 96 | } | |
| 89 | - | baseFrame := frame.Clone() | |
| 97 | + | var baseFrame renderer.Frame | |
| 98 | + | if state.overlays.active() { | |
| 99 | + | // Overlay composition mutates the visible frame but must never publish it | |
| 100 | + | // as the unadorned cache. | |
| 101 | + | baseFrame = frame.Clone() | |
| 102 | + | } | |
| 90 | 103 | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |
| 104 | + | if !state.overlays.active() { | |
| 105 | + | baseFrame = frame | |
| 106 | + | } | |
| 91 | 107 | if full || state.overlays.active() { | |
| 92 | 108 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 93 | 109 | } | |
| 94 | 110 | cursorInputs := state.cursor | |
| 95 | 111 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | |
| 96 | 112 | 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} | |
| 113 | + | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration} | |
| 98 | 114 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 99 | 115 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 100 | 116 | } | |
| @@ -25,6 +25,7 @@ type composeCacheInput struct { | |||||
| 25 | floatingGeometry floatingGeometry | 25 | floatingGeometry floatingGeometry | ||
| 26 | floatingTitleGeneration uint64 | 26 | floatingTitleGeneration uint64 | ||
| 27 | bars barCache | 27 | bars barCache | ||
| 28 | + | theme themeui.Theme | |||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | type composedRenderFrame struct { | 31 | type composedRenderFrame struct { | ||
| @@ -42,7 +43,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||||
| 42 | if width <= 0 || rows < 0 { | 43 | if width <= 0 || rows < 0 { | ||
| 43 | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | 44 | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | ||
| 44 | } | 45 | } | ||
| 45 | - | frame := renderer.NewFrame(width, rows+2) | 46 | + | // The attachment-owned cache is safe to reuse while sendMu serializes this |
| 47 | + | // transaction. It contains only prior composed cells, never VT-owned cells. | |||
| 48 | + | // Reusing it avoids cloning a client-sized frame on every incremental paint. | |||
| 49 | + | canReuseFrame := in.frame.Width == width && in.frame.Height == rows+2 && in.layoutFingerprint == state.layout.fingerprint | |||
| 50 | + | frame := in.frame | |||
| 51 | + | if !canReuseFrame { | |||
| 52 | + | frame = renderer.NewFrame(width, rows+2) | |||
| 53 | + | } | |||
| 46 | styles := newThemeStyles(state.theme) | 54 | styles := newThemeStyles(state.theme) | ||
| 47 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | 55 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | ||
| 48 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | 56 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | ||
| @@ -51,7 +59,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||||
| 51 | drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme)) | 59 | drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme)) | ||
| 52 | } | 60 | } | ||
| 53 | 61 | ||||
| 54 | - | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint | 62 | + | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme |
| 55 | titles := make(map[layout.PaneID]uint64, len(state.panes)) | 63 | titles := make(map[layout.PaneID]uint64, len(state.panes)) | ||
| 56 | var damage []renderer.Damage | 64 | var damage []renderer.Damage | ||
| 57 | for _, pane := range state.panes { | 65 | for _, pane := range state.panes { | ||
| @@ -63,7 +71,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||||
| 63 | } | 71 | } | ||
| 64 | titles[pane.id] = pane.titleGeneration | 72 | titles[pane.id] = pane.titleGeneration | ||
| 65 | } | 73 | } | ||
| 66 | - | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | 74 | + | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) { |
| 67 | blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme) | 75 | blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme) | ||
| 68 | } | 76 | } | ||
| 69 | for _, d := range pane.damage { | 77 | for _, d := range pane.damage { | ||
| @@ -86,15 +94,23 @@ 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}) | 94 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | ||
| 87 | } | 95 | } | ||
| 88 | } | 96 | } | ||
| 89 | - | baseFrame := frame.Clone() | 97 | + | var baseFrame renderer.Frame |
| 98 | + | if state.overlays.active() { | |||
| 99 | + | // Overlay composition mutates the visible frame but must never publish it | |||
| 100 | + | // as the unadorned cache. | |||
| 101 | + | baseFrame = frame.Clone() | |||
| 102 | + | } | |||
| 90 | frame, damage = composeCapturedOverlays(state, frame, damage, content) | 103 | frame, damage = composeCapturedOverlays(state, frame, damage, content) | ||
| 104 | + | if !state.overlays.active() { | |||
| 105 | + | baseFrame = frame | |||
| 106 | + | } | |||
| 91 | if full || state.overlays.active() { | 107 | if full || state.overlays.active() { | ||
| 92 | damage = []renderer.Damage{renderer.FullRedraw()} | 108 | damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 93 | } | 109 | } | ||
| 94 | cursorInputs := state.cursor | 110 | cursorInputs := state.cursor | ||
| 95 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | 111 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | ||
| 96 | cursor := desiredCapturedCursor(cursorInputs) | 112 | 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} | 113 | + | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, 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)) | 114 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | ||
| 99 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 115 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 100 | } | 116 | } | ||