detached 5578582

fix(render): draw pane dividers from the solved layout geometry

drawDividers recomputed split geometry with its own equal-division math,
ignoring Node.Weight and per-child minimum extents. The real pane rects come
from Solve -> splitChildRects -> distribute, which is weight-aware. The two
agreed only while every weight was equal, so the pane-resize feature made the
divider drift: the glyph landed inside a pane (and was overwritten by that
pane's blit) while the true gap column was never painted. The separator
appeared frozen at the middle of the tab and never followed a resize.

Collect divider gaps in the same traversal that solves placements and expose
them through layout.SolveWithDividers, so the renderer paints the gaps the
solver actually left instead of guessing where they are.
commit
55785825b294c79e0b402217aa55ead8aef716ed
author
brice · · 2026-07-25 14:17 UTC
parent
33be6b4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +288 -54
M internal/usecase/daemon/mouse_panes.go +3 -2
@@ -48,6 +48,7 @@ type tabLayoutSnapshot struct {
4848 area domain.Rect
4949 focus layout.PaneID
5050 placements []layout.Placement
51+ dividers []layout.Divider
5152 ok bool
5253 }
5354
@@ -61,8 +62,8 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
6162 return tabLayoutSnapshot{}
6263 }
6364 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}
65+ 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}
6667 }
6768
6869 func layoutFingerprint(root *layout.Node) string {
@@ -48,6 +48,7 @@ type tabLayoutSnapshot struct {
48 area domain.Rect48 area domain.Rect
49 focus layout.PaneID49 focus layout.PaneID
50 placements []layout.Placement50 placements []layout.Placement
51+ dividers []layout.Divider
51 ok bool52 ok bool
52 }53 }
53 54
@@ -61,8 +62,8 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
61 return tabLayoutSnapshot{}62 return tabLayoutSnapshot{}
62 }63 }
63 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}64 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
64- placements, ok := layout.Solve(tb.tree.Root, area)65+ 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}66+ return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, dividers: dividers, ok: ok}
66 }67 }
67 68
68 func layoutFingerprint(root *layout.Node) string {69 func layoutFingerprint(root *layout.Node) string {
M internal/usecase/daemon/picker.go +1 -0
@@ -632,6 +632,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
632632 state := capturedRenderState{layout: capturedTabLayout{
633633 root: tb.tree.Clone().Root, 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 {
@@ -632,6 +632,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
632 state := capturedRenderState{layout: capturedTabLayout{632 state := capturedRenderState{layout: capturedTabLayout{
633 root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus,633 root: tb.tree.Clone().Root, 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 -1
@@ -20,6 +20,7 @@ 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
@@ -58,6 +59,7 @@ type capturedTabLayout struct {
5859 area domain.Rect
5960 focus layout.PaneID
6061 placements []layout.Placement
62+ dividers []layout.Divider
6163 fingerprint string
6264 valid bool
6365 }
@@ -228,6 +230,7 @@ func capturePrimaryRenderState(
228230 defer tb.mu.Unlock()
229231 layoutSnap := solveTabLayoutLocked(tb)
230232 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
233+ scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
231234 scratch.treeUsed = 0
232235 var root *layout.Node
233236 if tb.tree != nil {
@@ -238,7 +241,7 @@ func capturePrimaryRenderState(
238241 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
239242 styles: request.styles, styleGeneration: request.styleGeneration,
240243 overlays: overlays, preview: preview,
241- layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
244+ layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
242245 floatingGeneration: tb.floating.generation,
243246 receipts: scratch.receipts[:0],
244247 }
@@ -20,6 +20,7 @@ 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
@@ -58,6 +59,7 @@ type capturedTabLayout struct {
58 area domain.Rect59 area domain.Rect
59 focus layout.PaneID60 focus layout.PaneID
60 placements []layout.Placement61 placements []layout.Placement
62+ dividers []layout.Divider
61 fingerprint string63 fingerprint string
62 valid bool64 valid bool
63 }65 }
@@ -228,6 +230,7 @@ func capturePrimaryRenderState(
228 defer tb.mu.Unlock()230 defer tb.mu.Unlock()
229 layoutSnap := solveTabLayoutLocked(tb)231 layoutSnap := solveTabLayoutLocked(tb)
230 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)232 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
233+ scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
231 scratch.treeUsed = 0234 scratch.treeUsed = 0
232 var root *layout.Node235 var root *layout.Node
233 if tb.tree != nil {236 if tb.tree != nil {
@@ -238,7 +241,7 @@ func capturePrimaryRenderState(
238 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,241 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
239 styles: request.styles, styleGeneration: request.styleGeneration,242 styles: request.styles, styleGeneration: request.styleGeneration,
240 overlays: overlays, preview: preview,243 overlays: overlays, preview: preview,
241- layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},244+ layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
242 floatingGeneration: tb.floating.generation,245 floatingGeneration: tb.floating.generation,
243 receipts: scratch.receipts[:0],246 receipts: scratch.receipts[:0],
244 }247 }
A internal/usecase/daemon/render_divider_geometry_test.go +54 -0
@@ -0,0 +1,54 @@
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 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.
17+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+ }
40+ }
41+
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+
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")
50+
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")
54+}
@@ -0,0 +1,54 @@
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 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.
17+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+ }
40+ }
41+
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+
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")
50+
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")
54+}
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 +4 -1
@@ -258,15 +258,18 @@ 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+ 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)}}
261263 placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}}
262264 leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R')
263265 if direction == layout.Vertical {
264266 placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}}
265267 leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R')
266268 }
269+ _, dividers, _ := layout.SolveWithDividers(root, area)
267270 return capturedRenderState{
268271 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},
272+ layout: capturedTabLayout{root: root, area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},
270273 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()}}},
271274 theme: theme,
272275 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
@@ -258,15 +258,18 @@ 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+ 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)}}
261 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 := []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')264 leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R')
263 if direction == layout.Vertical {265 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}}}266 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')267 leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R')
266 }268 }
269+ _, dividers, _ := layout.SolveWithDividers(root, area)
267 return capturedRenderState{270 return capturedRenderState{
268 reset: true,271 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},272+ layout: capturedTabLayout{root: root, 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()}}},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()}}},
271 theme: theme,274 theme: theme,
272 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,275 styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
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 +148 -0
@@ -0,0 +1,148 @@
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+// 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 {
27+ if a.Width <= 0 || a.Height <= 0 || b.Width <= 0 || b.Height <= 0 {
28+ return false
29+ }
30+ 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
31+}
32+
33+func TestDividersMatchSolvedGaps(t *testing.T) {
34+ t.Parallel()
35+
36+ tests := []struct {
37+ name string
38+ root *Node
39+ area domain.Rect
40+ }{
41+ {
42+ name: "equal weights",
43+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
44+ {Kind: Leaf, Leaf: "a", Weight: 1},
45+ {Kind: Leaf, Leaf: "b", Weight: 1},
46+ }},
47+ area: domain.Rect{Width: 61, Height: 10},
48+ },
49+ {
50+ name: "unequal weights 2:1",
51+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
52+ {Kind: Leaf, Leaf: "a", Weight: 2},
53+ {Kind: Leaf, Leaf: "b", Weight: 1},
54+ }},
55+ area: domain.Rect{Width: 61, Height: 10},
56+ },
57+ {
58+ name: "unequal weights 1:3",
59+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
60+ {Kind: Leaf, Leaf: "a", Weight: 1},
61+ {Kind: Leaf, Leaf: "b", Weight: 3},
62+ }},
63+ area: domain.Rect{Width: 81, Height: 10},
64+ },
65+ {
66+ name: "nested split",
67+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
68+ {Kind: Leaf, Leaf: "a", Weight: 1},
69+ {Kind: Split, Dir: Vertical, Weight: 1, Children: []*Node{
70+ {Kind: Leaf, Leaf: "b", Weight: 1},
71+ {Kind: Leaf, Leaf: "c", Weight: 1},
72+ }},
73+ }},
74+ area: domain.Rect{Width: 61, Height: 20},
75+ },
76+ {
77+ name: "three or more children",
78+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
79+ {Kind: Leaf, Leaf: "a", Weight: 1},
80+ {Kind: Leaf, Leaf: "b", Weight: 1},
81+ {Kind: Leaf, Leaf: "c", Weight: 1},
82+ }},
83+ area: domain.Rect{Width: 62, Height: 10},
84+ },
85+ {
86+ name: "minimum pins a child",
87+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
88+ {Kind: Leaf, Leaf: "a", Weight: 1},
89+ {Kind: Leaf, Leaf: "b", Weight: 100},
90+ }},
91+ area: domain.Rect{Width: 61, Height: 10},
92+ },
93+ {
94+ name: "vertical split",
95+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
96+ {Kind: Leaf, Leaf: "a", Weight: 1},
97+ {Kind: Leaf, Leaf: "b", Weight: 2},
98+ }},
99+ area: domain.Rect{Width: 41, Height: 10},
100+ },
101+ }
102+
103+ for _, tt := range tests {
104+ t.Run(tt.name, func(t *testing.T) {
105+ t.Parallel()
106+
107+ solved, solveOK := Solve(tt.root, tt.area)
108+ placements, dividers, dividersOK := SolveWithDividers(tt.root, tt.area)
109+ require.Equal(t, solveOK, dividersOK, "SolveWithDividers must succeed/fail exactly when Solve does")
110+ require.True(t, solveOK)
111+ require.Equal(t, solved, placements, "collecting dividers must not change the solved placements")
112+
113+ require.Len(t, dividers, countGaps(tt.root), "expected exactly one divider per sibling gap")
114+
115+ 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")
117+ for _, p := range placements {
118+ require.False(t, rectsOverlap(d.Rect, p.Content), "divider %+v overlaps pane %s content %+v", d.Rect, p.ID, p.Content)
119+ }
120+ }
121+ })
122+ }
123+}
124+
125+func TestDividersFailsLikeSolve(t *testing.T) {
126+ t.Parallel()
127+
128+ root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
129+ {Kind: Leaf, Leaf: "a", Weight: 1},
130+ {Kind: Leaf, Leaf: "b", Weight: 1},
131+ }}
132+ area := domain.Rect{Width: MinPaneCols, Height: MinPaneRows}
133+
134+ _, solveOK := Solve(root, area)
135+ require.False(t, solveOK)
136+
137+ _, _, dividersOK := SolveWithDividers(root, area)
138+ require.False(t, dividersOK)
139+}
140+
141+func TestDividersNilRoot(t *testing.T) {
142+ t.Parallel()
143+
144+ placements, dividers, ok := SolveWithDividers(nil, domain.Rect{Width: 40, Height: 10})
145+ require.False(t, ok)
146+ require.Nil(t, placements)
147+ require.Nil(t, dividers)
148+}
@@ -0,0 +1,148 @@
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+// 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 {
27+ if a.Width <= 0 || a.Height <= 0 || b.Width <= 0 || b.Height <= 0 {
28+ return false
29+ }
30+ 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
31+}
32+
33+func TestDividersMatchSolvedGaps(t *testing.T) {
34+ t.Parallel()
35+
36+ tests := []struct {
37+ name string
38+ root *Node
39+ area domain.Rect
40+ }{
41+ {
42+ name: "equal weights",
43+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
44+ {Kind: Leaf, Leaf: "a", Weight: 1},
45+ {Kind: Leaf, Leaf: "b", Weight: 1},
46+ }},
47+ area: domain.Rect{Width: 61, Height: 10},
48+ },
49+ {
50+ name: "unequal weights 2:1",
51+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
52+ {Kind: Leaf, Leaf: "a", Weight: 2},
53+ {Kind: Leaf, Leaf: "b", Weight: 1},
54+ }},
55+ area: domain.Rect{Width: 61, Height: 10},
56+ },
57+ {
58+ name: "unequal weights 1:3",
59+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
60+ {Kind: Leaf, Leaf: "a", Weight: 1},
61+ {Kind: Leaf, Leaf: "b", Weight: 3},
62+ }},
63+ area: domain.Rect{Width: 81, Height: 10},
64+ },
65+ {
66+ name: "nested split",
67+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
68+ {Kind: Leaf, Leaf: "a", Weight: 1},
69+ {Kind: Split, Dir: Vertical, Weight: 1, Children: []*Node{
70+ {Kind: Leaf, Leaf: "b", Weight: 1},
71+ {Kind: Leaf, Leaf: "c", Weight: 1},
72+ }},
73+ }},
74+ area: domain.Rect{Width: 61, Height: 20},
75+ },
76+ {
77+ name: "three or more children",
78+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
79+ {Kind: Leaf, Leaf: "a", Weight: 1},
80+ {Kind: Leaf, Leaf: "b", Weight: 1},
81+ {Kind: Leaf, Leaf: "c", Weight: 1},
82+ }},
83+ area: domain.Rect{Width: 62, Height: 10},
84+ },
85+ {
86+ name: "minimum pins a child",
87+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
88+ {Kind: Leaf, Leaf: "a", Weight: 1},
89+ {Kind: Leaf, Leaf: "b", Weight: 100},
90+ }},
91+ area: domain.Rect{Width: 61, Height: 10},
92+ },
93+ {
94+ name: "vertical split",
95+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
96+ {Kind: Leaf, Leaf: "a", Weight: 1},
97+ {Kind: Leaf, Leaf: "b", Weight: 2},
98+ }},
99+ area: domain.Rect{Width: 41, Height: 10},
100+ },
101+ }
102+
103+ for _, tt := range tests {
104+ t.Run(tt.name, func(t *testing.T) {
105+ t.Parallel()
106+
107+ solved, solveOK := Solve(tt.root, tt.area)
108+ placements, dividers, dividersOK := SolveWithDividers(tt.root, tt.area)
109+ require.Equal(t, solveOK, dividersOK, "SolveWithDividers must succeed/fail exactly when Solve does")
110+ require.True(t, solveOK)
111+ require.Equal(t, solved, placements, "collecting dividers must not change the solved placements")
112+
113+ require.Len(t, dividers, countGaps(tt.root), "expected exactly one divider per sibling gap")
114+
115+ 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")
117+ for _, p := range placements {
118+ require.False(t, rectsOverlap(d.Rect, p.Content), "divider %+v overlaps pane %s content %+v", d.Rect, p.ID, p.Content)
119+ }
120+ }
121+ })
122+ }
123+}
124+
125+func TestDividersFailsLikeSolve(t *testing.T) {
126+ t.Parallel()
127+
128+ root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
129+ {Kind: Leaf, Leaf: "a", Weight: 1},
130+ {Kind: Leaf, Leaf: "b", Weight: 1},
131+ }}
132+ area := domain.Rect{Width: MinPaneCols, Height: MinPaneRows}
133+
134+ _, solveOK := Solve(root, area)
135+ require.False(t, solveOK)
136+
137+ _, _, dividersOK := SolveWithDividers(root, area)
138+ require.False(t, dividersOK)
139+}
140+
141+func TestDividersNilRoot(t *testing.T) {
142+ t.Parallel()
143+
144+ placements, dividers, ok := SolveWithDividers(nil, domain.Rect{Width: 40, Height: 10})
145+ require.False(t, ok)
146+ require.Nil(t, placements)
147+ require.Nil(t, dividers)
148+}
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