fix(render): blank cells outside the shared layout in larger windows
- commit
- 46a921feb4372d2b7fbd400e57792ec956662d77
- author
- brice · · 2026-09-26 08:34 UTC
- committer
- brice · · 2026-09-26 08:37 UTC
- parent
- ab824cf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +55 -0
M internal/usecase/daemon/render_divider_geometry_test.go +37 -0
| @@ -80,3 +80,40 @@ func TestComposeFrameDividerFollowsWeights(t *testing.T) { | |||
| 80 | 80 | }) | |
| 81 | 81 | } | |
| 82 | 82 | } | |
| 83 | + | ||
| 84 | + | // TestComposeFrameClearsCellsOutsideSharedLayout pins that a window larger | |
| 85 | + | // than the shared session layout blanks the cells the layout no longer covers | |
| 86 | + | // instead of keeping a wider earlier layout's cells on screen. | |
| 87 | + | func TestComposeFrameClearsCellsOutsideSharedLayout(t *testing.T) { | |
| 88 | + | tests := []struct { | |
| 89 | + | name string | |
| 90 | + | wide, next domain.Rect | |
| 91 | + | x, y int // stale content cell outside next, frame coordinates | |
| 92 | + | }{ | |
| 93 | + | {name: "narrower layout clears the right side", wide: domain.Rect{Width: 6, Height: 2}, next: domain.Rect{Width: 4, Height: 2}, x: 5, y: 1}, | |
| 94 | + | {name: "shorter layout clears the bottom rows", wide: domain.Rect{Width: 6, Height: 2}, next: domain.Rect{Width: 6, Height: 1}, x: 0, y: 2}, | |
| 95 | + | } | |
| 96 | + | for _, tt := range tests { | |
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |
| 98 | + | stateFor := func(area domain.Rect, fingerprint string) capturedRenderState { | |
| 99 | + | pane := renderer.NewFrame(area.Width, area.Height) | |
| 100 | + | for y := range area.Height { | |
| 101 | + | for x := range area.Width { | |
| 102 | + | pane.Set(x, y, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | placement := layout.Placement{ID: "p", Content: area} | |
| 106 | + | return capturedRenderState{ | |
| 107 | + | window: domain.Size{Cols: 6, Rows: 2 + tabChromeRows}, | |
| 108 | + | layout: capturedTabLayout{area: area, focus: "p", valid: true, fingerprint: fingerprint, placements: []layout.Placement{placement}}, | |
| 109 | + | panes: []capturedPaneRenderState{{id: "p", frame: pane, focused: true, placement: placement, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | committed := composeFrame(stateFor(tt.wide, "wide"), composeCacheInput{}) | |
| 113 | + | require.Equal(t, 'X', committed.frame.At(tt.x, tt.y).Rune) | |
| 114 | + | ||
| 115 | + | out := composeFrame(stateFor(tt.next, "next"), committed.cache, composeCacheInput{}) | |
| 116 | + | require.Equal(t, ' ', out.frame.At(tt.x, tt.y).Rune, "cell outside the shared layout must be blanked") | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | } | |
| @@ -80,3 +80,40 @@ func TestComposeFrameDividerFollowsWeights(t *testing.T) { | |||||
| 80 | }) | 80 | }) | ||
| 81 | } | 81 | } | ||
| 82 | } | 82 | } | ||
| 83 | + | ||||
| 84 | + | // TestComposeFrameClearsCellsOutsideSharedLayout pins that a window larger | |||
| 85 | + | // than the shared session layout blanks the cells the layout no longer covers | |||
| 86 | + | // instead of keeping a wider earlier layout's cells on screen. | |||
| 87 | + | func TestComposeFrameClearsCellsOutsideSharedLayout(t *testing.T) { | |||
| 88 | + | tests := []struct { | |||
| 89 | + | name string | |||
| 90 | + | wide, next domain.Rect | |||
| 91 | + | x, y int // stale content cell outside next, frame coordinates | |||
| 92 | + | }{ | |||
| 93 | + | {name: "narrower layout clears the right side", wide: domain.Rect{Width: 6, Height: 2}, next: domain.Rect{Width: 4, Height: 2}, x: 5, y: 1}, | |||
| 94 | + | {name: "shorter layout clears the bottom rows", wide: domain.Rect{Width: 6, Height: 2}, next: domain.Rect{Width: 6, Height: 1}, x: 0, y: 2}, | |||
| 95 | + | } | |||
| 96 | + | for _, tt := range tests { | |||
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 98 | + | stateFor := func(area domain.Rect, fingerprint string) capturedRenderState { | |||
| 99 | + | pane := renderer.NewFrame(area.Width, area.Height) | |||
| 100 | + | for y := range area.Height { | |||
| 101 | + | for x := range area.Width { | |||
| 102 | + | pane.Set(x, y, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | placement := layout.Placement{ID: "p", Content: area} | |||
| 106 | + | return capturedRenderState{ | |||
| 107 | + | window: domain.Size{Cols: 6, Rows: 2 + tabChromeRows}, | |||
| 108 | + | layout: capturedTabLayout{area: area, focus: "p", valid: true, fingerprint: fingerprint, placements: []layout.Placement{placement}}, | |||
| 109 | + | panes: []capturedPaneRenderState{{id: "p", frame: pane, focused: true, placement: placement, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | committed := composeFrame(stateFor(tt.wide, "wide"), composeCacheInput{}) | |||
| 113 | + | require.Equal(t, 'X', committed.frame.At(tt.x, tt.y).Rune) | |||
| 114 | + | ||||
| 115 | + | out := composeFrame(stateFor(tt.next, "next"), committed.cache, composeCacheInput{}) | |||
| 116 | + | require.Equal(t, ' ', out.frame.At(tt.x, tt.y).Rune, "cell outside the shared layout must be blanked") | |||
| 117 | + | }) | |||
| 118 | + | } | |||
| 119 | + | } | |||
M internal/usecase/daemon/render_pipeline.go +18 -0
| @@ -63,6 +63,21 @@ type composedRenderFrame struct { | |||
| 63 | 63 | // scratch cache. It returns a new cache without mutating committed. | |
| 64 | 64 | const inactivePaneForegroundDimming = 55 | |
| 65 | 65 | ||
| 66 | + | // clearOutsideLayout blanks the content cells that the shared layout does not | |
| 67 | + | // cover. A window larger than the shared session geometry reuses its previous | |
| 68 | + | // frame, so without this the cells of a wider or taller earlier layout stay on | |
| 69 | + | // screen. | |
| 70 | + | func clearOutsideLayout(frame renderer.Frame, content, area domain.Rect) { | |
| 71 | + | blank := renderer.BlankCell() | |
| 72 | + | for y := content.Y; y < content.Y+content.Height; y++ { | |
| 73 | + | start := area.Width | |
| 74 | + | if y-content.Y >= area.Height { | |
| 75 | + | start = 0 | |
| 76 | + | } | |
| 77 | + | frame.FillRow(y, content.X+start, content.X+content.Width, blank) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 66 | 81 | func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame { | |
| 67 | 82 | scratch := composeCacheInput{} | |
| 68 | 83 | if len(scratchIn) > 0 { | |
| @@ -123,6 +138,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 123 | 138 | } | |
| 124 | 139 | ||
| 125 | 140 | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible | |
| 141 | + | if full { | |
| 142 | + | clearOutsideLayout(frame, content, state.layout.area) | |
| 143 | + | } | |
| 126 | 144 | titles := scratch.titleGenerations | |
| 127 | 145 | if titles == nil { | |
| 128 | 146 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | |
| @@ -63,6 +63,21 @@ type composedRenderFrame struct { | |||||
| 63 | // scratch cache. It returns a new cache without mutating committed. | 63 | // scratch cache. It returns a new cache without mutating committed. | ||
| 64 | const inactivePaneForegroundDimming = 55 | 64 | const inactivePaneForegroundDimming = 55 | ||
| 65 | 65 | ||||
| 66 | + | // clearOutsideLayout blanks the content cells that the shared layout does not | |||
| 67 | + | // cover. A window larger than the shared session geometry reuses its previous | |||
| 68 | + | // frame, so without this the cells of a wider or taller earlier layout stay on | |||
| 69 | + | // screen. | |||
| 70 | + | func clearOutsideLayout(frame renderer.Frame, content, area domain.Rect) { | |||
| 71 | + | blank := renderer.BlankCell() | |||
| 72 | + | for y := content.Y; y < content.Y+content.Height; y++ { | |||
| 73 | + | start := area.Width | |||
| 74 | + | if y-content.Y >= area.Height { | |||
| 75 | + | start = 0 | |||
| 76 | + | } | |||
| 77 | + | frame.FillRow(y, content.X+start, content.X+content.Width, blank) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 66 | func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame { | 81 | func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame { | ||
| 67 | scratch := composeCacheInput{} | 82 | scratch := composeCacheInput{} | ||
| 68 | if len(scratchIn) > 0 { | 83 | if len(scratchIn) > 0 { | ||
| @@ -123,6 +138,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 123 | } | 138 | } | ||
| 124 | 139 | ||||
| 125 | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible | 140 | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible | ||
| 141 | + | if full { | |||
| 142 | + | clearOutsideLayout(frame, content, state.layout.area) | |||
| 143 | + | } | |||
| 126 | titles := scratch.titleGenerations | 144 | titles := scratch.titleGenerations | ||
| 127 | if titles == nil { | 145 | if titles == nil { | ||
| 128 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | 146 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | ||