Merge pull request #135 from bnema/fix/resize-divider-geometry
fix(render): draw pane dividers from the solved layout geometry
10 files · +321 -90
M internal/usecase/daemon/mouse_panes.go +3 -3
| @@ -43,11 +43,11 @@ func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) { | |||
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | type tabLayoutSnapshot struct { | |
| 46 | - | root *layout.Node | |
| 47 | 46 | fingerprint string | |
| 48 | 47 | area domain.Rect | |
| 49 | 48 | focus layout.PaneID | |
| 50 | 49 | placements []layout.Placement | |
| 50 | + | dividers []layout.Divider | |
| 51 | 51 | ok bool | |
| 52 | 52 | } | |
| 53 | 53 | ||
| @@ -61,8 +61,8 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot { | |||
| 61 | 61 | return tabLayoutSnapshot{} | |
| 62 | 62 | } | |
| 63 | 63 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 64 | - | placements, ok := layout.Solve(tb.tree.Root, area) | |
| 65 | - | return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok} | |
| 64 | + | placements, dividers, ok := layout.SolveWithDividers(tb.tree.Root, area) | |
| 65 | + | return tabLayoutSnapshot{fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, dividers: dividers, ok: ok} | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 68 | func layoutFingerprint(root *layout.Node) string { | |
| @@ -43,11 +43,11 @@ func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) { | |||||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | type tabLayoutSnapshot struct { | 45 | type tabLayoutSnapshot struct { | ||
| 46 | - | root *layout.Node | |||
| 47 | fingerprint string | 46 | fingerprint string | ||
| 48 | area domain.Rect | 47 | area domain.Rect | ||
| 49 | focus layout.PaneID | 48 | focus layout.PaneID | ||
| 50 | placements []layout.Placement | 49 | placements []layout.Placement | ||
| 50 | + | dividers []layout.Divider | |||
| 51 | ok bool | 51 | ok bool | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| @@ -61,8 +61,8 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot { | |||||
| 61 | return tabLayoutSnapshot{} | 61 | return tabLayoutSnapshot{} | ||
| 62 | } | 62 | } | ||
| 63 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | 63 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | ||
| 64 | - | placements, ok := layout.Solve(tb.tree.Root, area) | 64 | + | placements, dividers, ok := layout.SolveWithDividers(tb.tree.Root, area) |
| 65 | - | return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok} | 65 | + | return tabLayoutSnapshot{fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, dividers: dividers, ok: ok} |
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | func layoutFingerprint(root *layout.Node) string { | 68 | func layoutFingerprint(root *layout.Node) string { | ||
M internal/usecase/daemon/picker.go +2 -1
| @@ -630,8 +630,9 @@ func snapshotPickerPreview(tb *tab) picker.Preview { | |||
| 630 | 630 | return preview | |
| 631 | 631 | } | |
| 632 | 632 | state := capturedRenderState{layout: capturedTabLayout{ | |
| 633 | - | root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus, | |
| 633 | + | area: layoutSnap.area, focus: layoutSnap.focus, | |
| 634 | 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 { | |
| @@ -630,8 +630,9 @@ func snapshotPickerPreview(tb *tab) picker.Preview { | |||||
| 630 | return preview | 630 | return preview | ||
| 631 | } | 631 | } | ||
| 632 | state := capturedRenderState{layout: capturedTabLayout{ | 632 | state := capturedRenderState{layout: capturedTabLayout{ | ||
| 633 | - | root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus, | 633 | + | area: layoutSnap.area, focus: layoutSnap.focus, |
| 634 | placements: append([]layout.Placement(nil), layoutSnap.placements...), | 634 | placements: append([]layout.Placement(nil), layoutSnap.placements...), | ||
| 635 | + | dividers: append([]layout.Divider(nil), layoutSnap.dividers...), | |||
| 635 | fingerprint: layoutSnap.fingerprint, valid: true, | 636 | fingerprint: layoutSnap.fingerprint, valid: true, | ||
| 636 | }} | 637 | }} | ||
| 637 | for _, placement := range layoutSnap.placements { | 638 | for _, placement := range layoutSnap.placements { | ||
M internal/usecase/daemon/render.go +13 -38
| @@ -409,46 +409,21 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo | |||
| 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 -28
| @@ -20,12 +20,11 @@ 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 | |
| 26 | 27 | titleIDs []layout.PaneID | |
| 27 | - | treeNodes []layout.Node | |
| 28 | - | treeUsed int | |
| 29 | 28 | receipts []damageReceipt | |
| 30 | 29 | } | |
| 31 | 30 | ||
| @@ -54,10 +53,10 @@ type capturedRenderState struct { | |||
| 54 | 53 | } | |
| 55 | 54 | ||
| 56 | 55 | type capturedTabLayout struct { | |
| 57 | - | root *layout.Node | |
| 58 | 56 | area domain.Rect | |
| 59 | 57 | focus layout.PaneID | |
| 60 | 58 | placements []layout.Placement | |
| 59 | + | dividers []layout.Divider | |
| 61 | 60 | fingerprint string | |
| 62 | 61 | valid bool | |
| 63 | 62 | } | |
| @@ -228,17 +227,13 @@ func capturePrimaryRenderState( | |||
| 228 | 227 | defer tb.mu.Unlock() | |
| 229 | 228 | layoutSnap := solveTabLayoutLocked(tb) | |
| 230 | 229 | scratch.placements = append(scratch.placements[:0], layoutSnap.placements...) | |
| 231 | - | scratch.treeUsed = 0 | |
| 232 | - | var root *layout.Node | |
| 233 | - | if tb.tree != nil { | |
| 234 | - | root = cloneLayoutNodeIntoScratch(tb.tree.Root, scratch) | |
| 235 | - | } | |
| 230 | + | scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...) | |
| 236 | 231 | state := &scratch.state | |
| 237 | 232 | *state = capturedRenderState{ | |
| 238 | 233 | attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme, | |
| 239 | 234 | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 240 | 235 | overlays: overlays, preview: preview, | |
| 241 | - | layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 236 | + | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 242 | 237 | floatingGeneration: tb.floating.generation, | |
| 243 | 238 | receipts: scratch.receipts[:0], | |
| 244 | 239 | } | |
| @@ -311,25 +306,6 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | |||
| 311 | 306 | return append(dst[:0], src...) | |
| 312 | 307 | } | |
| 313 | 308 | ||
| 314 | - | func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node { | |
| 315 | - | if src == nil { | |
| 316 | - | return nil | |
| 317 | - | } | |
| 318 | - | if scratch.treeUsed == len(scratch.treeNodes) { | |
| 319 | - | scratch.treeNodes = append(scratch.treeNodes, layout.Node{}) | |
| 320 | - | } | |
| 321 | - | index := scratch.treeUsed | |
| 322 | - | scratch.treeUsed++ | |
| 323 | - | dst := &scratch.treeNodes[index] | |
| 324 | - | children := dst.Children[:0] | |
| 325 | - | *dst = *src | |
| 326 | - | dst.Children = children | |
| 327 | - | for _, child := range src.Children { | |
| 328 | - | dst.Children = append(dst.Children, cloneLayoutNodeIntoScratch(child, scratch)) | |
| 329 | - | } | |
| 330 | - | return dst | |
| 331 | - | } | |
| 332 | - | ||
| 333 | 309 | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | |
| 334 | 310 | style, hasStyle := p.screen.CursorStyle() | |
| 335 | 311 | hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive | |
| @@ -20,12 +20,11 @@ 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 | ||
| 26 | titleIDs []layout.PaneID | 27 | titleIDs []layout.PaneID | ||
| 27 | - | treeNodes []layout.Node | |||
| 28 | - | treeUsed int | |||
| 29 | receipts []damageReceipt | 28 | receipts []damageReceipt | ||
| 30 | } | 29 | } | ||
| 31 | 30 | ||||
| @@ -54,10 +53,10 @@ type capturedRenderState struct { | |||||
| 54 | } | 53 | } | ||
| 55 | 54 | ||||
| 56 | type capturedTabLayout struct { | 55 | type capturedTabLayout struct { | ||
| 57 | - | root *layout.Node | |||
| 58 | area domain.Rect | 56 | area domain.Rect | ||
| 59 | focus layout.PaneID | 57 | focus layout.PaneID | ||
| 60 | placements []layout.Placement | 58 | placements []layout.Placement | ||
| 59 | + | dividers []layout.Divider | |||
| 61 | fingerprint string | 60 | fingerprint string | ||
| 62 | valid bool | 61 | valid bool | ||
| 63 | } | 62 | } | ||
| @@ -228,17 +227,13 @@ func capturePrimaryRenderState( | |||||
| 228 | defer tb.mu.Unlock() | 227 | defer tb.mu.Unlock() | ||
| 229 | layoutSnap := solveTabLayoutLocked(tb) | 228 | layoutSnap := solveTabLayoutLocked(tb) | ||
| 230 | scratch.placements = append(scratch.placements[:0], layoutSnap.placements...) | 229 | scratch.placements = append(scratch.placements[:0], layoutSnap.placements...) | ||
| 231 | - | scratch.treeUsed = 0 | 230 | + | scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...) |
| 232 | - | var root *layout.Node | |||
| 233 | - | if tb.tree != nil { | |||
| 234 | - | root = cloneLayoutNodeIntoScratch(tb.tree.Root, scratch) | |||
| 235 | - | } | |||
| 236 | state := &scratch.state | 231 | state := &scratch.state | ||
| 237 | *state = capturedRenderState{ | 232 | *state = capturedRenderState{ | ||
| 238 | attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme, | 233 | attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme, | ||
| 239 | styles: request.styles, styleGeneration: request.styleGeneration, | 234 | styles: request.styles, styleGeneration: request.styleGeneration, | ||
| 240 | overlays: overlays, preview: preview, | 235 | overlays: overlays, preview: preview, | ||
| 241 | - | layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | 236 | + | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, |
| 242 | floatingGeneration: tb.floating.generation, | 237 | floatingGeneration: tb.floating.generation, | ||
| 243 | receipts: scratch.receipts[:0], | 238 | receipts: scratch.receipts[:0], | ||
| 244 | } | 239 | } | ||
| @@ -311,25 +306,6 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | |||||
| 311 | return append(dst[:0], src...) | 306 | return append(dst[:0], src...) | ||
| 312 | } | 307 | } | ||
| 313 | 308 | ||||
| 314 | - | func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node { | |||
| 315 | - | if src == nil { | |||
| 316 | - | return nil | |||
| 317 | - | } | |||
| 318 | - | if scratch.treeUsed == len(scratch.treeNodes) { | |||
| 319 | - | scratch.treeNodes = append(scratch.treeNodes, layout.Node{}) | |||
| 320 | - | } | |||
| 321 | - | index := scratch.treeUsed | |||
| 322 | - | scratch.treeUsed++ | |||
| 323 | - | dst := &scratch.treeNodes[index] | |||
| 324 | - | children := dst.Children[:0] | |||
| 325 | - | *dst = *src | |||
| 326 | - | dst.Children = children | |||
| 327 | - | for _, child := range src.Children { | |||
| 328 | - | dst.Children = append(dst.Children, cloneLayoutNodeIntoScratch(child, scratch)) | |||
| 329 | - | } | |||
| 330 | - | return dst | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | 309 | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | ||
| 334 | style, hasStyle := p.screen.CursorStyle() | 310 | style, hasStyle := p.screen.CursorStyle() | ||
| 335 | hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive | 311 | hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive | ||
A internal/usecase/daemon/render_divider_geometry_test.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // TestComposeFrameDividerFollowsWeights proves rendered divider glyphs track | |
| 13 | + | // weight-aware pane geometry rather than an equal-split recomputation. | |
| 14 | + | func TestComposeFrameDividerFollowsWeights(t *testing.T) { | |
| 15 | + | tests := []struct { | |
| 16 | + | name string | |
| 17 | + | direction layout.SplitDir | |
| 18 | + | area domain.Rect | |
| 19 | + | glyph rune | |
| 20 | + | equalX, equalY int | |
| 21 | + | weightedX, weightedY int | |
| 22 | + | }{ | |
| 23 | + | { | |
| 24 | + | name: "horizontal split", | |
| 25 | + | direction: layout.Horizontal, | |
| 26 | + | area: domain.Rect{Width: 61, Height: 5}, | |
| 27 | + | glyph: '│', | |
| 28 | + | equalX: 30, | |
| 29 | + | equalY: 1, | |
| 30 | + | weightedX: 40, | |
| 31 | + | weightedY: 1, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "vertical split", | |
| 35 | + | direction: layout.Vertical, | |
| 36 | + | area: domain.Rect{Width: 41, Height: 10}, | |
| 37 | + | glyph: '─', | |
| 38 | + | equalX: 1, | |
| 39 | + | equalY: 6, | |
| 40 | + | weightedX: 1, | |
| 41 | + | weightedY: 7, | |
| 42 | + | }, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | for _, tt := range tests { | |
| 46 | + | t.Run(tt.name, func(t *testing.T) { | |
| 47 | + | root := &layout.Node{Kind: layout.Split, Dir: tt.direction, Children: []*layout.Node{ | |
| 48 | + | {Kind: layout.Leaf, Leaf: "first", Weight: 1}, | |
| 49 | + | {Kind: layout.Leaf, Leaf: "second", Weight: 1}, | |
| 50 | + | }} | |
| 51 | + | stateFor := func() capturedRenderState { | |
| 52 | + | placements, dividers, ok := layout.SolveWithDividers(root, tt.area) | |
| 53 | + | require.True(t, ok) | |
| 54 | + | panes := make([]capturedPaneRenderState, 0, len(placements)) | |
| 55 | + | for _, placement := range placements { | |
| 56 | + | frame := cachePaneFrame(placement.Content.Width, placement.Content.Height, rune(placement.ID[0])) | |
| 57 | + | panes = append(panes, capturedPaneRenderState{ | |
| 58 | + | id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "first", | |
| 59 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | return capturedRenderState{ | |
| 63 | + | reset: true, | |
| 64 | + | layout: capturedTabLayout{area: tt.area, focus: "first", placements: placements, dividers: dividers, fingerprint: layoutFingerprint(root), valid: true}, | |
| 65 | + | panes: panes, styles: resolveStyles(nil), | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | equal := stateFor() | |
| 70 | + | out := composeFrame(equal, composeCacheInput{}) | |
| 71 | + | require.Equal(t, tt.glyph, out.frame.At(tt.equalX, tt.equalY).Rune, "equal weights must place the divider at the equal-split gap") | |
| 72 | + | ||
| 73 | + | root.Children[0].Weight = 2 | |
| 74 | + | weighted := stateFor() | |
| 75 | + | require.NotEqual(t, equal.layout.fingerprint, weighted.layout.fingerprint) | |
| 76 | + | ||
| 77 | + | out = composeFrame(weighted, out.cache, composeCacheInput{}) | |
| 78 | + | require.Equal(t, tt.glyph, out.frame.At(tt.weightedX, tt.weightedY).Rune, "the divider must follow the weight-derived gap") | |
| 79 | + | require.NotEqual(t, tt.glyph, out.frame.At(tt.equalX, tt.equalY).Rune, "the divider must not linger at the old equal-split gap") | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // TestComposeFrameDividerFollowsWeights proves rendered divider glyphs track | |||
| 13 | + | // weight-aware pane geometry rather than an equal-split recomputation. | |||
| 14 | + | func TestComposeFrameDividerFollowsWeights(t *testing.T) { | |||
| 15 | + | tests := []struct { | |||
| 16 | + | name string | |||
| 17 | + | direction layout.SplitDir | |||
| 18 | + | area domain.Rect | |||
| 19 | + | glyph rune | |||
| 20 | + | equalX, equalY int | |||
| 21 | + | weightedX, weightedY int | |||
| 22 | + | }{ | |||
| 23 | + | { | |||
| 24 | + | name: "horizontal split", | |||
| 25 | + | direction: layout.Horizontal, | |||
| 26 | + | area: domain.Rect{Width: 61, Height: 5}, | |||
| 27 | + | glyph: '│', | |||
| 28 | + | equalX: 30, | |||
| 29 | + | equalY: 1, | |||
| 30 | + | weightedX: 40, | |||
| 31 | + | weightedY: 1, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "vertical split", | |||
| 35 | + | direction: layout.Vertical, | |||
| 36 | + | area: domain.Rect{Width: 41, Height: 10}, | |||
| 37 | + | glyph: '─', | |||
| 38 | + | equalX: 1, | |||
| 39 | + | equalY: 6, | |||
| 40 | + | weightedX: 1, | |||
| 41 | + | weightedY: 7, | |||
| 42 | + | }, | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | for _, tt := range tests { | |||
| 46 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 47 | + | root := &layout.Node{Kind: layout.Split, Dir: tt.direction, Children: []*layout.Node{ | |||
| 48 | + | {Kind: layout.Leaf, Leaf: "first", Weight: 1}, | |||
| 49 | + | {Kind: layout.Leaf, Leaf: "second", Weight: 1}, | |||
| 50 | + | }} | |||
| 51 | + | stateFor := func() capturedRenderState { | |||
| 52 | + | placements, dividers, ok := layout.SolveWithDividers(root, tt.area) | |||
| 53 | + | require.True(t, ok) | |||
| 54 | + | panes := make([]capturedPaneRenderState, 0, len(placements)) | |||
| 55 | + | for _, placement := range placements { | |||
| 56 | + | frame := cachePaneFrame(placement.Content.Width, placement.Content.Height, rune(placement.ID[0])) | |||
| 57 | + | panes = append(panes, capturedPaneRenderState{ | |||
| 58 | + | id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "first", | |||
| 59 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |||
| 60 | + | }) | |||
| 61 | + | } | |||
| 62 | + | return capturedRenderState{ | |||
| 63 | + | reset: true, | |||
| 64 | + | layout: capturedTabLayout{area: tt.area, focus: "first", placements: placements, dividers: dividers, fingerprint: layoutFingerprint(root), valid: true}, | |||
| 65 | + | panes: panes, styles: resolveStyles(nil), | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | equal := stateFor() | |||
| 70 | + | out := composeFrame(equal, composeCacheInput{}) | |||
| 71 | + | require.Equal(t, tt.glyph, out.frame.At(tt.equalX, tt.equalY).Rune, "equal weights must place the divider at the equal-split gap") | |||
| 72 | + | ||||
| 73 | + | root.Children[0].Weight = 2 | |||
| 74 | + | weighted := stateFor() | |||
| 75 | + | require.NotEqual(t, equal.layout.fingerprint, weighted.layout.fingerprint) | |||
| 76 | + | ||||
| 77 | + | out = composeFrame(weighted, out.cache, composeCacheInput{}) | |||
| 78 | + | require.Equal(t, tt.glyph, out.frame.At(tt.weightedX, tt.weightedY).Rune, "the divider must follow the weight-derived gap") | |||
| 79 | + | require.NotEqual(t, tt.glyph, out.frame.At(tt.equalX, tt.equalY).Rune, "the divider must not linger at the old equal-split gap") | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | } | |||
M internal/usecase/daemon/render_pipeline.go +2 -2
| @@ -96,8 +96,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 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 +10 -8
| @@ -147,7 +147,7 @@ func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) { | |||
| 147 | 147 | } | |
| 148 | 148 | return capturedRenderState{ | |
| 149 | 149 | reset: reset, | |
| 150 | - | layout: capturedTabLayout{root: root, area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true}, | |
| 150 | + | layout: capturedTabLayout{area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true}, | |
| 151 | 151 | panes: panes, styles: resolveStyles(nil), | |
| 152 | 152 | } | |
| 153 | 153 | } | |
| @@ -258,15 +258,17 @@ 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 | - | placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}} | |
| 262 | - | leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R') | |
| 263 | - | if direction == layout.Vertical { | |
| 264 | - | placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}} | |
| 265 | - | leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R') | |
| 261 | + | area := domain.Rect{Width: 41, Height: 5} | |
| 262 | + | root := &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}} | |
| 263 | + | placements, dividers, ok := layout.SolveWithDividers(root, area) | |
| 264 | + | if !ok { | |
| 265 | + | panic("cached split fixture has invalid geometry") | |
| 266 | 266 | } | |
| 267 | + | leftFrame := cachePaneFrame(placements[0].Content.Width, placements[0].Content.Height, 'L') | |
| 268 | + | rightFrame := cachePaneFrame(placements[1].Content.Width, placements[1].Content.Height, 'R') | |
| 267 | 269 | return capturedRenderState{ | |
| 268 | 270 | reset: true, | |
| 269 | - | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true}, | |
| 271 | + | layout: capturedTabLayout{area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true}, | |
| 270 | 272 | panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 271 | 273 | theme: theme, | |
| 272 | 274 | styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, | |
| @@ -281,7 +283,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured | |||
| 281 | 283 | } | |
| 282 | 284 | return capturedRenderState{ | |
| 283 | 285 | reset: reset, | |
| 284 | - | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true}, | |
| 286 | + | layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true}, | |
| 285 | 287 | panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 286 | 288 | styles: resolveStyles(nil), | |
| 287 | 289 | } | |
| @@ -147,7 +147,7 @@ func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) { | |||||
| 147 | } | 147 | } | ||
| 148 | return capturedRenderState{ | 148 | return capturedRenderState{ | ||
| 149 | reset: reset, | 149 | reset: reset, | ||
| 150 | - | layout: capturedTabLayout{root: root, area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true}, | 150 | + | layout: capturedTabLayout{area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true}, |
| 151 | panes: panes, styles: resolveStyles(nil), | 151 | panes: panes, styles: resolveStyles(nil), | ||
| 152 | } | 152 | } | ||
| 153 | } | 153 | } | ||
| @@ -258,15 +258,17 @@ func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) { | |||||
| 258 | 258 | ||||
| 259 | func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState { | 259 | func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState { | ||
| 260 | left, right := layout.PaneID("left"), layout.PaneID("right") | 260 | left, right := layout.PaneID("left"), layout.PaneID("right") | ||
| 261 | - | placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}} | 261 | + | area := domain.Rect{Width: 41, Height: 5} |
| 262 | - | leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R') | 262 | + | root := &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}} |
| 263 | - | if direction == layout.Vertical { | 263 | + | placements, dividers, ok := layout.SolveWithDividers(root, area) |
| 264 | - | placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}} | 264 | + | if !ok { |
| 265 | - | leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R') | 265 | + | panic("cached split fixture has invalid geometry") |
| 266 | } | 266 | } | ||
| 267 | + | leftFrame := cachePaneFrame(placements[0].Content.Width, placements[0].Content.Height, 'L') | |||
| 268 | + | rightFrame := cachePaneFrame(placements[1].Content.Width, placements[1].Content.Height, 'R') | |||
| 267 | return capturedRenderState{ | 269 | return capturedRenderState{ | ||
| 268 | reset: true, | 270 | reset: true, | ||
| 269 | - | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true}, | 271 | + | layout: capturedTabLayout{area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true}, |
| 270 | panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}}, | 272 | panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}}, | ||
| 271 | theme: theme, | 273 | theme: theme, | ||
| 272 | styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, | 274 | styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, | ||
| @@ -281,7 +283,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured | |||||
| 281 | } | 283 | } | ||
| 282 | return capturedRenderState{ | 284 | return capturedRenderState{ | ||
| 283 | reset: reset, | 285 | reset: reset, | ||
| 284 | - | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true}, | 286 | + | layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true}, |
| 285 | panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | 287 | panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | ||
| 286 | styles: resolveStyles(nil), | 288 | styles: resolveStyles(nil), | ||
| 287 | } | 289 | } | ||
A internal/usecase/layout/dividers.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 5 | + | // Divider is the geometry of a single gap between two sibling panes in a Split | |
| 6 | + | // node. Dir mirrors the owning Split's Dir: Horizontal produces a | |
| 7 | + | // 1-column-wide vertical divider rect; Vertical produces a 1-row-high | |
| 8 | + | // horizontal divider rect. | |
| 9 | + | type Divider struct { | |
| 10 | + | Rect domain.Rect | |
| 11 | + | Dir SplitDir | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // SolveWithDividers behaves like Solve but also returns the gaps between | |
| 15 | + | // sibling panes. Both come from the same traversal, so a divider can never sit | |
| 16 | + | // anywhere but in the gap Solve left for it. Leaf and Stack nodes contribute no | |
| 17 | + | // dividers. | |
| 18 | + | func SolveWithDividers(root *Node, area domain.Rect) ([]Placement, []Divider, bool) { | |
| 19 | + | return solveArea(root, area, true) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // dividerBetween returns the one-cell gap splitChildRects left after prev | |
| 23 | + | // inside parent. | |
| 24 | + | func dividerBetween(dir SplitDir, parent, prev domain.Rect) Divider { | |
| 25 | + | if dir == Horizontal { | |
| 26 | + | return Divider{Rect: domain.Rect{X: prev.X + prev.Width, Y: parent.Y, Width: 1, Height: parent.Height}, Dir: Horizontal} | |
| 27 | + | } | |
| 28 | + | return Divider{Rect: domain.Rect{X: parent.X, Y: prev.Y + prev.Height, Width: parent.Width, Height: 1}, Dir: Vertical} | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | // Divider is the geometry of a single gap between two sibling panes in a Split | |||
| 6 | + | // node. Dir mirrors the owning Split's Dir: Horizontal produces a | |||
| 7 | + | // 1-column-wide vertical divider rect; Vertical produces a 1-row-high | |||
| 8 | + | // horizontal divider rect. | |||
| 9 | + | type Divider struct { | |||
| 10 | + | Rect domain.Rect | |||
| 11 | + | Dir SplitDir | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // SolveWithDividers behaves like Solve but also returns the gaps between | |||
| 15 | + | // sibling panes. Both come from the same traversal, so a divider can never sit | |||
| 16 | + | // anywhere but in the gap Solve left for it. Leaf and Stack nodes contribute no | |||
| 17 | + | // dividers. | |||
| 18 | + | func SolveWithDividers(root *Node, area domain.Rect) ([]Placement, []Divider, bool) { | |||
| 19 | + | return solveArea(root, area, true) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // dividerBetween returns the one-cell gap splitChildRects left after prev | |||
| 23 | + | // inside parent. | |||
| 24 | + | func dividerBetween(dir SplitDir, parent, prev domain.Rect) Divider { | |||
| 25 | + | if dir == Horizontal { | |||
| 26 | + | return Divider{Rect: domain.Rect{X: prev.X + prev.Width, Y: parent.Y, Width: 1, Height: parent.Height}, Dir: Horizontal} | |||
| 27 | + | } | |||
| 28 | + | return Divider{Rect: domain.Rect{X: parent.X, Y: prev.Y + prev.Height, Width: parent.Width, Height: 1}, Dir: Vertical} | |||
| 29 | + | } | |||
A internal/usecase/layout/dividers_test.go +146 -0
| @@ -0,0 +1,146 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func rectsOverlap(a, b domain.Rect) bool { | |
| 11 | + | if a.Width <= 0 || a.Height <= 0 || b.Width <= 0 || b.Height <= 0 { | |
| 12 | + | return false | |
| 13 | + | } | |
| 14 | + | return a.X < b.X+b.Width && b.X < a.X+a.Width && a.Y < b.Y+b.Height && b.Y < a.Y+a.Height | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestDividersMatchSolvedGaps(t *testing.T) { | |
| 18 | + | t.Parallel() | |
| 19 | + | ||
| 20 | + | tests := []struct { | |
| 21 | + | name string | |
| 22 | + | root *Node | |
| 23 | + | area domain.Rect | |
| 24 | + | expected []Divider | |
| 25 | + | }{ | |
| 26 | + | { | |
| 27 | + | name: "equal weights", | |
| 28 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 29 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |
| 30 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |
| 31 | + | }}, | |
| 32 | + | area: domain.Rect{Width: 61, Height: 10}, | |
| 33 | + | expected: []Divider{{Rect: domain.Rect{X: 30, Width: 1, Height: 10}, Dir: Horizontal}}, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "unequal weights 2:1", | |
| 37 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 38 | + | {Kind: Leaf, Leaf: "a", Weight: 2}, | |
| 39 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |
| 40 | + | }}, | |
| 41 | + | area: domain.Rect{Width: 61, Height: 10}, | |
| 42 | + | expected: []Divider{{Rect: domain.Rect{X: 40, Width: 1, Height: 10}, Dir: Horizontal}}, | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "unequal weights 1:3", | |
| 46 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 47 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |
| 48 | + | {Kind: Leaf, Leaf: "b", Weight: 3}, | |
| 49 | + | }}, | |
| 50 | + | area: domain.Rect{Width: 81, Height: 10}, | |
| 51 | + | expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}}, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "nested split", | |
| 55 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 56 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |
| 57 | + | {Kind: Split, Dir: Vertical, Weight: 1, Children: []*Node{ | |
| 58 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |
| 59 | + | {Kind: Leaf, Leaf: "c", Weight: 1}, | |
| 60 | + | }}, | |
| 61 | + | }}, | |
| 62 | + | area: domain.Rect{Width: 61, Height: 20}, | |
| 63 | + | expected: []Divider{ | |
| 64 | + | {Rect: domain.Rect{X: 30, Width: 1, Height: 20}, Dir: Horizontal}, | |
| 65 | + | {Rect: domain.Rect{X: 31, Y: 10, Width: 30, Height: 1}, Dir: Vertical}, | |
| 66 | + | }, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "three or more children", | |
| 70 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 71 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |
| 72 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |
| 73 | + | {Kind: Leaf, Leaf: "c", Weight: 1}, | |
| 74 | + | }}, | |
| 75 | + | area: domain.Rect{Width: 62, Height: 10}, | |
| 76 | + | expected: []Divider{ | |
| 77 | + | {Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}, | |
| 78 | + | {Rect: domain.Rect{X: 41, Width: 1, Height: 10}, Dir: Horizontal}, | |
| 79 | + | }, | |
| 80 | + | }, | |
| 81 | + | { | |
| 82 | + | name: "minimum pins a child", | |
| 83 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 84 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |
| 85 | + | {Kind: Leaf, Leaf: "b", Weight: 100}, | |
| 86 | + | }}, | |
| 87 | + | area: domain.Rect{Width: 61, Height: 10}, | |
| 88 | + | expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}}, | |
| 89 | + | }, | |
| 90 | + | { | |
| 91 | + | name: "vertical split", | |
| 92 | + | root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{ | |
| 93 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |
| 94 | + | {Kind: Leaf, Leaf: "b", Weight: 2}, | |
| 95 | + | }}, | |
| 96 | + | area: domain.Rect{Width: 41, Height: 10}, | |
| 97 | + | expected: []Divider{{Rect: domain.Rect{Y: 3, Width: 41, Height: 1}, Dir: Vertical}}, | |
| 98 | + | }, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | for _, tt := range tests { | |
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |
| 103 | + | t.Parallel() | |
| 104 | + | ||
| 105 | + | solved, solveOK := Solve(tt.root, tt.area) | |
| 106 | + | placements, dividers, dividersOK := SolveWithDividers(tt.root, tt.area) | |
| 107 | + | require.Equal(t, solveOK, dividersOK, "SolveWithDividers must succeed/fail exactly when Solve does") | |
| 108 | + | require.True(t, solveOK) | |
| 109 | + | require.Equal(t, solved, placements, "collecting dividers must not change the solved placements") | |
| 110 | + | ||
| 111 | + | require.Equal(t, tt.expected, dividers, "divider geometry and direction must match the solved gaps exactly") | |
| 112 | + | ||
| 113 | + | for _, d := range dividers { | |
| 114 | + | require.True(t, d.Rect.Width == 1 || d.Rect.Height == 1, "divider rect must be a single row or column") | |
| 115 | + | for _, p := range placements { | |
| 116 | + | require.False(t, rectsOverlap(d.Rect, p.Content), "divider %+v overlaps pane %s content %+v", d.Rect, p.ID, p.Content) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestDividersFailsLikeSolve(t *testing.T) { | |
| 124 | + | t.Parallel() | |
| 125 | + | ||
| 126 | + | root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 127 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |
| 128 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |
| 129 | + | }} | |
| 130 | + | area := domain.Rect{Width: MinPaneCols, Height: MinPaneRows} | |
| 131 | + | ||
| 132 | + | _, solveOK := Solve(root, area) | |
| 133 | + | require.False(t, solveOK) | |
| 134 | + | ||
| 135 | + | _, _, dividersOK := SolveWithDividers(root, area) | |
| 136 | + | require.False(t, dividersOK) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestDividersNilRoot(t *testing.T) { | |
| 140 | + | t.Parallel() | |
| 141 | + | ||
| 142 | + | placements, dividers, ok := SolveWithDividers(nil, domain.Rect{Width: 40, Height: 10}) | |
| 143 | + | require.False(t, ok) | |
| 144 | + | require.Nil(t, placements) | |
| 145 | + | require.Nil(t, dividers) | |
| 146 | + | } | |
| @@ -0,0 +1,146 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func rectsOverlap(a, b domain.Rect) bool { | |||
| 11 | + | if a.Width <= 0 || a.Height <= 0 || b.Width <= 0 || b.Height <= 0 { | |||
| 12 | + | return false | |||
| 13 | + | } | |||
| 14 | + | return a.X < b.X+b.Width && b.X < a.X+a.Width && a.Y < b.Y+b.Height && b.Y < a.Y+a.Height | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestDividersMatchSolvedGaps(t *testing.T) { | |||
| 18 | + | t.Parallel() | |||
| 19 | + | ||||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | root *Node | |||
| 23 | + | area domain.Rect | |||
| 24 | + | expected []Divider | |||
| 25 | + | }{ | |||
| 26 | + | { | |||
| 27 | + | name: "equal weights", | |||
| 28 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 29 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |||
| 30 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |||
| 31 | + | }}, | |||
| 32 | + | area: domain.Rect{Width: 61, Height: 10}, | |||
| 33 | + | expected: []Divider{{Rect: domain.Rect{X: 30, Width: 1, Height: 10}, Dir: Horizontal}}, | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "unequal weights 2:1", | |||
| 37 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 38 | + | {Kind: Leaf, Leaf: "a", Weight: 2}, | |||
| 39 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |||
| 40 | + | }}, | |||
| 41 | + | area: domain.Rect{Width: 61, Height: 10}, | |||
| 42 | + | expected: []Divider{{Rect: domain.Rect{X: 40, Width: 1, Height: 10}, Dir: Horizontal}}, | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "unequal weights 1:3", | |||
| 46 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 47 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |||
| 48 | + | {Kind: Leaf, Leaf: "b", Weight: 3}, | |||
| 49 | + | }}, | |||
| 50 | + | area: domain.Rect{Width: 81, Height: 10}, | |||
| 51 | + | expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}}, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "nested split", | |||
| 55 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 56 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |||
| 57 | + | {Kind: Split, Dir: Vertical, Weight: 1, Children: []*Node{ | |||
| 58 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |||
| 59 | + | {Kind: Leaf, Leaf: "c", Weight: 1}, | |||
| 60 | + | }}, | |||
| 61 | + | }}, | |||
| 62 | + | area: domain.Rect{Width: 61, Height: 20}, | |||
| 63 | + | expected: []Divider{ | |||
| 64 | + | {Rect: domain.Rect{X: 30, Width: 1, Height: 20}, Dir: Horizontal}, | |||
| 65 | + | {Rect: domain.Rect{X: 31, Y: 10, Width: 30, Height: 1}, Dir: Vertical}, | |||
| 66 | + | }, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "three or more children", | |||
| 70 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 71 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |||
| 72 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |||
| 73 | + | {Kind: Leaf, Leaf: "c", Weight: 1}, | |||
| 74 | + | }}, | |||
| 75 | + | area: domain.Rect{Width: 62, Height: 10}, | |||
| 76 | + | expected: []Divider{ | |||
| 77 | + | {Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}, | |||
| 78 | + | {Rect: domain.Rect{X: 41, Width: 1, Height: 10}, Dir: Horizontal}, | |||
| 79 | + | }, | |||
| 80 | + | }, | |||
| 81 | + | { | |||
| 82 | + | name: "minimum pins a child", | |||
| 83 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 84 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |||
| 85 | + | {Kind: Leaf, Leaf: "b", Weight: 100}, | |||
| 86 | + | }}, | |||
| 87 | + | area: domain.Rect{Width: 61, Height: 10}, | |||
| 88 | + | expected: []Divider{{Rect: domain.Rect{X: 20, Width: 1, Height: 10}, Dir: Horizontal}}, | |||
| 89 | + | }, | |||
| 90 | + | { | |||
| 91 | + | name: "vertical split", | |||
| 92 | + | root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{ | |||
| 93 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |||
| 94 | + | {Kind: Leaf, Leaf: "b", Weight: 2}, | |||
| 95 | + | }}, | |||
| 96 | + | area: domain.Rect{Width: 41, Height: 10}, | |||
| 97 | + | expected: []Divider{{Rect: domain.Rect{Y: 3, Width: 41, Height: 1}, Dir: Vertical}}, | |||
| 98 | + | }, | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | for _, tt := range tests { | |||
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 103 | + | t.Parallel() | |||
| 104 | + | ||||
| 105 | + | solved, solveOK := Solve(tt.root, tt.area) | |||
| 106 | + | placements, dividers, dividersOK := SolveWithDividers(tt.root, tt.area) | |||
| 107 | + | require.Equal(t, solveOK, dividersOK, "SolveWithDividers must succeed/fail exactly when Solve does") | |||
| 108 | + | require.True(t, solveOK) | |||
| 109 | + | require.Equal(t, solved, placements, "collecting dividers must not change the solved placements") | |||
| 110 | + | ||||
| 111 | + | require.Equal(t, tt.expected, dividers, "divider geometry and direction must match the solved gaps exactly") | |||
| 112 | + | ||||
| 113 | + | for _, d := range dividers { | |||
| 114 | + | require.True(t, d.Rect.Width == 1 || d.Rect.Height == 1, "divider rect must be a single row or column") | |||
| 115 | + | for _, p := range placements { | |||
| 116 | + | require.False(t, rectsOverlap(d.Rect, p.Content), "divider %+v overlaps pane %s content %+v", d.Rect, p.ID, p.Content) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestDividersFailsLikeSolve(t *testing.T) { | |||
| 124 | + | t.Parallel() | |||
| 125 | + | ||||
| 126 | + | root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 127 | + | {Kind: Leaf, Leaf: "a", Weight: 1}, | |||
| 128 | + | {Kind: Leaf, Leaf: "b", Weight: 1}, | |||
| 129 | + | }} | |||
| 130 | + | area := domain.Rect{Width: MinPaneCols, Height: MinPaneRows} | |||
| 131 | + | ||||
| 132 | + | _, solveOK := Solve(root, area) | |||
| 133 | + | require.False(t, solveOK) | |||
| 134 | + | ||||
| 135 | + | _, _, dividersOK := SolveWithDividers(root, area) | |||
| 136 | + | require.False(t, dividersOK) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestDividersNilRoot(t *testing.T) { | |||
| 140 | + | t.Parallel() | |||
| 141 | + | ||||
| 142 | + | placements, dividers, ok := SolveWithDividers(nil, domain.Rect{Width: 40, Height: 10}) | |||
| 143 | + | require.False(t, ok) | |||
| 144 | + | require.Nil(t, placements) | |||
| 145 | + | require.Nil(t, dividers) | |||
| 146 | + | } | |||
M internal/usecase/layout/geometry.go +30 -10
| @@ -9,25 +9,42 @@ import ( | |||
| 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 | } | ||