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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 48 | 48 | area domain.Rect | |
| 49 | 49 | focus layout.PaneID | |
| 50 | 50 | placements []layout.Placement | |
| 51 | + | dividers []layout.Divider | |
| 51 | 52 | ok bool | |
| 52 | 53 | } | |
| 53 | 54 | ||
| @@ -61,8 +62,8 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot { | |||
| 61 | 62 | return tabLayoutSnapshot{} | |
| 62 | 63 | } | |
| 63 | 64 | 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} | |
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | 69 | func layoutFingerprint(root *layout.Node) string { | |
| @@ -48,6 +48,7 @@ type tabLayoutSnapshot struct { | |||||
| 48 | area domain.Rect | 48 | area domain.Rect | ||
| 49 | focus layout.PaneID | 49 | focus layout.PaneID | ||
| 50 | placements []layout.Placement | 50 | placements []layout.Placement | ||
| 51 | + | dividers []layout.Divider | |||
| 51 | ok bool | 52 | 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 { | |||
| 632 | 632 | state := capturedRenderState{layout: capturedTabLayout{ | |
| 633 | 633 | root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus, | |
| 634 | 634 | placements: append([]layout.Placement(nil), layoutSnap.placements...), | |
| 635 | + | dividers: append([]layout.Divider(nil), layoutSnap.dividers...), | |
| 635 | 636 | fingerprint: layoutSnap.fingerprint, valid: true, | |
| 636 | 637 | }} | |
| 637 | 638 | 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 | |||
| 409 | 409 | } | |
| 410 | 410 | } | |
| 411 | 411 | ||
| 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 = '─' | |
| 444 | 421 | } | |
| 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}) | |
| 450 | 426 | } | |
| 451 | - | y++ | |
| 452 | 427 | } | |
| 453 | 428 | } | |
| 454 | 429 | } | |
| @@ -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 | - | return | 414 | + | // 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%count | 419 | + | if d.Dir == layout.Vertical { |
| 420 | - | x := r.X | 420 | + | 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 += h | 423 | + | 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 { | |||
| 20 | 20 | state capturedRenderState | |
| 21 | 21 | panes []capturedPaneRenderState | |
| 22 | 22 | placements []layout.Placement | |
| 23 | + | dividers []layout.Divider | |
| 23 | 24 | statusTabs []statusTab | |
| 24 | 25 | mru []recentSession | |
| 25 | 26 | ranked []rankedRecent | |
| @@ -58,6 +59,7 @@ type capturedTabLayout struct { | |||
| 58 | 59 | area domain.Rect | |
| 59 | 60 | focus layout.PaneID | |
| 60 | 61 | placements []layout.Placement | |
| 62 | + | dividers []layout.Divider | |
| 61 | 63 | fingerprint string | |
| 62 | 64 | valid bool | |
| 63 | 65 | } | |
| @@ -228,6 +230,7 @@ func capturePrimaryRenderState( | |||
| 228 | 230 | defer tb.mu.Unlock() | |
| 229 | 231 | layoutSnap := solveTabLayoutLocked(tb) | |
| 230 | 232 | scratch.placements = append(scratch.placements[:0], layoutSnap.placements...) | |
| 233 | + | scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...) | |
| 231 | 234 | scratch.treeUsed = 0 | |
| 232 | 235 | var root *layout.Node | |
| 233 | 236 | if tb.tree != nil { | |
| @@ -238,7 +241,7 @@ func capturePrimaryRenderState( | |||
| 238 | 241 | attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme, | |
| 239 | 242 | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 240 | 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 | 245 | floatingGeneration: tb.floating.generation, | |
| 243 | 246 | receipts: scratch.receipts[:0], | |
| 244 | 247 | } | |
| @@ -20,6 +20,7 @@ type renderCaptureScratch struct { | |||||
| 20 | state capturedRenderState | 20 | state capturedRenderState | ||
| 21 | panes []capturedPaneRenderState | 21 | panes []capturedPaneRenderState | ||
| 22 | placements []layout.Placement | 22 | placements []layout.Placement | ||
| 23 | + | dividers []layout.Divider | |||
| 23 | statusTabs []statusTab | 24 | statusTabs []statusTab | ||
| 24 | mru []recentSession | 25 | mru []recentSession | ||
| 25 | ranked []rankedRecent | 26 | ranked []rankedRecent | ||
| @@ -58,6 +59,7 @@ type capturedTabLayout struct { | |||||
| 58 | area domain.Rect | 59 | area domain.Rect | ||
| 59 | focus layout.PaneID | 60 | focus layout.PaneID | ||
| 60 | placements []layout.Placement | 61 | placements []layout.Placement | ||
| 62 | + | dividers []layout.Divider | |||
| 61 | fingerprint string | 63 | fingerprint string | ||
| 62 | valid bool | 64 | 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 = 0 | 234 | scratch.treeUsed = 0 | ||
| 232 | var root *layout.Node | 235 | 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 ... | |||
| 96 | 96 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 97 | 97 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| 98 | 98 | 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)) | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | 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.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.visible | 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.visible | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +4 -1
| @@ -258,15 +258,18 @@ func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) { | |||
| 258 | 258 | ||
| 259 | 259 | func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState { | |
| 260 | 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 | 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 | 264 | leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R') | |
| 263 | 265 | if direction == layout.Vertical { | |
| 264 | 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 | 267 | leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R') | |
| 266 | 268 | } | |
| 269 | + | _, dividers, _ := layout.SolveWithDividers(root, area) | |
| 267 | 270 | return capturedRenderState{ | |
| 268 | 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 | 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 | 274 | theme: theme, | |
| 272 | 275 | 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 ( | |||
| 9 | 9 | ||
| 10 | 10 | // Solve returns deterministic pane placements for root inside area. | |
| 11 | 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 | 20 | if root == nil || area.Width <= 0 || area.Height <= 0 { | |
| 13 | - | return nil, false | |
| 21 | + | return nil, nil, false | |
| 14 | 22 | } | |
| 15 | 23 | minWidth, ok := minimumExtent(root, Horizontal) | |
| 16 | 24 | if !ok || area.Width < minWidth { | |
| 17 | - | return nil, false | |
| 25 | + | return nil, nil, false | |
| 18 | 26 | } | |
| 19 | 27 | minHeight, ok := minimumExtent(root, Vertical) | |
| 20 | 28 | if !ok || area.Height < minHeight { | |
| 21 | - | return nil, false | |
| 29 | + | return nil, nil, false | |
| 22 | 30 | } | |
| 23 | 31 | 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 | |
| 26 | 38 | } | |
| 27 | - | return out, true | |
| 39 | + | 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 | 48 | switch n.Kind { | |
| 32 | 49 | case Leaf: | |
| 33 | 50 | if r.Width < MinPaneCols || r.Height < MinPaneRows { | |
| @@ -36,7 +53,7 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool { | |||
| 36 | 53 | *out = append(*out, Placement{ID: n.Leaf, Content: r}) | |
| 37 | 54 | return true | |
| 38 | 55 | case Split: | |
| 39 | - | return solveSplit(n, r, out) | |
| 56 | + | return solveSplit(n, r, out, divs) | |
| 40 | 57 | case Stack: | |
| 41 | 58 | return solveStack(n, r, out) | |
| 42 | 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 | 65 | rects, ok := splitChildRects(n, r) | |
| 49 | 66 | if !ok { | |
| 50 | 67 | return false | |
| 51 | 68 | } | |
| 52 | 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 | 74 | return false | |
| 55 | 75 | } | |
| 56 | 76 | } | |
| @@ -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, false | 21 | + | 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, false | 25 | + | 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, false | 29 | + | return nil, nil, false |
| 22 | } | 30 | } | ||
| 23 | var out []Placement | 31 | var out []Placement | ||
| 24 | - | if !solve(root, area, &out) { | 32 | + | var dividers *[]Divider |
| 25 | - | return nil, false | 33 | + | if withDividers { |
| 34 | + | dividers = new([]Divider) | |||
| 35 | + | } | |||
| 36 | + | if !solve(root, area, &out, dividers) { | |||
| 37 | + | return nil, nil, false | |||
| 26 | } | 38 | } | ||
| 27 | - | return out, true | 39 | + | 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 true | 54 | 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 false | 67 | 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 false | 74 | return false | ||
| 55 | } | 75 | } | ||
| 56 | } | 76 | } | ||