fix(daemon): restore base frame after floating close
- commit
- 0d51098e3eddc905828b64b528909f6236e3b1a0
- author
- brice · · 2026-07-12 07:41 UTC
- parent
- aecc277
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +26 -11
M internal/usecase/daemon/render_pipeline.go +13 -11
| @@ -33,6 +33,7 @@ type composeCacheInput struct { | |||
| 33 | 33 | layoutFingerprint string | |
| 34 | 34 | titleGenerations map[layout.PaneID]uint64 | |
| 35 | 35 | damage []renderer.Damage | |
| 36 | + | floatingVisible bool | |
| 36 | 37 | floatingGeneration uint64 | |
| 37 | 38 | floatingGeometry floatingGeometry | |
| 38 | 39 | floatingTitleGeneration uint64 | |
| @@ -80,7 +81,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 80 | 81 | drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme)) | |
| 81 | 82 | } | |
| 82 | 83 | ||
| 83 | - | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme | |
| 84 | + | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible | |
| 84 | 85 | titles := scratch.titleGenerations | |
| 85 | 86 | if titles == nil { | |
| 86 | 87 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | |
| @@ -105,9 +106,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 105 | 106 | damage = append(damage, d) | |
| 106 | 107 | } | |
| 107 | 108 | } | |
| 109 | + | // Keep the committed cache unadorned: floating composition clones this | |
| 110 | + | // base, so closing or moving a popup cannot retain dimmed/bordered cells. | |
| 111 | + | baseFrame := frame | |
| 108 | 112 | if state.floating.visible { | |
| 109 | 113 | var floatingDamage []renderer.Damage | |
| 110 | - | frame, floatingDamage = composeCapturedFloatingFrame(frame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active()) | |
| 114 | + | frame, floatingDamage = composeCapturedFloatingFrame(baseFrame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active()) | |
| 111 | 115 | damage = floatingDamage | |
| 112 | 116 | } | |
| 113 | 117 | if !full { | |
| @@ -118,15 +122,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 118 | 122 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | |
| 119 | 123 | } | |
| 120 | 124 | } | |
| 121 | - | var baseFrame renderer.Frame | |
| 122 | 125 | if state.overlays.active() { | |
| 123 | - | // Overlay composition mutates the visible frame but must never publish it | |
| 124 | - | // as the unadorned cache. | |
| 125 | - | baseFrame = frame.Clone() | |
| 126 | - | } | |
| 127 | - | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |
| 128 | - | if !state.overlays.active() { | |
| 129 | - | baseFrame = frame | |
| 126 | + | // Without a floating frame, overlay composition would otherwise mutate | |
| 127 | + | // the base cache in place. Floating composition already owns a clone. | |
| 128 | + | if !state.floating.visible { | |
| 129 | + | baseFrame = frame.Clone() | |
| 130 | + | } | |
| 131 | + | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |
| 130 | 132 | } | |
| 131 | 133 | if full || state.overlays.active() { | |
| 132 | 134 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| @@ -134,7 +136,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 134 | 136 | cursorInputs := state.cursor | |
| 135 | 137 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | |
| 136 | 138 | cursor := desiredCapturedCursor(cursorInputs) | |
| 137 | - | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| 139 | + | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| 138 | 140 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 139 | 141 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 140 | 142 | } | |
| @@ -33,6 +33,7 @@ type composeCacheInput struct { | |||||
| 33 | layoutFingerprint string | 33 | layoutFingerprint string | ||
| 34 | titleGenerations map[layout.PaneID]uint64 | 34 | titleGenerations map[layout.PaneID]uint64 | ||
| 35 | damage []renderer.Damage | 35 | damage []renderer.Damage | ||
| 36 | + | floatingVisible bool | |||
| 36 | floatingGeneration uint64 | 37 | floatingGeneration uint64 | ||
| 37 | floatingGeometry floatingGeometry | 38 | floatingGeometry floatingGeometry | ||
| 38 | floatingTitleGeneration uint64 | 39 | floatingTitleGeneration uint64 | ||
| @@ -80,7 +81,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 80 | drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme)) | 81 | drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme)) | ||
| 81 | } | 82 | } | ||
| 82 | 83 | ||||
| 83 | - | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme | 84 | + | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible |
| 84 | titles := scratch.titleGenerations | 85 | titles := scratch.titleGenerations | ||
| 85 | if titles == nil { | 86 | if titles == nil { | ||
| 86 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | 87 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | ||
| @@ -105,9 +106,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 105 | damage = append(damage, d) | 106 | damage = append(damage, d) | ||
| 106 | } | 107 | } | ||
| 107 | } | 108 | } | ||
| 109 | + | // Keep the committed cache unadorned: floating composition clones this | |||
| 110 | + | // base, so closing or moving a popup cannot retain dimmed/bordered cells. | |||
| 111 | + | baseFrame := frame | |||
| 108 | if state.floating.visible { | 112 | if state.floating.visible { | ||
| 109 | var floatingDamage []renderer.Damage | 113 | var floatingDamage []renderer.Damage | ||
| 110 | - | frame, floatingDamage = composeCapturedFloatingFrame(frame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active()) | 114 | + | frame, floatingDamage = composeCapturedFloatingFrame(baseFrame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active()) |
| 111 | damage = floatingDamage | 115 | damage = floatingDamage | ||
| 112 | } | 116 | } | ||
| 113 | if !full { | 117 | if !full { | ||
| @@ -118,15 +122,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 118 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | 122 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | ||
| 119 | } | 123 | } | ||
| 120 | } | 124 | } | ||
| 121 | - | var baseFrame renderer.Frame | |||
| 122 | if state.overlays.active() { | 125 | if state.overlays.active() { | ||
| 123 | - | // Overlay composition mutates the visible frame but must never publish it | 126 | + | // Without a floating frame, overlay composition would otherwise mutate |
| 124 | - | // as the unadorned cache. | 127 | + | // the base cache in place. Floating composition already owns a clone. |
| 125 | - | baseFrame = frame.Clone() | 128 | + | if !state.floating.visible { |
| 126 | - | } | 129 | + | baseFrame = frame.Clone() |
| 127 | - | frame, damage = composeCapturedOverlays(state, frame, damage, content) | 130 | + | } |
| 128 | - | if !state.overlays.active() { | 131 | + | frame, damage = composeCapturedOverlays(state, frame, damage, content) |
| 129 | - | baseFrame = frame | |||
| 130 | } | 132 | } | ||
| 131 | if full || state.overlays.active() { | 133 | if full || state.overlays.active() { | ||
| 132 | damage = []renderer.Damage{renderer.FullRedraw()} | 134 | damage = []renderer.Damage{renderer.FullRedraw()} | ||
| @@ -134,7 +136,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 134 | cursorInputs := state.cursor | 136 | cursorInputs := state.cursor | ||
| 135 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | 137 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | ||
| 136 | cursor := desiredCapturedCursor(cursorInputs) | 138 | cursor := desiredCapturedCursor(cursorInputs) | ||
| 137 | - | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | 139 | + | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} |
| 138 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | 140 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | ||
| 139 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 141 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 140 | } | 142 | } | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +13 -0
| @@ -73,6 +73,19 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) { | |||
| 73 | 73 | } | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) { | |
| 77 | + | visible := cacheState("base", 1) | |
| 78 | + | committed := composeFrame(visible, composeCacheInput{}) | |
| 79 | + | ||
| 80 | + | hidden := visible | |
| 81 | + | hidden.floating = capturedFloatingRenderState{} | |
| 82 | + | hidden.panes[0].damage = nil | |
| 83 | + | result := composeFrame(hidden, committed.cache, committed.cache) | |
| 84 | + | ||
| 85 | + | require.Equal(t, 's', result.frame.Row(1)[2].Rune) | |
| 86 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage) | |
| 87 | + | } | |
| 88 | + | ||
| 76 | 89 | func cacheState(title string, generation uint64) capturedRenderState { | |
| 77 | 90 | pane := renderer.NewFrame(6, 1) | |
| 78 | 91 | for x, r := range title[:min(len(title), 6)] { | |
| @@ -73,6 +73,19 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) { | |||||
| 73 | } | 73 | } | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) { | |||
| 77 | + | visible := cacheState("base", 1) | |||
| 78 | + | committed := composeFrame(visible, composeCacheInput{}) | |||
| 79 | + | ||||
| 80 | + | hidden := visible | |||
| 81 | + | hidden.floating = capturedFloatingRenderState{} | |||
| 82 | + | hidden.panes[0].damage = nil | |||
| 83 | + | result := composeFrame(hidden, committed.cache, committed.cache) | |||
| 84 | + | ||||
| 85 | + | require.Equal(t, 's', result.frame.Row(1)[2].Rune) | |||
| 86 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 76 | func cacheState(title string, generation uint64) capturedRenderState { | 89 | func cacheState(title string, generation uint64) capturedRenderState { | ||
| 77 | pane := renderer.NewFrame(6, 1) | 90 | pane := renderer.NewFrame(6, 1) | ||
| 78 | for x, r := range title[:min(len(title), 6)] { | 91 | for x, r := range title[:min(len(title), 6)] { | ||