detached b9a93ef

refactor(render): remove obsolete layout tree capture

commit
b9a93ef80efb21bf7ac3ee9ae381d46741d6b528
author
brice · · 2026-07-25 14:31 UTC
parent
5578582
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +99 -102
M internal/usecase/daemon/mouse_panes.go +1 -2
@@ -43,7 +43,6 @@ func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {
4343 }
4444
4545 type tabLayoutSnapshot struct {
46- root *layout.Node
4746 fingerprint string
4847 area domain.Rect
4948 focus layout.PaneID
@@ -63,7 +62,7 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
6362 }
6463 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
6564 placements, dividers, ok := layout.SolveWithDividers(tb.tree.Root, area)
66- return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, dividers: dividers, ok: ok}
65+ return tabLayoutSnapshot{fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, dividers: dividers, ok: ok}
6766 }
6867
6968 func layoutFingerprint(root *layout.Node) string {
@@ -43,7 +43,6 @@ func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {
43 }43 }
44 44
45 type tabLayoutSnapshot struct {45 type tabLayoutSnapshot struct {
46- root *layout.Node
47 fingerprint string46 fingerprint string
48 area domain.Rect47 area domain.Rect
49 focus layout.PaneID48 focus layout.PaneID
@@ -63,7 +62,7 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
63 }62 }
64 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}63 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
65 placements, dividers, ok := layout.SolveWithDividers(tb.tree.Root, area)64 placements, dividers, ok := layout.SolveWithDividers(tb.tree.Root, area)
66- return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, dividers: dividers, ok: ok}65+ return tabLayoutSnapshot{fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, dividers: dividers, ok: ok}
67 }66 }
68 67
69 func layoutFingerprint(root *layout.Node) string {68 func layoutFingerprint(root *layout.Node) string {
M internal/usecase/daemon/picker.go +1 -1
@@ -630,7 +630,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
630630 return preview
631631 }
632632 state := capturedRenderState{layout: capturedTabLayout{
633- root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus,
633+ area: layoutSnap.area, focus: layoutSnap.focus,
634634 placements: append([]layout.Placement(nil), layoutSnap.placements...),
635635 dividers: append([]layout.Divider(nil), layoutSnap.dividers...),
636636 fingerprint: layoutSnap.fingerprint, valid: true,
@@ -630,7 +630,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
630 return preview630 return preview
631 }631 }
632 state := capturedRenderState{layout: capturedTabLayout{632 state := capturedRenderState{layout: capturedTabLayout{
633- root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus,633+ area: layoutSnap.area, focus: layoutSnap.focus,
634 placements: append([]layout.Placement(nil), layoutSnap.placements...),634 placements: append([]layout.Placement(nil), layoutSnap.placements...),
635 dividers: append([]layout.Divider(nil), layoutSnap.dividers...),635 dividers: append([]layout.Divider(nil), layoutSnap.dividers...),
636 fingerprint: layoutSnap.fingerprint, valid: true,636 fingerprint: layoutSnap.fingerprint, valid: true,
M internal/usecase/daemon/render_capture.go +1 -28
@@ -25,8 +25,6 @@ type renderCaptureScratch struct {
2525 mru []recentSession
2626 ranked []rankedRecent
2727 titleIDs []layout.PaneID
28- treeNodes []layout.Node
29- treeUsed int
3028 receipts []damageReceipt
3129 }
3230
@@ -55,7 +53,6 @@ type capturedRenderState struct {
5553 }
5654
5755 type capturedTabLayout struct {
58- root *layout.Node
5956 area domain.Rect
6057 focus layout.PaneID
6158 placements []layout.Placement
@@ -231,17 +228,12 @@ func capturePrimaryRenderState(
231228 layoutSnap := solveTabLayoutLocked(tb)
232229 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
233230 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
234- scratch.treeUsed = 0
235- var root *layout.Node
236- if tb.tree != nil {
237- root = cloneLayoutNodeIntoScratch(tb.tree.Root, scratch)
238- }
239231 state := &scratch.state
240232 *state = capturedRenderState{
241233 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
242234 styles: request.styles, styleGeneration: request.styleGeneration,
243235 overlays: overlays, preview: preview,
244- layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
236+ layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
245237 floatingGeneration: tb.floating.generation,
246238 receipts: scratch.receipts[:0],
247239 }
@@ -314,25 +306,6 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
314306 return append(dst[:0], src...)
315307 }
316308
317-func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
318- if src == nil {
319- return nil
320- }
321- if scratch.treeUsed == len(scratch.treeNodes) {
322- scratch.treeNodes = append(scratch.treeNodes, layout.Node{})
323- }
324- index := scratch.treeUsed
325- scratch.treeUsed++
326- dst := &scratch.treeNodes[index]
327- children := dst.Children[:0]
328- *dst = *src
329- dst.Children = children
330- for _, child := range src.Children {
331- dst.Children = append(dst.Children, cloneLayoutNodeIntoScratch(child, scratch))
332- }
333- return dst
334-}
335-
336309 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
337310 style, hasStyle := p.screen.CursorStyle()
338311 hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
@@ -25,8 +25,6 @@ type renderCaptureScratch struct {
25 mru []recentSession25 mru []recentSession
26 ranked []rankedRecent26 ranked []rankedRecent
27 titleIDs []layout.PaneID27 titleIDs []layout.PaneID
28- treeNodes []layout.Node
29- treeUsed int
30 receipts []damageReceipt28 receipts []damageReceipt
31 }29 }
32 30
@@ -55,7 +53,6 @@ type capturedRenderState struct {
55 }53 }
56 54
57 type capturedTabLayout struct {55 type capturedTabLayout struct {
58- root *layout.Node
59 area domain.Rect56 area domain.Rect
60 focus layout.PaneID57 focus layout.PaneID
61 placements []layout.Placement58 placements []layout.Placement
@@ -231,17 +228,12 @@ func capturePrimaryRenderState(
231 layoutSnap := solveTabLayoutLocked(tb)228 layoutSnap := solveTabLayoutLocked(tb)
232 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)229 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
233 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)230 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
234- scratch.treeUsed = 0
235- var root *layout.Node
236- if tb.tree != nil {
237- root = cloneLayoutNodeIntoScratch(tb.tree.Root, scratch)
238- }
239 state := &scratch.state231 state := &scratch.state
240 *state = capturedRenderState{232 *state = capturedRenderState{
241 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,233 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
242 styles: request.styles, styleGeneration: request.styleGeneration,234 styles: request.styles, styleGeneration: request.styleGeneration,
243 overlays: overlays, preview: preview,235 overlays: overlays, preview: preview,
244- layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},236+ layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
245 floatingGeneration: tb.floating.generation,237 floatingGeneration: tb.floating.generation,
246 receipts: scratch.receipts[:0],238 receipts: scratch.receipts[:0],
247 }239 }
@@ -314,25 +306,6 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
314 return append(dst[:0], src...)306 return append(dst[:0], src...)
315 }307 }
316 308
317-func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
318- if src == nil {
319- return nil
320- }
321- if scratch.treeUsed == len(scratch.treeNodes) {
322- scratch.treeNodes = append(scratch.treeNodes, layout.Node{})
323- }
324- index := scratch.treeUsed
325- scratch.treeUsed++
326- dst := &scratch.treeNodes[index]
327- children := dst.Children[:0]
328- *dst = *src
329- dst.Children = children
330- for _, child := range src.Children {
331- dst.Children = append(dst.Children, cloneLayoutNodeIntoScratch(child, scratch))
332- }
333- return dst
334-}
335-
336 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {309 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
337 style, hasStyle := p.screen.CursorStyle()310 style, hasStyle := p.screen.CursorStyle()
338 hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive311 hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
M internal/usecase/daemon/render_divider_geometry_test.go +65 -37
@@ -9,46 +9,74 @@ import (
99 "github.com/stretchr/testify/require"
1010 )
1111
12-// TestComposeFrameDividerFollowsWeights proves the rendered divider glyph
13-// tracks the weight-aware pane geometry from layout.Solve, rather than an
14-// equal-split recomputation. On a 61-wide area, equal weights place the
15-// divider at x=30; a 2:1 weight split moves the real gap to x=40. The
16-// divider must move with it and must not linger at the old column.
12+// TestComposeFrameDividerFollowsWeights proves rendered divider glyphs track
13+// weight-aware pane geometry rather than an equal-split recomputation.
1714 func TestComposeFrameDividerFollowsWeights(t *testing.T) {
18- root := &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
19- {Kind: layout.Leaf, Leaf: "left", Weight: 1},
20- {Kind: layout.Leaf, Leaf: "right", Weight: 1},
21- }}
22- area := domain.Rect{Width: 61, Height: 5}
23-
24- stateFor := func() capturedRenderState {
25- placements, dividers, ok := layout.SolveWithDividers(root, area)
26- require.True(t, ok)
27- panes := make([]capturedPaneRenderState, 0, len(placements))
28- for _, placement := range placements {
29- frame := cachePaneFrame(placement.Content.Width, placement.Content.Height, rune(placement.ID[0]))
30- panes = append(panes, capturedPaneRenderState{
31- id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "left",
32- damage: []renderer.Damage{renderer.FullRedraw()},
33- })
34- }
35- return capturedRenderState{
36- reset: true,
37- layout: capturedTabLayout{root: root, area: area, focus: "left", placements: placements, dividers: dividers, fingerprint: layoutFingerprint(root), valid: true},
38- panes: panes, styles: resolveStyles(nil),
39- }
15+ tests := []struct {
16+ name string
17+ direction layout.SplitDir
18+ area domain.Rect
19+ glyph rune
20+ equalX, equalY int
21+ weightedX, weightedY int
22+ }{
23+ {
24+ name: "horizontal split",
25+ direction: layout.Horizontal,
26+ area: domain.Rect{Width: 61, Height: 5},
27+ glyph: '│',
28+ equalX: 30,
29+ equalY: 1,
30+ weightedX: 40,
31+ weightedY: 1,
32+ },
33+ {
34+ name: "vertical split",
35+ direction: layout.Vertical,
36+ area: domain.Rect{Width: 41, Height: 10},
37+ glyph: '─',
38+ equalX: 1,
39+ equalY: 6,
40+ weightedX: 1,
41+ weightedY: 7,
42+ },
4043 }
4144
42- equal := stateFor()
43- out := composeFrame(equal, composeCacheInput{})
44- require.Equal(t, '│', out.frame.At(30, 1).Rune, "equal weights must place the divider at the equal-split column")
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ root := &layout.Node{Kind: layout.Split, Dir: tt.direction, Children: []*layout.Node{
48+ {Kind: layout.Leaf, Leaf: "first", Weight: 1},
49+ {Kind: layout.Leaf, Leaf: "second", Weight: 1},
50+ }}
51+ stateFor := func() capturedRenderState {
52+ placements, dividers, ok := layout.SolveWithDividers(root, tt.area)
53+ require.True(t, ok)
54+ panes := make([]capturedPaneRenderState, 0, len(placements))
55+ for _, placement := range placements {
56+ frame := cachePaneFrame(placement.Content.Width, placement.Content.Height, rune(placement.ID[0]))
57+ panes = append(panes, capturedPaneRenderState{
58+ id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "first",
59+ damage: []renderer.Damage{renderer.FullRedraw()},
60+ })
61+ }
62+ return capturedRenderState{
63+ reset: true,
64+ layout: capturedTabLayout{area: tt.area, focus: "first", placements: placements, dividers: dividers, fingerprint: layoutFingerprint(root), valid: true},
65+ panes: panes, styles: resolveStyles(nil),
66+ }
67+ }
68+
69+ equal := stateFor()
70+ out := composeFrame(equal, composeCacheInput{})
71+ require.Equal(t, tt.glyph, out.frame.At(tt.equalX, tt.equalY).Rune, "equal weights must place the divider at the equal-split gap")
4572
46- root.Children[0].Weight = 2
47- weighted := stateFor()
48- require.NotEqual(t, equal.layout.fingerprint, weighted.layout.fingerprint)
49- require.Equal(t, 41, weighted.layout.placements[1].Content.X, "sanity check: left pane is 40 wide, so the right pane starts at x=41")
73+ root.Children[0].Weight = 2
74+ weighted := stateFor()
75+ require.NotEqual(t, equal.layout.fingerprint, weighted.layout.fingerprint)
5076
51- out = composeFrame(weighted, out.cache, composeCacheInput{})
52- require.Equal(t, '│', out.frame.At(40, 1).Rune, "the divider must follow the weight-derived gap")
53- require.NotEqual(t, '│', out.frame.At(30, 1).Rune, "the divider must not linger at the old equal-split column")
77+ out = composeFrame(weighted, out.cache, composeCacheInput{})
78+ require.Equal(t, tt.glyph, out.frame.At(tt.weightedX, tt.weightedY).Rune, "the divider must follow the weight-derived gap")
79+ require.NotEqual(t, tt.glyph, out.frame.At(tt.equalX, tt.equalY).Rune, "the divider must not linger at the old equal-split gap")
80+ })
81+ }
5482 }
@@ -9,46 +9,74 @@ import (
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
12-// TestComposeFrameDividerFollowsWeights proves the rendered divider glyph12+// TestComposeFrameDividerFollowsWeights proves rendered divider glyphs track
13-// tracks the weight-aware pane geometry from layout.Solve, rather than an13+// weight-aware pane geometry rather than an equal-split recomputation.
14-// equal-split recomputation. On a 61-wide area, equal weights place the
15-// divider at x=30; a 2:1 weight split moves the real gap to x=40. The
16-// divider must move with it and must not linger at the old column.
17 func TestComposeFrameDividerFollowsWeights(t *testing.T) {14 func TestComposeFrameDividerFollowsWeights(t *testing.T) {
18- root := &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{15+ tests := []struct {
19- {Kind: layout.Leaf, Leaf: "left", Weight: 1},16+ name string
20- {Kind: layout.Leaf, Leaf: "right", Weight: 1},17+ direction layout.SplitDir
21- }}18+ area domain.Rect
22- area := domain.Rect{Width: 61, Height: 5}19+ glyph rune
23-20+ equalX, equalY int
24- stateFor := func() capturedRenderState {21+ weightedX, weightedY int
25- placements, dividers, ok := layout.SolveWithDividers(root, area)22+ }{
26- require.True(t, ok)23+ {
27- panes := make([]capturedPaneRenderState, 0, len(placements))24+ name: "horizontal split",
28- for _, placement := range placements {25+ direction: layout.Horizontal,
29- frame := cachePaneFrame(placement.Content.Width, placement.Content.Height, rune(placement.ID[0]))26+ area: domain.Rect{Width: 61, Height: 5},
30- panes = append(panes, capturedPaneRenderState{27+ glyph: '│',
31- id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "left",28+ equalX: 30,
32- damage: []renderer.Damage{renderer.FullRedraw()},29+ equalY: 1,
33- })30+ weightedX: 40,
34- }31+ weightedY: 1,
35- return capturedRenderState{32+ },
36- reset: true,33+ {
37- layout: capturedTabLayout{root: root, area: area, focus: "left", placements: placements, dividers: dividers, fingerprint: layoutFingerprint(root), valid: true},34+ name: "vertical split",
38- panes: panes, styles: resolveStyles(nil),35+ direction: layout.Vertical,
39- }36+ area: domain.Rect{Width: 41, Height: 10},
37+ glyph: '─',
38+ equalX: 1,
39+ equalY: 6,
40+ weightedX: 1,
41+ weightedY: 7,
42+ },
40 }43 }
41 44
42- equal := stateFor()45+ for _, tt := range tests {
43- out := composeFrame(equal, composeCacheInput{})46+ t.Run(tt.name, func(t *testing.T) {
44- require.Equal(t, '│', out.frame.At(30, 1).Rune, "equal weights must place the divider at the equal-split column")47+ root := &layout.Node{Kind: layout.Split, Dir: tt.direction, Children: []*layout.Node{
48+ {Kind: layout.Leaf, Leaf: "first", Weight: 1},
49+ {Kind: layout.Leaf, Leaf: "second", Weight: 1},
50+ }}
51+ stateFor := func() capturedRenderState {
52+ placements, dividers, ok := layout.SolveWithDividers(root, tt.area)
53+ require.True(t, ok)
54+ panes := make([]capturedPaneRenderState, 0, len(placements))
55+ for _, placement := range placements {
56+ frame := cachePaneFrame(placement.Content.Width, placement.Content.Height, rune(placement.ID[0]))
57+ panes = append(panes, capturedPaneRenderState{
58+ id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "first",
59+ damage: []renderer.Damage{renderer.FullRedraw()},
60+ })
61+ }
62+ return capturedRenderState{
63+ reset: true,
64+ layout: capturedTabLayout{area: tt.area, focus: "first", placements: placements, dividers: dividers, fingerprint: layoutFingerprint(root), valid: true},
65+ panes: panes, styles: resolveStyles(nil),
66+ }
67+ }
68+
69+ equal := stateFor()
70+ out := composeFrame(equal, composeCacheInput{})
71+ require.Equal(t, tt.glyph, out.frame.At(tt.equalX, tt.equalY).Rune, "equal weights must place the divider at the equal-split gap")
45 72
46- root.Children[0].Weight = 273+ root.Children[0].Weight = 2
47- weighted := stateFor()74+ weighted := stateFor()
48- require.NotEqual(t, equal.layout.fingerprint, weighted.layout.fingerprint)75+ require.NotEqual(t, equal.layout.fingerprint, weighted.layout.fingerprint)
49- require.Equal(t, 41, weighted.layout.placements[1].Content.X, "sanity check: left pane is 40 wide, so the right pane starts at x=41")
50 76
51- out = composeFrame(weighted, out.cache, composeCacheInput{})77+ out = composeFrame(weighted, out.cache, composeCacheInput{})
52- require.Equal(t, '│', out.frame.At(40, 1).Rune, "the divider must follow the weight-derived gap")78+ require.Equal(t, tt.glyph, out.frame.At(tt.weightedX, tt.weightedY).Rune, "the divider must follow the weight-derived gap")
53- require.NotEqual(t, '│', out.frame.At(30, 1).Rune, "the divider must not linger at the old equal-split column")79+ require.NotEqual(t, tt.glyph, out.frame.At(tt.equalX, tt.equalY).Rune, "the divider must not linger at the old equal-split gap")
80+ })
81+ }
54 }82 }
M internal/usecase/daemon/render_pipeline_cache_test.go +8 -9
@@ -147,7 +147,7 @@ func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) {
147147 }
148148 return capturedRenderState{
149149 reset: reset,
150- layout: capturedTabLayout{root: root, area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true},
150+ layout: capturedTabLayout{area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true},
151151 panes: panes, styles: resolveStyles(nil),
152152 }
153153 }
@@ -260,16 +260,15 @@ func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.
260260 left, right := layout.PaneID("left"), layout.PaneID("right")
261261 area := domain.Rect{Width: 41, Height: 5}
262262 root := &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}
263- placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}}
264- leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R')
265- if direction == layout.Vertical {
266- placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}}
267- leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R')
263+ placements, dividers, ok := layout.SolveWithDividers(root, area)
264+ if !ok {
265+ panic("cached split fixture has invalid geometry")
268266 }
269- _, dividers, _ := layout.SolveWithDividers(root, area)
267+ leftFrame := cachePaneFrame(placements[0].Content.Width, placements[0].Content.Height, 'L')
268+ rightFrame := cachePaneFrame(placements[1].Content.Width, placements[1].Content.Height, 'R')
270269 return capturedRenderState{
271270 reset: true,
272- layout: capturedTabLayout{root: root, area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},
271+ layout: capturedTabLayout{area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},
273272 panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},
274273 theme: theme,
275274 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
@@ -284,7 +283,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured
284283 }
285284 return capturedRenderState{
286285 reset: reset,
287- layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
286+ layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
288287 panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
289288 styles: resolveStyles(nil),
290289 }
@@ -147,7 +147,7 @@ func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) {
147 }147 }
148 return capturedRenderState{148 return capturedRenderState{
149 reset: reset,149 reset: reset,
150- layout: capturedTabLayout{root: root, area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true},150+ layout: capturedTabLayout{area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true},
151 panes: panes, styles: resolveStyles(nil),151 panes: panes, styles: resolveStyles(nil),
152 }152 }
153 }153 }
@@ -260,16 +260,15 @@ func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.
260 left, right := layout.PaneID("left"), layout.PaneID("right")260 left, right := layout.PaneID("left"), layout.PaneID("right")
261 area := domain.Rect{Width: 41, Height: 5}261 area := domain.Rect{Width: 41, Height: 5}
262 root := &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}262 root := &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}
263- placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}}263+ placements, dividers, ok := layout.SolveWithDividers(root, area)
264- leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R')264+ if !ok {
265- if direction == layout.Vertical {265+ panic("cached split fixture has invalid geometry")
266- placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}}
267- leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R')
268 }266 }
269- _, dividers, _ := layout.SolveWithDividers(root, area)267+ leftFrame := cachePaneFrame(placements[0].Content.Width, placements[0].Content.Height, 'L')
268+ rightFrame := cachePaneFrame(placements[1].Content.Width, placements[1].Content.Height, 'R')
270 return capturedRenderState{269 return capturedRenderState{
271 reset: true,270 reset: true,
272- layout: capturedTabLayout{root: root, area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},271+ layout: capturedTabLayout{area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},
273 panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},272 panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},
274 theme: theme,273 theme: theme,
275 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,274 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
@@ -284,7 +283,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured
284 }283 }
285 return capturedRenderState{284 return capturedRenderState{
286 reset: reset,285 reset: reset,
287- layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},286+ layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
288 panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},287 panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
289 styles: resolveStyles(nil),288 styles: resolveStyles(nil),
290 }289 }
M internal/usecase/layout/dividers_test.go +23 -25
@@ -7,22 +7,6 @@ import (
77 "github.com/stretchr/testify/require"
88 )
99
10-// countGaps returns the number of sibling gaps in the tree, i.e. the number
11-// of divider rects SolveWithDividers is expected to produce.
12-func countGaps(n *Node) int {
13- if n == nil || n.Kind != Split {
14- return 0
15- }
16- total := 0
17- if len(n.Children) > 1 {
18- total = len(n.Children) - 1
19- }
20- for _, child := range n.Children {
21- total += countGaps(child)
22- }
23- return total
24-}
25-
2610 func rectsOverlap(a, b domain.Rect) bool {
2711 if a.Width <= 0 || a.Height <= 0 || b.Width <= 0 || b.Height <= 0 {
2812 return false
@@ -34,9 +18,10 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
3418 t.Parallel()
3519
3620 tests := []struct {
37- name string
38- root *Node
39- area domain.Rect
21+ name string
22+ root *Node
23+ area domain.Rect
24+ expected []Divider
4025 }{
4126 {
4227 name: "equal weights",
@@ -44,7 +29,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
4429 {Kind: Leaf, Leaf: "a", Weight: 1},
4530 {Kind: Leaf, Leaf: "b", Weight: 1},
4631 }},
47- area: domain.Rect{Width: 61, Height: 10},
32+ area: domain.Rect{Width: 61, Height: 10},
33+ expected: []Divider{{Rect: domain.Rect{X: 30, Width: 1, Height: 10}, Dir: Horizontal}},
4834 },
4935 {
5036 name: "unequal weights 2:1",
@@ -52,7 +38,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
5238 {Kind: Leaf, Leaf: "a", Weight: 2},
5339 {Kind: Leaf, Leaf: "b", Weight: 1},
5440 }},
55- area: domain.Rect{Width: 61, Height: 10},
41+ area: domain.Rect{Width: 61, Height: 10},
42+ expected: []Divider{{Rect: domain.Rect{X: 40, Width: 1, Height: 10}, Dir: Horizontal}},
5643 },
5744 {
5845 name: "unequal weights 1:3",
@@ -60,7 +47,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
6047 {Kind: Leaf, Leaf: "a", Weight: 1},
6148 {Kind: Leaf, Leaf: "b", Weight: 3},
6249 }},
63- area: domain.Rect{Width: 81, Height: 10},
50+ area: domain.Rect{Width: 81, Height: 10},
51+ expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}},
6452 },
6553 {
6654 name: "nested split",
@@ -72,6 +60,10 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
7260 }},
7361 }},
7462 area: domain.Rect{Width: 61, Height: 20},
63+ expected: []Divider{
64+ {Rect: domain.Rect{X: 30, Width: 1, Height: 20}, Dir: Horizontal},
65+ {Rect: domain.Rect{X: 31, Y: 10, Width: 30, Height: 1}, Dir: Vertical},
66+ },
7567 },
7668 {
7769 name: "three or more children",
@@ -81,6 +73,10 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
8173 {Kind: Leaf, Leaf: "c", Weight: 1},
8274 }},
8375 area: domain.Rect{Width: 62, Height: 10},
76+ expected: []Divider{
77+ {Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal},
78+ {Rect: domain.Rect{X: 41, Width: 1, Height: 10}, Dir: Horizontal},
79+ },
8480 },
8581 {
8682 name: "minimum pins a child",
@@ -88,7 +84,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
8884 {Kind: Leaf, Leaf: "a", Weight: 1},
8985 {Kind: Leaf, Leaf: "b", Weight: 100},
9086 }},
91- area: domain.Rect{Width: 61, Height: 10},
87+ area: domain.Rect{Width: 61, Height: 10},
88+ expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}},
9289 },
9390 {
9491 name: "vertical split",
@@ -96,7 +93,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
9693 {Kind: Leaf, Leaf: "a", Weight: 1},
9794 {Kind: Leaf, Leaf: "b", Weight: 2},
9895 }},
99- area: domain.Rect{Width: 41, Height: 10},
96+ area: domain.Rect{Width: 41, Height: 10},
97+ expected: []Divider{{Rect: domain.Rect{Y: 3, Width: 41, Height: 1}, Dir: Vertical}},
10098 },
10199 }
102100
@@ -110,7 +108,7 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
110108 require.True(t, solveOK)
111109 require.Equal(t, solved, placements, "collecting dividers must not change the solved placements")
112110
113- require.Len(t, dividers, countGaps(tt.root), "expected exactly one divider per sibling gap")
111+ require.Equal(t, tt.expected, dividers, "divider geometry and direction must match the solved gaps exactly")
114112
115113 for _, d := range dividers {
116114 require.True(t, d.Rect.Width == 1 || d.Rect.Height == 1, "divider rect must be a single row or column")
@@ -7,22 +7,6 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
10-// countGaps returns the number of sibling gaps in the tree, i.e. the number
11-// of divider rects SolveWithDividers is expected to produce.
12-func countGaps(n *Node) int {
13- if n == nil || n.Kind != Split {
14- return 0
15- }
16- total := 0
17- if len(n.Children) > 1 {
18- total = len(n.Children) - 1
19- }
20- for _, child := range n.Children {
21- total += countGaps(child)
22- }
23- return total
24-}
25-
26 func rectsOverlap(a, b domain.Rect) bool {10 func rectsOverlap(a, b domain.Rect) bool {
27 if a.Width <= 0 || a.Height <= 0 || b.Width <= 0 || b.Height <= 0 {11 if a.Width <= 0 || a.Height <= 0 || b.Width <= 0 || b.Height <= 0 {
28 return false12 return false
@@ -34,9 +18,10 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
34 t.Parallel()18 t.Parallel()
35 19
36 tests := []struct {20 tests := []struct {
37- name string21+ name string
38- root *Node22+ root *Node
39- area domain.Rect23+ area domain.Rect
24+ expected []Divider
40 }{25 }{
41 {26 {
42 name: "equal weights",27 name: "equal weights",
@@ -44,7 +29,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
44 {Kind: Leaf, Leaf: "a", Weight: 1},29 {Kind: Leaf, Leaf: "a", Weight: 1},
45 {Kind: Leaf, Leaf: "b", Weight: 1},30 {Kind: Leaf, Leaf: "b", Weight: 1},
46 }},31 }},
47- area: domain.Rect{Width: 61, Height: 10},32+ area: domain.Rect{Width: 61, Height: 10},
33+ expected: []Divider{{Rect: domain.Rect{X: 30, Width: 1, Height: 10}, Dir: Horizontal}},
48 },34 },
49 {35 {
50 name: "unequal weights 2:1",36 name: "unequal weights 2:1",
@@ -52,7 +38,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
52 {Kind: Leaf, Leaf: "a", Weight: 2},38 {Kind: Leaf, Leaf: "a", Weight: 2},
53 {Kind: Leaf, Leaf: "b", Weight: 1},39 {Kind: Leaf, Leaf: "b", Weight: 1},
54 }},40 }},
55- area: domain.Rect{Width: 61, Height: 10},41+ area: domain.Rect{Width: 61, Height: 10},
42+ expected: []Divider{{Rect: domain.Rect{X: 40, Width: 1, Height: 10}, Dir: Horizontal}},
56 },43 },
57 {44 {
58 name: "unequal weights 1:3",45 name: "unequal weights 1:3",
@@ -60,7 +47,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
60 {Kind: Leaf, Leaf: "a", Weight: 1},47 {Kind: Leaf, Leaf: "a", Weight: 1},
61 {Kind: Leaf, Leaf: "b", Weight: 3},48 {Kind: Leaf, Leaf: "b", Weight: 3},
62 }},49 }},
63- area: domain.Rect{Width: 81, Height: 10},50+ area: domain.Rect{Width: 81, Height: 10},
51+ expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}},
64 },52 },
65 {53 {
66 name: "nested split",54 name: "nested split",
@@ -72,6 +60,10 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
72 }},60 }},
73 }},61 }},
74 area: domain.Rect{Width: 61, Height: 20},62 area: domain.Rect{Width: 61, Height: 20},
63+ expected: []Divider{
64+ {Rect: domain.Rect{X: 30, Width: 1, Height: 20}, Dir: Horizontal},
65+ {Rect: domain.Rect{X: 31, Y: 10, Width: 30, Height: 1}, Dir: Vertical},
66+ },
75 },67 },
76 {68 {
77 name: "three or more children",69 name: "three or more children",
@@ -81,6 +73,10 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
81 {Kind: Leaf, Leaf: "c", Weight: 1},73 {Kind: Leaf, Leaf: "c", Weight: 1},
82 }},74 }},
83 area: domain.Rect{Width: 62, Height: 10},75 area: domain.Rect{Width: 62, Height: 10},
76+ expected: []Divider{
77+ {Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal},
78+ {Rect: domain.Rect{X: 41, Width: 1, Height: 10}, Dir: Horizontal},
79+ },
84 },80 },
85 {81 {
86 name: "minimum pins a child",82 name: "minimum pins a child",
@@ -88,7 +84,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
88 {Kind: Leaf, Leaf: "a", Weight: 1},84 {Kind: Leaf, Leaf: "a", Weight: 1},
89 {Kind: Leaf, Leaf: "b", Weight: 100},85 {Kind: Leaf, Leaf: "b", Weight: 100},
90 }},86 }},
91- area: domain.Rect{Width: 61, Height: 10},87+ area: domain.Rect{Width: 61, Height: 10},
88+ expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}},
92 },89 },
93 {90 {
94 name: "vertical split",91 name: "vertical split",
@@ -96,7 +93,8 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
96 {Kind: Leaf, Leaf: "a", Weight: 1},93 {Kind: Leaf, Leaf: "a", Weight: 1},
97 {Kind: Leaf, Leaf: "b", Weight: 2},94 {Kind: Leaf, Leaf: "b", Weight: 2},
98 }},95 }},
99- area: domain.Rect{Width: 41, Height: 10},96+ area: domain.Rect{Width: 41, Height: 10},
97+ expected: []Divider{{Rect: domain.Rect{Y: 3, Width: 41, Height: 1}, Dir: Vertical}},
100 },98 },
101 }99 }
102 100
@@ -110,7 +108,7 @@ func TestDividersMatchSolvedGaps(t *testing.T) {
110 require.True(t, solveOK)108 require.True(t, solveOK)
111 require.Equal(t, solved, placements, "collecting dividers must not change the solved placements")109 require.Equal(t, solved, placements, "collecting dividers must not change the solved placements")
112 110
113- require.Len(t, dividers, countGaps(tt.root), "expected exactly one divider per sibling gap")111+ require.Equal(t, tt.expected, dividers, "divider geometry and direction must match the solved gaps exactly")
114 112
115 for _, d := range dividers {113 for _, d := range dividers {
116 require.True(t, d.Rect.Width == 1 || d.Rect.Height == 1, "divider rect must be a single row or column")114 require.True(t, d.Rect.Width == 1 || d.Rect.Height == 1, "divider rect must be a single row or column")

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