detached 623e7c1

Merge pull request #135 from bnema/fix/resize-divider-geometry

fix(render): draw pane dividers from the solved layout geometry
commit
623e7c1d10cea96dcad879e210792fd12261abc1
author
bnema · · 2026-07-25 18:58 UTC
committer
GitHub · · 2026-07-25 18:58 UTC
merge of
4e93dda + b9a93ef · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +321 -90
M internal/usecase/daemon/mouse_panes.go +3 -3
@@ -43,11 +43,11 @@ 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
5049 placements []layout.Placement
50+ dividers []layout.Divider
5151 ok bool
5252 }
5353
@@ -61,8 +61,8 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
6161 return tabLayoutSnapshot{}
6262 }
6363 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
64- placements, ok := layout.Solve(tb.tree.Root, area)
65- return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
64+ placements, dividers, ok := layout.SolveWithDividers(tb.tree.Root, area)
65+ return tabLayoutSnapshot{fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, dividers: dividers, ok: ok}
6666 }
6767
6868 func layoutFingerprint(root *layout.Node) string {
@@ -43,11 +43,11 @@ 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
50 placements []layout.Placement49 placements []layout.Placement
50+ dividers []layout.Divider
51 ok bool51 ok bool
52 }52 }
53 53
@@ -61,8 +61,8 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
61 return tabLayoutSnapshot{}61 return tabLayoutSnapshot{}
62 }62 }
63 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}63 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
64- placements, ok := layout.Solve(tb.tree.Root, area)64+ placements, dividers, ok := layout.SolveWithDividers(tb.tree.Root, area)
65- return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}65+ return tabLayoutSnapshot{fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, dividers: dividers, ok: ok}
66 }66 }
67 67
68 func layoutFingerprint(root *layout.Node) string {68 func layoutFingerprint(root *layout.Node) string {
M internal/usecase/daemon/picker.go +2 -1
@@ -630,8 +630,9 @@ 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...),
635+ dividers: append([]layout.Divider(nil), layoutSnap.dividers...),
635636 fingerprint: layoutSnap.fingerprint, valid: true,
636637 }}
637638 for _, placement := range layoutSnap.placements {
@@ -630,8 +630,9 @@ 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 fingerprint: layoutSnap.fingerprint, valid: true,636 fingerprint: layoutSnap.fingerprint, valid: true,
636 }}637 }}
637 for _, placement := range layoutSnap.placements {638 for _, placement := range layoutSnap.placements {
M internal/usecase/daemon/render.go +13 -38
@@ -409,46 +409,21 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
409409 }
410410 }
411411
412-func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
413- if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {
414- return
415- }
416- count := len(n.Children)
417- if n.Dir == layout.Horizontal {
418- usable := r.Width - (count - 1)
419- base, rem := usable/count, usable%count
420- x := r.X
421- for i, child := range n.Children {
422- w := base
423- if i < rem {
424- w++
425- }
426- drawDividers(frame, child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, style)
427- x += w
428- if i < count-1 {
429- for y := r.Y; y < r.Y+r.Height; y++ {
430- frame.Set(x, y, renderer.Cell{Rune: '│', Style: style})
431- }
432- x++
433- }
434- }
435- return
436- }
437- usable := r.Height - (count - 1)
438- base, rem := usable/count, usable%count
439- y := r.Y
440- for i, child := range n.Children {
441- h := base
442- if i < rem {
443- h++
412+// drawDividers paints each precomputed divider gap onto frame, offsetting by
413+// dy (composeFrame's content area starts one row below the top status bar).
414+// The dividers themselves come from layout.SolveWithDividers, computed once
415+// per capture alongside pane placements, rather than recomputed here.
416+func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style renderer.Style) {
417+ for _, d := range dividers {
418+ glyph := rune('│')
419+ if d.Dir == layout.Vertical {
420+ glyph = '─'
444421 }
445- drawDividers(frame, child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, style)
446- y += h
447- if i < count-1 {
448- for x := r.X; x < r.X+r.Width; x++ {
449- frame.Set(x, y, renderer.Cell{Rune: '─', Style: style})
422+ y0 := d.Rect.Y + dy
423+ for y := y0; y < y0+d.Rect.Height; y++ {
424+ for x := d.Rect.X; x < d.Rect.X+d.Rect.Width; x++ {
425+ frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
450426 }
451- y++
452427 }
453428 }
454429 }
@@ -409,46 +409,21 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
409 }409 }
410 }410 }
411 411
412-func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {412+// drawDividers paints each precomputed divider gap onto frame, offsetting by
413- if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {413+// dy (composeFrame's content area starts one row below the top status bar).
414- return414+// The dividers themselves come from layout.SolveWithDividers, computed once
415- }415+// per capture alongside pane placements, rather than recomputed here.
416- count := len(n.Children)416+func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style renderer.Style) {
417- if n.Dir == layout.Horizontal {417+ for _, d := range dividers {
418- usable := r.Width - (count - 1)418+ glyph := rune('│')
419- base, rem := usable/count, usable%count419+ if d.Dir == layout.Vertical {
420- x := r.X420+ glyph = '─'
421- for i, child := range n.Children {
422- w := base
423- if i < rem {
424- w++
425- }
426- drawDividers(frame, child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, style)
427- x += w
428- if i < count-1 {
429- for y := r.Y; y < r.Y+r.Height; y++ {
430- frame.Set(x, y, renderer.Cell{Rune: '│', Style: style})
431- }
432- x++
433- }
434- }
435- return
436- }
437- usable := r.Height - (count - 1)
438- base, rem := usable/count, usable%count
439- y := r.Y
440- for i, child := range n.Children {
441- h := base
442- if i < rem {
443- h++
444 }421 }
445- drawDividers(frame, child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, style)422+ y0 := d.Rect.Y + dy
446- y += h423+ for y := y0; y < y0+d.Rect.Height; y++ {
447- if i < count-1 {424+ for x := d.Rect.X; x < d.Rect.X+d.Rect.Width; x++ {
448- for x := r.X; x < r.X+r.Width; x++ {425+ frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
449- frame.Set(x, y, renderer.Cell{Rune: '─', Style: style})
450 }426 }
451- y++
452 }427 }
453 }428 }
454 }429 }
M internal/usecase/daemon/render_capture.go +4 -28
@@ -20,12 +20,11 @@ type renderCaptureScratch struct {
2020 state capturedRenderState
2121 panes []capturedPaneRenderState
2222 placements []layout.Placement
23+ dividers []layout.Divider
2324 statusTabs []statusTab
2425 mru []recentSession
2526 ranked []rankedRecent
2627 titleIDs []layout.PaneID
27- treeNodes []layout.Node
28- treeUsed int
2928 receipts []damageReceipt
3029 }
3130
@@ -54,10 +53,10 @@ type capturedRenderState struct {
5453 }
5554
5655 type capturedTabLayout struct {
57- root *layout.Node
5856 area domain.Rect
5957 focus layout.PaneID
6058 placements []layout.Placement
59+ dividers []layout.Divider
6160 fingerprint string
6261 valid bool
6362 }
@@ -228,17 +227,13 @@ func capturePrimaryRenderState(
228227 defer tb.mu.Unlock()
229228 layoutSnap := solveTabLayoutLocked(tb)
230229 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
231- scratch.treeUsed = 0
232- var root *layout.Node
233- if tb.tree != nil {
234- root = cloneLayoutNodeIntoScratch(tb.tree.Root, scratch)
235- }
230+ scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
236231 state := &scratch.state
237232 *state = capturedRenderState{
238233 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
239234 styles: request.styles, styleGeneration: request.styleGeneration,
240235 overlays: overlays, preview: preview,
241- layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, 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},
242237 floatingGeneration: tb.floating.generation,
243238 receipts: scratch.receipts[:0],
244239 }
@@ -311,25 +306,6 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
311306 return append(dst[:0], src...)
312307 }
313308
314-func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
315- if src == nil {
316- return nil
317- }
318- if scratch.treeUsed == len(scratch.treeNodes) {
319- scratch.treeNodes = append(scratch.treeNodes, layout.Node{})
320- }
321- index := scratch.treeUsed
322- scratch.treeUsed++
323- dst := &scratch.treeNodes[index]
324- children := dst.Children[:0]
325- *dst = *src
326- dst.Children = children
327- for _, child := range src.Children {
328- dst.Children = append(dst.Children, cloneLayoutNodeIntoScratch(child, scratch))
329- }
330- return dst
331-}
332-
333309 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
334310 style, hasStyle := p.screen.CursorStyle()
335311 hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
@@ -20,12 +20,11 @@ type renderCaptureScratch struct {
20 state capturedRenderState20 state capturedRenderState
21 panes []capturedPaneRenderState21 panes []capturedPaneRenderState
22 placements []layout.Placement22 placements []layout.Placement
23+ dividers []layout.Divider
23 statusTabs []statusTab24 statusTabs []statusTab
24 mru []recentSession25 mru []recentSession
25 ranked []rankedRecent26 ranked []rankedRecent
26 titleIDs []layout.PaneID27 titleIDs []layout.PaneID
27- treeNodes []layout.Node
28- treeUsed int
29 receipts []damageReceipt28 receipts []damageReceipt
30 }29 }
31 30
@@ -54,10 +53,10 @@ type capturedRenderState struct {
54 }53 }
55 54
56 type capturedTabLayout struct {55 type capturedTabLayout struct {
57- root *layout.Node
58 area domain.Rect56 area domain.Rect
59 focus layout.PaneID57 focus layout.PaneID
60 placements []layout.Placement58 placements []layout.Placement
59+ dividers []layout.Divider
61 fingerprint string60 fingerprint string
62 valid bool61 valid bool
63 }62 }
@@ -228,17 +227,13 @@ func capturePrimaryRenderState(
228 defer tb.mu.Unlock()227 defer tb.mu.Unlock()
229 layoutSnap := solveTabLayoutLocked(tb)228 layoutSnap := solveTabLayoutLocked(tb)
230 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)229 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
231- scratch.treeUsed = 0230+ scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
232- var root *layout.Node
233- if tb.tree != nil {
234- root = cloneLayoutNodeIntoScratch(tb.tree.Root, scratch)
235- }
236 state := &scratch.state231 state := &scratch.state
237 *state = capturedRenderState{232 *state = capturedRenderState{
238 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,233 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
239 styles: request.styles, styleGeneration: request.styleGeneration,234 styles: request.styles, styleGeneration: request.styleGeneration,
240 overlays: overlays, preview: preview,235 overlays: overlays, preview: preview,
241- layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, 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},
242 floatingGeneration: tb.floating.generation,237 floatingGeneration: tb.floating.generation,
243 receipts: scratch.receipts[:0],238 receipts: scratch.receipts[:0],
244 }239 }
@@ -311,25 +306,6 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
311 return append(dst[:0], src...)306 return append(dst[:0], src...)
312 }307 }
313 308
314-func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
315- if src == nil {
316- return nil
317- }
318- if scratch.treeUsed == len(scratch.treeNodes) {
319- scratch.treeNodes = append(scratch.treeNodes, layout.Node{})
320- }
321- index := scratch.treeUsed
322- scratch.treeUsed++
323- dst := &scratch.treeNodes[index]
324- children := dst.Children[:0]
325- *dst = *src
326- dst.Children = children
327- for _, child := range src.Children {
328- dst.Children = append(dst.Children, cloneLayoutNodeIntoScratch(child, scratch))
329- }
330- return dst
331-}
332-
333 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {309 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
334 style, hasStyle := p.screen.CursorStyle()310 style, hasStyle := p.screen.CursorStyle()
335 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
A internal/usecase/daemon/render_divider_geometry_test.go +82 -0
@@ -0,0 +1,82 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// TestComposeFrameDividerFollowsWeights proves rendered divider glyphs track
13+// weight-aware pane geometry rather than an equal-split recomputation.
14+func TestComposeFrameDividerFollowsWeights(t *testing.T) {
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+ },
43+ }
44+
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")
72+
73+ root.Children[0].Weight = 2
74+ weighted := stateFor()
75+ require.NotEqual(t, equal.layout.fingerprint, weighted.layout.fingerprint)
76+
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+ }
82+}
@@ -0,0 +1,82 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// TestComposeFrameDividerFollowsWeights proves rendered divider glyphs track
13+// weight-aware pane geometry rather than an equal-split recomputation.
14+func TestComposeFrameDividerFollowsWeights(t *testing.T) {
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+ },
43+ }
44+
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")
72+
73+ root.Children[0].Weight = 2
74+ weighted := stateFor()
75+ require.NotEqual(t, equal.layout.fingerprint, weighted.layout.fingerprint)
76+
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+ }
82+}
M internal/usecase/daemon/render_pipeline.go +2 -2
@@ -96,8 +96,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
9696 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
9797 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
9898 content := domain.Rect{Y: 1, Width: width, Height: rows}
99- if state.layout.valid && state.layout.root != nil {
100- drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(neutralBorder))
99+ if state.layout.valid {
100+ drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
101101 }
102102
103103 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
@@ -96,8 +96,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
96 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)96 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
97 drawStatusBarState(frame.Row(rows+1), state.bars, styles)97 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
98 content := domain.Rect{Y: 1, Width: width, Height: rows}98 content := domain.Rect{Y: 1, Width: width, Height: rows}
99- if state.layout.valid && state.layout.root != nil {99+ if state.layout.valid {
100- drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(neutralBorder))100+ drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
101 }101 }
102 102
103 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible103 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
M internal/usecase/daemon/render_pipeline_cache_test.go +10 -8
@@ -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 }
@@ -258,15 +258,17 @@ func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
258258
259259 func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState {
260260 left, right := layout.PaneID("left"), layout.PaneID("right")
261- placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}}
262- leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R')
263- if direction == layout.Vertical {
264- placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}}
265- leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R')
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)}}
263+ placements, dividers, ok := layout.SolveWithDividers(root, area)
264+ if !ok {
265+ panic("cached split fixture has invalid geometry")
266266 }
267+ leftFrame := cachePaneFrame(placements[0].Content.Width, placements[0].Content.Height, 'L')
268+ rightFrame := cachePaneFrame(placements[1].Content.Width, placements[1].Content.Height, 'R')
267269 return capturedRenderState{
268270 reset: true,
269- layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true},
271+ layout: capturedTabLayout{area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},
270272 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()}}},
271273 theme: theme,
272274 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
@@ -281,7 +283,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured
281283 }
282284 return capturedRenderState{
283285 reset: reset,
284- 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},
285287 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()}}},
286288 styles: resolveStyles(nil),
287289 }
@@ -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 }
@@ -258,15 +258,17 @@ func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
258 258
259 func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState {259 func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState {
260 left, right := layout.PaneID("left"), layout.PaneID("right")260 left, right := layout.PaneID("left"), layout.PaneID("right")
261- placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}}261+ area := domain.Rect{Width: 41, Height: 5}
262- leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R')262+ root := &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}
263- if direction == layout.Vertical {263+ placements, dividers, ok := layout.SolveWithDividers(root, area)
264- placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}}264+ if !ok {
265- leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R')265+ panic("cached split fixture has invalid geometry")
266 }266 }
267+ leftFrame := cachePaneFrame(placements[0].Content.Width, placements[0].Content.Height, 'L')
268+ rightFrame := cachePaneFrame(placements[1].Content.Width, placements[1].Content.Height, 'R')
267 return capturedRenderState{269 return capturedRenderState{
268 reset: true,270 reset: true,
269- layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true},271+ layout: capturedTabLayout{area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},
270 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()}}},
271 theme: theme,273 theme: theme,
272 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,274 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
@@ -281,7 +283,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured
281 }283 }
282 return capturedRenderState{284 return capturedRenderState{
283 reset: reset,285 reset: reset,
284- 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},
285 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()}}},
286 styles: resolveStyles(nil),288 styles: resolveStyles(nil),
287 }289 }
A internal/usecase/layout/dividers.go +29 -0
@@ -0,0 +1,29 @@
1+package layout
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// Divider is the geometry of a single gap between two sibling panes in a Split
6+// node. Dir mirrors the owning Split's Dir: Horizontal produces a
7+// 1-column-wide vertical divider rect; Vertical produces a 1-row-high
8+// horizontal divider rect.
9+type Divider struct {
10+ Rect domain.Rect
11+ Dir SplitDir
12+}
13+
14+// SolveWithDividers behaves like Solve but also returns the gaps between
15+// sibling panes. Both come from the same traversal, so a divider can never sit
16+// anywhere but in the gap Solve left for it. Leaf and Stack nodes contribute no
17+// dividers.
18+func SolveWithDividers(root *Node, area domain.Rect) ([]Placement, []Divider, bool) {
19+ return solveArea(root, area, true)
20+}
21+
22+// dividerBetween returns the one-cell gap splitChildRects left after prev
23+// inside parent.
24+func dividerBetween(dir SplitDir, parent, prev domain.Rect) Divider {
25+ if dir == Horizontal {
26+ return Divider{Rect: domain.Rect{X: prev.X + prev.Width, Y: parent.Y, Width: 1, Height: parent.Height}, Dir: Horizontal}
27+ }
28+ return Divider{Rect: domain.Rect{X: parent.X, Y: prev.Y + prev.Height, Width: parent.Width, Height: 1}, Dir: Vertical}
29+}
@@ -0,0 +1,29 @@
1+package layout
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// Divider is the geometry of a single gap between two sibling panes in a Split
6+// node. Dir mirrors the owning Split's Dir: Horizontal produces a
7+// 1-column-wide vertical divider rect; Vertical produces a 1-row-high
8+// horizontal divider rect.
9+type Divider struct {
10+ Rect domain.Rect
11+ Dir SplitDir
12+}
13+
14+// SolveWithDividers behaves like Solve but also returns the gaps between
15+// sibling panes. Both come from the same traversal, so a divider can never sit
16+// anywhere but in the gap Solve left for it. Leaf and Stack nodes contribute no
17+// dividers.
18+func SolveWithDividers(root *Node, area domain.Rect) ([]Placement, []Divider, bool) {
19+ return solveArea(root, area, true)
20+}
21+
22+// dividerBetween returns the one-cell gap splitChildRects left after prev
23+// inside parent.
24+func dividerBetween(dir SplitDir, parent, prev domain.Rect) Divider {
25+ if dir == Horizontal {
26+ return Divider{Rect: domain.Rect{X: prev.X + prev.Width, Y: parent.Y, Width: 1, Height: parent.Height}, Dir: Horizontal}
27+ }
28+ return Divider{Rect: domain.Rect{X: parent.X, Y: prev.Y + prev.Height, Width: parent.Width, Height: 1}, Dir: Vertical}
29+}
A internal/usecase/layout/dividers_test.go +146 -0
@@ -0,0 +1,146 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func rectsOverlap(a, b domain.Rect) bool {
11+ if a.Width <= 0 || a.Height <= 0 || b.Width <= 0 || b.Height <= 0 {
12+ return false
13+ }
14+ return a.X < b.X+b.Width && b.X < a.X+a.Width && a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
15+}
16+
17+func TestDividersMatchSolvedGaps(t *testing.T) {
18+ t.Parallel()
19+
20+ tests := []struct {
21+ name string
22+ root *Node
23+ area domain.Rect
24+ expected []Divider
25+ }{
26+ {
27+ name: "equal weights",
28+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
29+ {Kind: Leaf, Leaf: "a", Weight: 1},
30+ {Kind: Leaf, Leaf: "b", Weight: 1},
31+ }},
32+ area: domain.Rect{Width: 61, Height: 10},
33+ expected: []Divider{{Rect: domain.Rect{X: 30, Width: 1, Height: 10}, Dir: Horizontal}},
34+ },
35+ {
36+ name: "unequal weights 2:1",
37+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
38+ {Kind: Leaf, Leaf: "a", Weight: 2},
39+ {Kind: Leaf, Leaf: "b", Weight: 1},
40+ }},
41+ area: domain.Rect{Width: 61, Height: 10},
42+ expected: []Divider{{Rect: domain.Rect{X: 40, Width: 1, Height: 10}, Dir: Horizontal}},
43+ },
44+ {
45+ name: "unequal weights 1:3",
46+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
47+ {Kind: Leaf, Leaf: "a", Weight: 1},
48+ {Kind: Leaf, Leaf: "b", Weight: 3},
49+ }},
50+ area: domain.Rect{Width: 81, Height: 10},
51+ expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}},
52+ },
53+ {
54+ name: "nested split",
55+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
56+ {Kind: Leaf, Leaf: "a", Weight: 1},
57+ {Kind: Split, Dir: Vertical, Weight: 1, Children: []*Node{
58+ {Kind: Leaf, Leaf: "b", Weight: 1},
59+ {Kind: Leaf, Leaf: "c", Weight: 1},
60+ }},
61+ }},
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+ },
67+ },
68+ {
69+ name: "three or more children",
70+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
71+ {Kind: Leaf, Leaf: "a", Weight: 1},
72+ {Kind: Leaf, Leaf: "b", Weight: 1},
73+ {Kind: Leaf, Leaf: "c", Weight: 1},
74+ }},
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+ },
80+ },
81+ {
82+ name: "minimum pins a child",
83+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
84+ {Kind: Leaf, Leaf: "a", Weight: 1},
85+ {Kind: Leaf, Leaf: "b", Weight: 100},
86+ }},
87+ area: domain.Rect{Width: 61, Height: 10},
88+ expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}},
89+ },
90+ {
91+ name: "vertical split",
92+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
93+ {Kind: Leaf, Leaf: "a", Weight: 1},
94+ {Kind: Leaf, Leaf: "b", Weight: 2},
95+ }},
96+ area: domain.Rect{Width: 41, Height: 10},
97+ expected: []Divider{{Rect: domain.Rect{Y: 3, Width: 41, Height: 1}, Dir: Vertical}},
98+ },
99+ }
100+
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ t.Parallel()
104+
105+ solved, solveOK := Solve(tt.root, tt.area)
106+ placements, dividers, dividersOK := SolveWithDividers(tt.root, tt.area)
107+ require.Equal(t, solveOK, dividersOK, "SolveWithDividers must succeed/fail exactly when Solve does")
108+ require.True(t, solveOK)
109+ require.Equal(t, solved, placements, "collecting dividers must not change the solved placements")
110+
111+ require.Equal(t, tt.expected, dividers, "divider geometry and direction must match the solved gaps exactly")
112+
113+ for _, d := range dividers {
114+ require.True(t, d.Rect.Width == 1 || d.Rect.Height == 1, "divider rect must be a single row or column")
115+ for _, p := range placements {
116+ require.False(t, rectsOverlap(d.Rect, p.Content), "divider %+v overlaps pane %s content %+v", d.Rect, p.ID, p.Content)
117+ }
118+ }
119+ })
120+ }
121+}
122+
123+func TestDividersFailsLikeSolve(t *testing.T) {
124+ t.Parallel()
125+
126+ root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
127+ {Kind: Leaf, Leaf: "a", Weight: 1},
128+ {Kind: Leaf, Leaf: "b", Weight: 1},
129+ }}
130+ area := domain.Rect{Width: MinPaneCols, Height: MinPaneRows}
131+
132+ _, solveOK := Solve(root, area)
133+ require.False(t, solveOK)
134+
135+ _, _, dividersOK := SolveWithDividers(root, area)
136+ require.False(t, dividersOK)
137+}
138+
139+func TestDividersNilRoot(t *testing.T) {
140+ t.Parallel()
141+
142+ placements, dividers, ok := SolveWithDividers(nil, domain.Rect{Width: 40, Height: 10})
143+ require.False(t, ok)
144+ require.Nil(t, placements)
145+ require.Nil(t, dividers)
146+}
@@ -0,0 +1,146 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func rectsOverlap(a, b domain.Rect) bool {
11+ if a.Width <= 0 || a.Height <= 0 || b.Width <= 0 || b.Height <= 0 {
12+ return false
13+ }
14+ return a.X < b.X+b.Width && b.X < a.X+a.Width && a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
15+}
16+
17+func TestDividersMatchSolvedGaps(t *testing.T) {
18+ t.Parallel()
19+
20+ tests := []struct {
21+ name string
22+ root *Node
23+ area domain.Rect
24+ expected []Divider
25+ }{
26+ {
27+ name: "equal weights",
28+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
29+ {Kind: Leaf, Leaf: "a", Weight: 1},
30+ {Kind: Leaf, Leaf: "b", Weight: 1},
31+ }},
32+ area: domain.Rect{Width: 61, Height: 10},
33+ expected: []Divider{{Rect: domain.Rect{X: 30, Width: 1, Height: 10}, Dir: Horizontal}},
34+ },
35+ {
36+ name: "unequal weights 2:1",
37+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
38+ {Kind: Leaf, Leaf: "a", Weight: 2},
39+ {Kind: Leaf, Leaf: "b", Weight: 1},
40+ }},
41+ area: domain.Rect{Width: 61, Height: 10},
42+ expected: []Divider{{Rect: domain.Rect{X: 40, Width: 1, Height: 10}, Dir: Horizontal}},
43+ },
44+ {
45+ name: "unequal weights 1:3",
46+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
47+ {Kind: Leaf, Leaf: "a", Weight: 1},
48+ {Kind: Leaf, Leaf: "b", Weight: 3},
49+ }},
50+ area: domain.Rect{Width: 81, Height: 10},
51+ expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}},
52+ },
53+ {
54+ name: "nested split",
55+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
56+ {Kind: Leaf, Leaf: "a", Weight: 1},
57+ {Kind: Split, Dir: Vertical, Weight: 1, Children: []*Node{
58+ {Kind: Leaf, Leaf: "b", Weight: 1},
59+ {Kind: Leaf, Leaf: "c", Weight: 1},
60+ }},
61+ }},
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+ },
67+ },
68+ {
69+ name: "three or more children",
70+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
71+ {Kind: Leaf, Leaf: "a", Weight: 1},
72+ {Kind: Leaf, Leaf: "b", Weight: 1},
73+ {Kind: Leaf, Leaf: "c", Weight: 1},
74+ }},
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+ },
80+ },
81+ {
82+ name: "minimum pins a child",
83+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
84+ {Kind: Leaf, Leaf: "a", Weight: 1},
85+ {Kind: Leaf, Leaf: "b", Weight: 100},
86+ }},
87+ area: domain.Rect{Width: 61, Height: 10},
88+ expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}},
89+ },
90+ {
91+ name: "vertical split",
92+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
93+ {Kind: Leaf, Leaf: "a", Weight: 1},
94+ {Kind: Leaf, Leaf: "b", Weight: 2},
95+ }},
96+ area: domain.Rect{Width: 41, Height: 10},
97+ expected: []Divider{{Rect: domain.Rect{Y: 3, Width: 41, Height: 1}, Dir: Vertical}},
98+ },
99+ }
100+
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ t.Parallel()
104+
105+ solved, solveOK := Solve(tt.root, tt.area)
106+ placements, dividers, dividersOK := SolveWithDividers(tt.root, tt.area)
107+ require.Equal(t, solveOK, dividersOK, "SolveWithDividers must succeed/fail exactly when Solve does")
108+ require.True(t, solveOK)
109+ require.Equal(t, solved, placements, "collecting dividers must not change the solved placements")
110+
111+ require.Equal(t, tt.expected, dividers, "divider geometry and direction must match the solved gaps exactly")
112+
113+ for _, d := range dividers {
114+ require.True(t, d.Rect.Width == 1 || d.Rect.Height == 1, "divider rect must be a single row or column")
115+ for _, p := range placements {
116+ require.False(t, rectsOverlap(d.Rect, p.Content), "divider %+v overlaps pane %s content %+v", d.Rect, p.ID, p.Content)
117+ }
118+ }
119+ })
120+ }
121+}
122+
123+func TestDividersFailsLikeSolve(t *testing.T) {
124+ t.Parallel()
125+
126+ root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
127+ {Kind: Leaf, Leaf: "a", Weight: 1},
128+ {Kind: Leaf, Leaf: "b", Weight: 1},
129+ }}
130+ area := domain.Rect{Width: MinPaneCols, Height: MinPaneRows}
131+
132+ _, solveOK := Solve(root, area)
133+ require.False(t, solveOK)
134+
135+ _, _, dividersOK := SolveWithDividers(root, area)
136+ require.False(t, dividersOK)
137+}
138+
139+func TestDividersNilRoot(t *testing.T) {
140+ t.Parallel()
141+
142+ placements, dividers, ok := SolveWithDividers(nil, domain.Rect{Width: 40, Height: 10})
143+ require.False(t, ok)
144+ require.Nil(t, placements)
145+ require.Nil(t, dividers)
146+}
M internal/usecase/layout/geometry.go +30 -10
@@ -9,25 +9,42 @@ import (
99
1010 // Solve returns deterministic pane placements for root inside area.
1111 func Solve(root *Node, area domain.Rect) ([]Placement, bool) {
12+ placements, _, ok := solveArea(root, area, false)
13+ return placements, ok
14+}
15+
16+// solveArea is the single geometry traversal behind Solve and
17+// SolveWithDividers. Divider gaps are collected only when withDividers is set,
18+// so both callers share one weight- and minimum-aware pass and cannot drift.
19+func solveArea(root *Node, area domain.Rect, withDividers bool) ([]Placement, []Divider, bool) {
1220 if root == nil || area.Width <= 0 || area.Height <= 0 {
13- return nil, false
21+ return nil, nil, false
1422 }
1523 minWidth, ok := minimumExtent(root, Horizontal)
1624 if !ok || area.Width < minWidth {
17- return nil, false
25+ return nil, nil, false
1826 }
1927 minHeight, ok := minimumExtent(root, Vertical)
2028 if !ok || area.Height < minHeight {
21- return nil, false
29+ return nil, nil, false
2230 }
2331 var out []Placement
24- if !solve(root, area, &out) {
25- return nil, false
32+ var dividers *[]Divider
33+ if withDividers {
34+ dividers = new([]Divider)
35+ }
36+ if !solve(root, area, &out, dividers) {
37+ return nil, nil, false
2638 }
27- return out, true
39+ if dividers == nil {
40+ return out, nil, true
41+ }
42+ return out, *dividers, true
2843 }
2944
30-func solve(n *Node, r domain.Rect, out *[]Placement) bool {
45+// solve appends the placements for n inside r. divs is nil when the caller does
46+// not need divider geometry.
47+func solve(n *Node, r domain.Rect, out *[]Placement, divs *[]Divider) bool {
3148 switch n.Kind {
3249 case Leaf:
3350 if r.Width < MinPaneCols || r.Height < MinPaneRows {
@@ -36,7 +53,7 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool {
3653 *out = append(*out, Placement{ID: n.Leaf, Content: r})
3754 return true
3855 case Split:
39- return solveSplit(n, r, out)
56+ return solveSplit(n, r, out, divs)
4057 case Stack:
4158 return solveStack(n, r, out)
4259 default:
@@ -44,13 +61,16 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool {
4461 }
4562 }
4663
47-func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool {
64+func solveSplit(n *Node, r domain.Rect, out *[]Placement, divs *[]Divider) bool {
4865 rects, ok := splitChildRects(n, r)
4966 if !ok {
5067 return false
5168 }
5269 for i, child := range n.Children {
53- if !solve(child, rects[i], out) {
70+ if i > 0 && divs != nil {
71+ *divs = append(*divs, dividerBetween(n.Dir, r, rects[i-1]))
72+ }
73+ if !solve(child, rects[i], out, divs) {
5474 return false
5575 }
5676 }
@@ -9,25 +9,42 @@ import (
9 9
10 // Solve returns deterministic pane placements for root inside area.10 // Solve returns deterministic pane placements for root inside area.
11 func Solve(root *Node, area domain.Rect) ([]Placement, bool) {11 func Solve(root *Node, area domain.Rect) ([]Placement, bool) {
12+ placements, _, ok := solveArea(root, area, false)
13+ return placements, ok
14+}
15+
16+// solveArea is the single geometry traversal behind Solve and
17+// SolveWithDividers. Divider gaps are collected only when withDividers is set,
18+// so both callers share one weight- and minimum-aware pass and cannot drift.
19+func solveArea(root *Node, area domain.Rect, withDividers bool) ([]Placement, []Divider, bool) {
12 if root == nil || area.Width <= 0 || area.Height <= 0 {20 if root == nil || area.Width <= 0 || area.Height <= 0 {
13- return nil, false21+ return nil, nil, false
14 }22 }
15 minWidth, ok := minimumExtent(root, Horizontal)23 minWidth, ok := minimumExtent(root, Horizontal)
16 if !ok || area.Width < minWidth {24 if !ok || area.Width < minWidth {
17- return nil, false25+ return nil, nil, false
18 }26 }
19 minHeight, ok := minimumExtent(root, Vertical)27 minHeight, ok := minimumExtent(root, Vertical)
20 if !ok || area.Height < minHeight {28 if !ok || area.Height < minHeight {
21- return nil, false29+ return nil, nil, false
22 }30 }
23 var out []Placement31 var out []Placement
24- if !solve(root, area, &out) {32+ var dividers *[]Divider
25- return nil, false33+ if withDividers {
34+ dividers = new([]Divider)
35+ }
36+ if !solve(root, area, &out, dividers) {
37+ return nil, nil, false
26 }38 }
27- return out, true39+ if dividers == nil {
40+ return out, nil, true
41+ }
42+ return out, *dividers, true
28 }43 }
29 44
30-func solve(n *Node, r domain.Rect, out *[]Placement) bool {45+// solve appends the placements for n inside r. divs is nil when the caller does
46+// not need divider geometry.
47+func solve(n *Node, r domain.Rect, out *[]Placement, divs *[]Divider) bool {
31 switch n.Kind {48 switch n.Kind {
32 case Leaf:49 case Leaf:
33 if r.Width < MinPaneCols || r.Height < MinPaneRows {50 if r.Width < MinPaneCols || r.Height < MinPaneRows {
@@ -36,7 +53,7 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool {
36 *out = append(*out, Placement{ID: n.Leaf, Content: r})53 *out = append(*out, Placement{ID: n.Leaf, Content: r})
37 return true54 return true
38 case Split:55 case Split:
39- return solveSplit(n, r, out)56+ return solveSplit(n, r, out, divs)
40 case Stack:57 case Stack:
41 return solveStack(n, r, out)58 return solveStack(n, r, out)
42 default:59 default:
@@ -44,13 +61,16 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool {
44 }61 }
45 }62 }
46 63
47-func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool {64+func solveSplit(n *Node, r domain.Rect, out *[]Placement, divs *[]Divider) bool {
48 rects, ok := splitChildRects(n, r)65 rects, ok := splitChildRects(n, r)
49 if !ok {66 if !ok {
50 return false67 return false
51 }68 }
52 for i, child := range n.Children {69 for i, child := range n.Children {
53- if !solve(child, rects[i], out) {70+ if i > 0 && divs != nil {
71+ *divs = append(*divs, dividerBetween(n.Dir, r, rects[i-1]))
72+ }
73+ if !solve(child, rects[i], out, divs) {
54 return false74 return false
55 }75 }
56 }76 }

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