detached 46a921f

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
8080 })
8181 }
8282 }
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 {
6363 // scratch cache. It returns a new cache without mutating committed.
6464 const inactivePaneForegroundDimming = 55
6565
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+
6681 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
6782 scratch := composeCacheInput{}
6883 if len(scratchIn) > 0 {
@@ -123,6 +138,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
123138 }
124139
125140 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+ }
126144 titles := scratch.titleGenerations
127145 if titles == nil {
128146 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 = 5564 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.visible140 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.titleGenerations144 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))

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close