detached cbe74eb

feat(layout): support weighted split geometry

commit
cbe74eb0791f6372a373b126d80d6bb70d0658fd
author
brice · · 2026-07-24 15:01 UTC
committer
brice · · 2026-07-25 04:08 UTC
parent
c240dab
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +612 -49
M internal/usecase/layout/geometry.go +201 -30
@@ -1,12 +1,25 @@
11 package layout
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "math"
5+ "sort"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
49
510 // Solve returns deterministic pane placements for root inside area.
611 func Solve(root *Node, area domain.Rect) ([]Placement, bool) {
712 if root == nil || area.Width <= 0 || area.Height <= 0 {
813 return nil, false
914 }
15+ minWidth, ok := minimumExtent(root, Horizontal)
16+ if !ok || area.Width < minWidth {
17+ return nil, false
18+ }
19+ minHeight, ok := minimumExtent(root, Vertical)
20+ if !ok || area.Height < minHeight {
21+ return nil, false
22+ }
1023 var out []Placement
1124 if !solve(root, area, &out) {
1225 return nil, false
@@ -32,50 +45,208 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool {
3245 }
3346
3447 func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool {
48+ rects, ok := splitChildRects(n, r)
49+ if !ok {
50+ return false
51+ }
52+ for i, child := range n.Children {
53+ if !solve(child, rects[i], out) {
54+ return false
55+ }
56+ }
57+ return true
58+}
59+
60+func splitChildRects(n *Node, r domain.Rect) ([]domain.Rect, bool) {
3561 count := len(n.Children)
3662 if count == 0 {
37- return false
63+ return nil, false
3864 }
3965 if count == 1 {
40- return solve(n.Children[0], r, out)
66+ return []domain.Rect{r}, true
4167 }
42- if n.Dir == Horizontal {
43- usable := r.Width - (count - 1)
44- if usable < count*MinPaneCols || r.Height < MinPaneRows {
45- return false
68+
69+ total := r.Width
70+ if n.Dir == Vertical {
71+ total = r.Height
72+ }
73+ usable := total - (count - 1)
74+ minimums := make([]int, count)
75+ weights := make([]float64, count)
76+ for i, child := range n.Children {
77+ minimum, ok := minimumExtent(child, n.Dir)
78+ if !ok {
79+ return nil, false
80+ }
81+ minimums[i] = minimum
82+ weights[i] = effectiveWeight(child.Weight)
83+ }
84+ extents, ok := distribute(usable, minimums, weights)
85+ if !ok {
86+ return nil, false
87+ }
88+
89+ rects := make([]domain.Rect, count)
90+ x, y := r.X, r.Y
91+ for i, extent := range extents {
92+ if n.Dir == Horizontal {
93+ rects[i] = domain.Rect{X: x, Y: r.Y, Width: extent, Height: r.Height}
94+ x += extent + 1
95+ continue
96+ }
97+ rects[i] = domain.Rect{X: r.X, Y: y, Width: r.Width, Height: extent}
98+ y += extent + 1
99+ }
100+ return rects, true
101+}
102+
103+func effectiveWeight(weight float64) float64 {
104+ if weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
105+ return 1
106+ }
107+ return weight
108+}
109+
110+func minimumExtent(n *Node, axis SplitDir) (int, bool) {
111+ if n == nil {
112+ return 0, false
113+ }
114+ switch n.Kind {
115+ case Leaf:
116+ if axis == Horizontal {
117+ return MinPaneCols, true
118+ }
119+ return MinPaneRows, true
120+ case Stack:
121+ if len(n.Children) == 0 {
122+ return 0, false
123+ }
124+ for _, child := range n.Children {
125+ if child == nil || child.Kind != Leaf {
126+ return 0, false
127+ }
128+ }
129+ if axis == Horizontal {
130+ return MinPaneCols, true
131+ }
132+ return len(n.Children) + 1, true
133+ case Split:
134+ if len(n.Children) == 0 {
135+ return 0, false
46136 }
47- base, rem := usable/count, usable%count
48- x := r.X
49- for i, child := range n.Children {
50- w := base
51- if i < rem {
52- w++
137+ if n.Dir == axis {
138+ total := len(n.Children) - 1
139+ for _, child := range n.Children {
140+ minimum, ok := minimumExtent(child, axis)
141+ if !ok {
142+ return 0, false
143+ }
144+ total += minimum
53145 }
54- if !solve(child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, out) {
55- return false
146+ return total, true
147+ }
148+ largest := 0
149+ for _, child := range n.Children {
150+ minimum, ok := minimumExtent(child, axis)
151+ if !ok {
152+ return 0, false
56153 }
57- x += w + 1
154+ largest = max(largest, minimum)
58155 }
59- return true
156+ return largest, true
157+ default:
158+ return 0, false
60159 }
160+}
61161
62- usable := r.Height - (count - 1)
63- if usable < count*MinPaneRows || r.Width < MinPaneCols {
64- return false
162+// distribute allocates total integer cells proportionally while respecting each
163+// child's minimum. It uses stable largest-remainder rounding after all
164+// constrained children have been pinned.
165+func distribute(total int, minimums []int, weights []float64) ([]int, bool) {
166+ if total < 0 || len(minimums) == 0 || len(minimums) != len(weights) {
167+ return nil, false
65168 }
66- base, rem := usable/count, usable%count
67- y := r.Y
68- for i, child := range n.Children {
69- h := base
70- if i < rem {
71- h++
169+ minimumTotal := 0
170+ for _, minimum := range minimums {
171+ if minimum < 0 {
172+ return nil, false
72173 }
73- if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) {
74- return false
174+ minimumTotal += minimum
175+ }
176+ if minimumTotal > total {
177+ return nil, false
178+ }
179+
180+ allocations := make([]int, len(minimums))
181+ active := make([]bool, len(minimums))
182+ for i := range active {
183+ active[i] = true
184+ }
185+ remaining := total
186+
187+ for {
188+ normalized, sum := normalizedActiveWeights(weights, active)
189+ pinned := false
190+ for i := range active {
191+ if !active[i] {
192+ continue
193+ }
194+ quota := float64(remaining) * normalized[i] / sum
195+ if quota < float64(minimums[i]) {
196+ allocations[i] = minimums[i]
197+ remaining -= minimums[i]
198+ active[i] = false
199+ pinned = true
200+ }
201+ }
202+ if !pinned {
203+ break
75204 }
76- y += h + 1
77205 }
78- return true
206+
207+ normalized, sum := normalizedActiveWeights(weights, active)
208+ type remainder struct {
209+ index int
210+ fraction float64
211+ }
212+ remainders := make([]remainder, 0, len(weights))
213+ allocated := total - remaining
214+ for i := range active {
215+ if !active[i] {
216+ continue
217+ }
218+ quota := float64(remaining) * normalized[i] / sum
219+ whole := int(math.Floor(quota))
220+ allocations[i] = whole
221+ allocated += whole
222+ remainders = append(remainders, remainder{index: i, fraction: quota - float64(whole)})
223+ }
224+ sort.SliceStable(remainders, func(i, j int) bool {
225+ return remainders[i].fraction > remainders[j].fraction
226+ })
227+ for cells, i := total-allocated, 0; cells > 0; cells, i = cells-1, i+1 {
228+ allocations[remainders[i%len(remainders)].index]++
229+ }
230+ return allocations, true
231+}
232+
233+func normalizedActiveWeights(weights []float64, active []bool) ([]float64, float64) {
234+ largest := 0.0
235+ for i, weight := range weights {
236+ if active[i] {
237+ largest = math.Max(largest, effectiveWeight(weight))
238+ }
239+ }
240+ normalized := make([]float64, len(weights))
241+ sum := 0.0
242+ for i, weight := range weights {
243+ if !active[i] {
244+ continue
245+ }
246+ normalized[i] = effectiveWeight(weight) / largest
247+ sum += normalized[i]
248+ }
249+ return normalized, sum
79250 }
80251
81252 func solveStack(n *Node, r domain.Rect, out *[]Placement) bool {
@@ -1,12 +1,25 @@
1 package layout1 package layout
2 2
3-import "github.com/bnema/vev/internal/domain"3+import (
4+ "math"
5+ "sort"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
4 9
5 // Solve returns deterministic pane placements for root inside area.10 // Solve returns deterministic pane placements for root inside area.
6 func Solve(root *Node, area domain.Rect) ([]Placement, bool) {11 func Solve(root *Node, area domain.Rect) ([]Placement, bool) {
7 if root == nil || area.Width <= 0 || area.Height <= 0 {12 if root == nil || area.Width <= 0 || area.Height <= 0 {
8 return nil, false13 return nil, false
9 }14 }
15+ minWidth, ok := minimumExtent(root, Horizontal)
16+ if !ok || area.Width < minWidth {
17+ return nil, false
18+ }
19+ minHeight, ok := minimumExtent(root, Vertical)
20+ if !ok || area.Height < minHeight {
21+ return nil, false
22+ }
10 var out []Placement23 var out []Placement
11 if !solve(root, area, &out) {24 if !solve(root, area, &out) {
12 return nil, false25 return nil, false
@@ -32,50 +45,208 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool {
32 }45 }
33 46
34 func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool {47 func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool {
48+ rects, ok := splitChildRects(n, r)
49+ if !ok {
50+ return false
51+ }
52+ for i, child := range n.Children {
53+ if !solve(child, rects[i], out) {
54+ return false
55+ }
56+ }
57+ return true
58+}
59+
60+func splitChildRects(n *Node, r domain.Rect) ([]domain.Rect, bool) {
35 count := len(n.Children)61 count := len(n.Children)
36 if count == 0 {62 if count == 0 {
37- return false63+ return nil, false
38 }64 }
39 if count == 1 {65 if count == 1 {
40- return solve(n.Children[0], r, out)66+ return []domain.Rect{r}, true
41 }67 }
42- if n.Dir == Horizontal {68+
43- usable := r.Width - (count - 1)69+ total := r.Width
44- if usable < count*MinPaneCols || r.Height < MinPaneRows {70+ if n.Dir == Vertical {
45- return false71+ total = r.Height
72+ }
73+ usable := total - (count - 1)
74+ minimums := make([]int, count)
75+ weights := make([]float64, count)
76+ for i, child := range n.Children {
77+ minimum, ok := minimumExtent(child, n.Dir)
78+ if !ok {
79+ return nil, false
80+ }
81+ minimums[i] = minimum
82+ weights[i] = effectiveWeight(child.Weight)
83+ }
84+ extents, ok := distribute(usable, minimums, weights)
85+ if !ok {
86+ return nil, false
87+ }
88+
89+ rects := make([]domain.Rect, count)
90+ x, y := r.X, r.Y
91+ for i, extent := range extents {
92+ if n.Dir == Horizontal {
93+ rects[i] = domain.Rect{X: x, Y: r.Y, Width: extent, Height: r.Height}
94+ x += extent + 1
95+ continue
96+ }
97+ rects[i] = domain.Rect{X: r.X, Y: y, Width: r.Width, Height: extent}
98+ y += extent + 1
99+ }
100+ return rects, true
101+}
102+
103+func effectiveWeight(weight float64) float64 {
104+ if weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
105+ return 1
106+ }
107+ return weight
108+}
109+
110+func minimumExtent(n *Node, axis SplitDir) (int, bool) {
111+ if n == nil {
112+ return 0, false
113+ }
114+ switch n.Kind {
115+ case Leaf:
116+ if axis == Horizontal {
117+ return MinPaneCols, true
118+ }
119+ return MinPaneRows, true
120+ case Stack:
121+ if len(n.Children) == 0 {
122+ return 0, false
123+ }
124+ for _, child := range n.Children {
125+ if child == nil || child.Kind != Leaf {
126+ return 0, false
127+ }
128+ }
129+ if axis == Horizontal {
130+ return MinPaneCols, true
131+ }
132+ return len(n.Children) + 1, true
133+ case Split:
134+ if len(n.Children) == 0 {
135+ return 0, false
46 }136 }
47- base, rem := usable/count, usable%count137+ if n.Dir == axis {
48- x := r.X138+ total := len(n.Children) - 1
49- for i, child := range n.Children {139+ for _, child := range n.Children {
50- w := base140+ minimum, ok := minimumExtent(child, axis)
51- if i < rem {141+ if !ok {
52- w++142+ return 0, false
143+ }
144+ total += minimum
53 }145 }
54- if !solve(child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, out) {146+ return total, true
55- return false147+ }
148+ largest := 0
149+ for _, child := range n.Children {
150+ minimum, ok := minimumExtent(child, axis)
151+ if !ok {
152+ return 0, false
56 }153 }
57- x += w + 1154+ largest = max(largest, minimum)
58 }155 }
59- return true156+ return largest, true
157+ default:
158+ return 0, false
60 }159 }
160+}
61 161
62- usable := r.Height - (count - 1)162+// distribute allocates total integer cells proportionally while respecting each
63- if usable < count*MinPaneRows || r.Width < MinPaneCols {163+// child's minimum. It uses stable largest-remainder rounding after all
64- return false164+// constrained children have been pinned.
165+func distribute(total int, minimums []int, weights []float64) ([]int, bool) {
166+ if total < 0 || len(minimums) == 0 || len(minimums) != len(weights) {
167+ return nil, false
65 }168 }
66- base, rem := usable/count, usable%count169+ minimumTotal := 0
67- y := r.Y170+ for _, minimum := range minimums {
68- for i, child := range n.Children {171+ if minimum < 0 {
69- h := base172+ return nil, false
70- if i < rem {
71- h++
72 }173 }
73- if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) {174+ minimumTotal += minimum
74- return false175+ }
176+ if minimumTotal > total {
177+ return nil, false
178+ }
179+
180+ allocations := make([]int, len(minimums))
181+ active := make([]bool, len(minimums))
182+ for i := range active {
183+ active[i] = true
184+ }
185+ remaining := total
186+
187+ for {
188+ normalized, sum := normalizedActiveWeights(weights, active)
189+ pinned := false
190+ for i := range active {
191+ if !active[i] {
192+ continue
193+ }
194+ quota := float64(remaining) * normalized[i] / sum
195+ if quota < float64(minimums[i]) {
196+ allocations[i] = minimums[i]
197+ remaining -= minimums[i]
198+ active[i] = false
199+ pinned = true
200+ }
201+ }
202+ if !pinned {
203+ break
75 }204 }
76- y += h + 1
77 }205 }
78- return true206+
207+ normalized, sum := normalizedActiveWeights(weights, active)
208+ type remainder struct {
209+ index int
210+ fraction float64
211+ }
212+ remainders := make([]remainder, 0, len(weights))
213+ allocated := total - remaining
214+ for i := range active {
215+ if !active[i] {
216+ continue
217+ }
218+ quota := float64(remaining) * normalized[i] / sum
219+ whole := int(math.Floor(quota))
220+ allocations[i] = whole
221+ allocated += whole
222+ remainders = append(remainders, remainder{index: i, fraction: quota - float64(whole)})
223+ }
224+ sort.SliceStable(remainders, func(i, j int) bool {
225+ return remainders[i].fraction > remainders[j].fraction
226+ })
227+ for cells, i := total-allocated, 0; cells > 0; cells, i = cells-1, i+1 {
228+ allocations[remainders[i%len(remainders)].index]++
229+ }
230+ return allocations, true
231+}
232+
233+func normalizedActiveWeights(weights []float64, active []bool) ([]float64, float64) {
234+ largest := 0.0
235+ for i, weight := range weights {
236+ if active[i] {
237+ largest = math.Max(largest, effectiveWeight(weight))
238+ }
239+ }
240+ normalized := make([]float64, len(weights))
241+ sum := 0.0
242+ for i, weight := range weights {
243+ if !active[i] {
244+ continue
245+ }
246+ normalized[i] = effectiveWeight(weight) / largest
247+ sum += normalized[i]
248+ }
249+ return normalized, sum
79 }250 }
80 251
81 func solveStack(n *Node, r domain.Rect, out *[]Placement) bool {252 func solveStack(n *Node, r domain.Rect, out *[]Placement) bool {
M internal/usecase/layout/ops.go +70 -19
@@ -18,7 +18,7 @@ var (
1818 // is placed after target on the chosen axis.
1919 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {
2020 candidate := t.clone()
21- if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID) {
21+ if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID, area) {
2222 return ErrNotFound
2323 }
2424 candidate.Focus = newID
@@ -29,14 +29,16 @@ func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, are
2929 return nil
3030 }
3131
32-func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool {
32+func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID, area domain.Rect) bool {
3333 if n == nil {
3434 return false
3535 }
36+ childAreas := nodeChildAreas(n, area)
3637 for i, child := range n.Children {
3738 if child.Kind == Leaf && child.Leaf == target {
3839 newLeaf := NewLeaf(newID)
3940 if n.Kind == Split && n.Dir == axis {
41+ normalizeChildWeightsFromArea(n, area)
4042 at := i
4143 if after {
4244 at++
@@ -47,36 +49,82 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID
4749 return true
4850 }
4951 if n.Kind == Stack {
52+ weight := n.Weight
5053 stack := n.clone()
54+ stack.Weight = 0
55+ clearChildWeights(stack)
5156 children := []*Node{stack, newLeaf}
5257 if !after {
5358 children = []*Node{newLeaf, stack}
5459 }
55- *n = Node{Kind: Split, Dir: axis, Children: children}
60+ *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight}
5661 return true
5762 }
63+ weight := child.Weight
64+ child.Weight = 0
5865 children := []*Node{child, newLeaf}
5966 if !after {
6067 children = []*Node{newLeaf, child}
6168 }
62- n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children}
69+ n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children, Weight: weight}
6370 return true
6471 }
65- if insertSplit(child, target, axis, after, newID) {
72+ childArea := area
73+ if i < len(childAreas) {
74+ childArea = childAreas[i]
75+ }
76+ if insertSplit(child, target, axis, after, newID, childArea) {
6677 return true
6778 }
6879 }
6980 if n.Kind == Leaf && n.Leaf == target {
70- children := []*Node{n.clone(), NewLeaf(newID)}
81+ weight := n.Weight
82+ existing := n.clone()
83+ existing.Weight = 0
84+ children := []*Node{existing, NewLeaf(newID)}
7185 if !after {
72- children = []*Node{NewLeaf(newID), n.clone()}
86+ children = []*Node{NewLeaf(newID), existing}
7387 }
74- *n = Node{Kind: Split, Dir: axis, Children: children}
88+ *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight}
7589 return true
7690 }
7791 return false
7892 }
7993
94+func nodeChildAreas(n *Node, area domain.Rect) []domain.Rect {
95+ if n.Kind == Split {
96+ rects, ok := splitChildRects(n, area)
97+ if ok {
98+ return rects
99+ }
100+ }
101+ areas := make([]domain.Rect, len(n.Children))
102+ for i := range areas {
103+ areas[i] = area
104+ }
105+ return areas
106+}
107+
108+func normalizeChildWeightsFromArea(n *Node, area domain.Rect) {
109+ rects, ok := splitChildRects(n, area)
110+ if !ok || len(rects) == 0 {
111+ return
112+ }
113+ for i, rect := range rects {
114+ extent := rect.Width
115+ if n.Dir == Vertical {
116+ extent = rect.Height
117+ }
118+ n.Children[i].Weight = float64(extent)
119+ }
120+}
121+
122+func clearChildWeights(n *Node) {
123+ for _, child := range n.Children {
124+ child.Weight = 0
125+ }
126+}
127+
80128 // StackNew puts newID in target's stack, creating one if target is not stacked.
81129 func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error {
82130 candidate := t.clone()
@@ -100,13 +148,16 @@ func stackNew(n *Node, target, newID PaneID) bool {
100148 if child.Kind == Leaf && child.Leaf == target {
101149 n.Children = append(n.Children, NewLeaf(newID))
102150 n.Expanded = newID
151+ clearChildWeights(n)
103152 return true
104153 }
105154 }
106155 }
107156 for i, child := range n.Children {
108157 if child.Kind == Leaf && child.Leaf == target {
109- n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID}
158+ weight := child.Weight
159+ child.Weight = 0
160+ n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID, Weight: weight}
110161 return true
111162 }
112163 if stackNew(child, target, newID) {
@@ -114,7 +165,8 @@ func stackNew(n *Node, target, newID PaneID) bool {
114165 }
115166 }
116167 if n.Kind == Leaf && n.Leaf == target {
117- *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID}
168+ weight := n.Weight
169+ *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID, Weight: weight}
118170 return true
119171 }
120172 return false
@@ -169,11 +221,13 @@ func toggleStack(n *Node, target PaneID) toggleResult {
169221 n.Kind = Split
170222 n.Dir = Vertical
171223 n.Expanded = ""
224+ clearChildWeights(n)
172225 return toggleToggled
173226 }
174227 if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) {
175228 n.Kind = Stack
176229 n.Expanded = target
230+ clearChildWeights(n)
177231 return toggleToggled
178232 }
179233 return toggleNotToggleable
@@ -252,16 +306,13 @@ func closeNode(n *Node, target PaneID) (*Node, bool) {
252306 if !removed {
253307 return n, false
254308 }
255- if n.Kind == Stack {
256- if n.Expanded == target && len(n.Children) > 0 {
257- n.Expanded = firstLeaf(n.Children[0])
258- }
259- if len(n.Children) == 1 {
260- return n.Children[0], true
261- }
309+ if n.Kind == Stack && n.Expanded == target && len(n.Children) > 0 {
310+ n.Expanded = firstLeaf(n.Children[0])
262311 }
263- if n.Kind == Split && len(n.Children) == 1 {
264- return n.Children[0], true
312+ if (n.Kind == Stack || n.Kind == Split) && len(n.Children) == 1 {
313+ promoted := n.Children[0]
314+ promoted.Weight = n.Weight
315+ return promoted, true
265316 }
266317 return n, true
267318 }
@@ -18,7 +18,7 @@ var (
18 // is placed after target on the chosen axis.18 // is placed after target on the chosen axis.
19 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {19 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {
20 candidate := t.clone()20 candidate := t.clone()
21- if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID) {21+ if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID, area) {
22 return ErrNotFound22 return ErrNotFound
23 }23 }
24 candidate.Focus = newID24 candidate.Focus = newID
@@ -29,14 +29,16 @@ func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, are
29 return nil29 return nil
30 }30 }
31 31
32-func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool {32+func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID, area domain.Rect) bool {
33 if n == nil {33 if n == nil {
34 return false34 return false
35 }35 }
36+ childAreas := nodeChildAreas(n, area)
36 for i, child := range n.Children {37 for i, child := range n.Children {
37 if child.Kind == Leaf && child.Leaf == target {38 if child.Kind == Leaf && child.Leaf == target {
38 newLeaf := NewLeaf(newID)39 newLeaf := NewLeaf(newID)
39 if n.Kind == Split && n.Dir == axis {40 if n.Kind == Split && n.Dir == axis {
41+ normalizeChildWeightsFromArea(n, area)
40 at := i42 at := i
41 if after {43 if after {
42 at++44 at++
@@ -47,36 +49,82 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID
47 return true49 return true
48 }50 }
49 if n.Kind == Stack {51 if n.Kind == Stack {
52+ weight := n.Weight
50 stack := n.clone()53 stack := n.clone()
54+ stack.Weight = 0
55+ clearChildWeights(stack)
51 children := []*Node{stack, newLeaf}56 children := []*Node{stack, newLeaf}
52 if !after {57 if !after {
53 children = []*Node{newLeaf, stack}58 children = []*Node{newLeaf, stack}
54 }59 }
55- *n = Node{Kind: Split, Dir: axis, Children: children}60+ *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight}
56 return true61 return true
57 }62 }
63+ weight := child.Weight
64+ child.Weight = 0
58 children := []*Node{child, newLeaf}65 children := []*Node{child, newLeaf}
59 if !after {66 if !after {
60 children = []*Node{newLeaf, child}67 children = []*Node{newLeaf, child}
61 }68 }
62- n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children}69+ n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children, Weight: weight}
63 return true70 return true
64 }71 }
65- if insertSplit(child, target, axis, after, newID) {72+ childArea := area
73+ if i < len(childAreas) {
74+ childArea = childAreas[i]
75+ }
76+ if insertSplit(child, target, axis, after, newID, childArea) {
66 return true77 return true
67 }78 }
68 }79 }
69 if n.Kind == Leaf && n.Leaf == target {80 if n.Kind == Leaf && n.Leaf == target {
70- children := []*Node{n.clone(), NewLeaf(newID)}81+ weight := n.Weight
82+ existing := n.clone()
83+ existing.Weight = 0
84+ children := []*Node{existing, NewLeaf(newID)}
71 if !after {85 if !after {
72- children = []*Node{NewLeaf(newID), n.clone()}86+ children = []*Node{NewLeaf(newID), existing}
73 }87 }
74- *n = Node{Kind: Split, Dir: axis, Children: children}88+ *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight}
75 return true89 return true
76 }90 }
77 return false91 return false
78 }92 }
79 93
94+func nodeChildAreas(n *Node, area domain.Rect) []domain.Rect {
95+ if n.Kind == Split {
96+ rects, ok := splitChildRects(n, area)
97+ if ok {
98+ return rects
99+ }
100+ }
101+ areas := make([]domain.Rect, len(n.Children))
102+ for i := range areas {
103+ areas[i] = area
104+ }
105+ return areas
106+}
107+
108+func normalizeChildWeightsFromArea(n *Node, area domain.Rect) {
109+ rects, ok := splitChildRects(n, area)
110+ if !ok || len(rects) == 0 {
111+ return
112+ }
113+ for i, rect := range rects {
114+ extent := rect.Width
115+ if n.Dir == Vertical {
116+ extent = rect.Height
117+ }
118+ n.Children[i].Weight = float64(extent)
119+ }
120+}
121+
122+func clearChildWeights(n *Node) {
123+ for _, child := range n.Children {
124+ child.Weight = 0
125+ }
126+}
127+
80 // StackNew puts newID in target's stack, creating one if target is not stacked.128 // StackNew puts newID in target's stack, creating one if target is not stacked.
81 func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error {129 func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error {
82 candidate := t.clone()130 candidate := t.clone()
@@ -100,13 +148,16 @@ func stackNew(n *Node, target, newID PaneID) bool {
100 if child.Kind == Leaf && child.Leaf == target {148 if child.Kind == Leaf && child.Leaf == target {
101 n.Children = append(n.Children, NewLeaf(newID))149 n.Children = append(n.Children, NewLeaf(newID))
102 n.Expanded = newID150 n.Expanded = newID
151+ clearChildWeights(n)
103 return true152 return true
104 }153 }
105 }154 }
106 }155 }
107 for i, child := range n.Children {156 for i, child := range n.Children {
108 if child.Kind == Leaf && child.Leaf == target {157 if child.Kind == Leaf && child.Leaf == target {
109- n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID}158+ weight := child.Weight
159+ child.Weight = 0
160+ n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID, Weight: weight}
110 return true161 return true
111 }162 }
112 if stackNew(child, target, newID) {163 if stackNew(child, target, newID) {
@@ -114,7 +165,8 @@ func stackNew(n *Node, target, newID PaneID) bool {
114 }165 }
115 }166 }
116 if n.Kind == Leaf && n.Leaf == target {167 if n.Kind == Leaf && n.Leaf == target {
117- *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID}168+ weight := n.Weight
169+ *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID, Weight: weight}
118 return true170 return true
119 }171 }
120 return false172 return false
@@ -169,11 +221,13 @@ func toggleStack(n *Node, target PaneID) toggleResult {
169 n.Kind = Split221 n.Kind = Split
170 n.Dir = Vertical222 n.Dir = Vertical
171 n.Expanded = ""223 n.Expanded = ""
224+ clearChildWeights(n)
172 return toggleToggled225 return toggleToggled
173 }226 }
174 if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) {227 if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) {
175 n.Kind = Stack228 n.Kind = Stack
176 n.Expanded = target229 n.Expanded = target
230+ clearChildWeights(n)
177 return toggleToggled231 return toggleToggled
178 }232 }
179 return toggleNotToggleable233 return toggleNotToggleable
@@ -252,16 +306,13 @@ func closeNode(n *Node, target PaneID) (*Node, bool) {
252 if !removed {306 if !removed {
253 return n, false307 return n, false
254 }308 }
255- if n.Kind == Stack {309+ if n.Kind == Stack && n.Expanded == target && len(n.Children) > 0 {
256- if n.Expanded == target && len(n.Children) > 0 {310+ n.Expanded = firstLeaf(n.Children[0])
257- n.Expanded = firstLeaf(n.Children[0])
258- }
259- if len(n.Children) == 1 {
260- return n.Children[0], true
261- }
262 }311 }
263- if n.Kind == Split && len(n.Children) == 1 {312+ if (n.Kind == Stack || n.Kind == Split) && len(n.Children) == 1 {
264- return n.Children[0], true313+ promoted := n.Children[0]
314+ promoted.Weight = n.Weight
315+ return promoted, true
265 }316 }
266 return n, true317 return n, true
267 }318 }
M internal/usecase/layout/tree.go +1 -0
@@ -46,6 +46,7 @@ type Node struct {
4646 Children []*Node
4747 Leaf PaneID
4848 Expanded PaneID
49+ Weight float64
4950 }
5051
5152 // Tree is a pane layout plus its focused pane.
@@ -46,6 +46,7 @@ type Node struct {
46 Children []*Node46 Children []*Node
47 Leaf PaneID47 Leaf PaneID
48 Expanded PaneID48 Expanded PaneID
49+ Weight float64
49 }50 }
50 51
51 // Tree is a pane layout plus its focused pane.52 // Tree is a pane layout plus its focused pane.
A internal/usecase/layout/weights_test.go +340 -0
@@ -0,0 +1,340 @@
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 TestWeightedSplitAllocation(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ root *Node
16+ area domain.Rect
17+ want map[PaneID]int
18+ }{
19+ {
20+ name: "zero weights preserve equal split and remainder order",
21+ root: split(Horizontal, NewLeaf("a"), NewLeaf("b"), NewLeaf("c")),
22+ area: domain.Rect{Width: 64, Height: 2},
23+ want: map[PaneID]int{"a": 21, "b": 21, "c": 20},
24+ },
25+ {
26+ name: "two to one weights",
27+ root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)),
28+ area: domain.Rect{Width: 61, Height: 2},
29+ want: map[PaneID]int{"a": 40, "b": 20},
30+ },
31+ {
32+ name: "largest remainder ties use child order",
33+ root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 2)),
34+ area: domain.Rect{Width: 84, Height: 2},
35+ want: map[PaneID]int{"a": 21, "b": 20, "c": 41},
36+ },
37+ {
38+ name: "minimum constrained children pin and remainder redistributes proportionally",
39+ root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 8)),
40+ area: domain.Rect{Width: 102, Height: 2},
41+ want: map[PaneID]int{"a": 20, "b": 20, "c": 60},
42+ },
43+ {
44+ name: "negative and zero in memory weights use default",
45+ root: split(Horizontal, weightedLeaf("a", -4), weightedLeaf("b", 0), weightedLeaf("c", 2)),
46+ area: domain.Rect{Width: 82, Height: 2},
47+ want: map[PaneID]int{"a": 20, "b": 20, "c": 40},
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ placements, ok := Solve(tt.root, tt.area)
54+ require.True(t, ok)
55+ for id, want := range tt.want {
56+ require.Equal(t, want, contents(placements)[id].Width)
57+ }
58+ })
59+ }
60+}
61+
62+func TestWeightedSplitRecursiveMinima(t *testing.T) {
63+ t.Parallel()
64+
65+ tests := []struct {
66+ name string
67+ root *Node
68+ area domain.Rect
69+ wantOK bool
70+ want map[PaneID]domain.Rect
71+ }{
72+ {
73+ name: "same axis nested minima sum children and separators",
74+ root: split(Horizontal,
75+ weightedNode(split(Horizontal, NewLeaf("a"), NewLeaf("b")), 1),
76+ weightedLeaf("c", 9),
77+ ),
78+ area: domain.Rect{Width: 102, Height: 2},
79+ wantOK: true,
80+ want: map[PaneID]domain.Rect{
81+ "a": {Width: 20, Height: 2},
82+ "b": {X: 21, Width: 20, Height: 2},
83+ "c": {X: 42, Width: 60, Height: 2},
84+ },
85+ },
86+ {
87+ name: "cross axis nested minima take maximum",
88+ root: split(Horizontal,
89+ split(Vertical, NewLeaf("a"), NewLeaf("b")),
90+ NewLeaf("c"),
91+ ),
92+ area: domain.Rect{Width: 41, Height: 5},
93+ wantOK: true,
94+ },
95+ {
96+ name: "stack vertical minimum is titles plus one content row",
97+ root: split(Vertical,
98+ weightedNode(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}, 1),
99+ weightedLeaf("d", 9),
100+ ),
101+ area: domain.Rect{Width: 20, Height: 7},
102+ wantOK: true,
103+ },
104+ {
105+ name: "insufficient same axis recursive minimum fails without placements",
106+ root: split(Horizontal,
107+ split(Horizontal, NewLeaf("a"), NewLeaf("b")),
108+ NewLeaf("c"),
109+ ),
110+ area: domain.Rect{Width: 61, Height: 2},
111+ wantOK: false,
112+ },
113+ {
114+ name: "insufficient cross axis recursive minimum fails without placements",
115+ root: split(Horizontal,
116+ split(Vertical, NewLeaf("a"), NewLeaf("b")),
117+ NewLeaf("c"),
118+ ),
119+ area: domain.Rect{Width: 41, Height: 4},
120+ wantOK: false,
121+ },
122+ }
123+
124+ for _, tt := range tests {
125+ t.Run(tt.name, func(t *testing.T) {
126+ placements, ok := Solve(tt.root, tt.area)
127+ require.Equal(t, tt.wantOK, ok)
128+ if !tt.wantOK {
129+ require.Nil(t, placements)
130+ return
131+ }
132+ if tt.want != nil {
133+ require.Equal(t, tt.want, contents(placements))
134+ }
135+ })
136+ }
137+}
138+
139+func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T) {
140+ t.Parallel()
141+
142+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
143+ area := domain.Rect{Width: 82, Height: 2}
144+ require.NoError(t, tr.Split("b", Right, true, "c", area))
145+
146+ require.Equal(t, 54.0, tr.Root.Children[0].Weight)
147+ require.Equal(t, 27.0, tr.Root.Children[1].Weight)
148+ require.Zero(t, tr.Root.Children[2].Weight)
149+ placements, ok := Solve(tr.Root, area)
150+ require.True(t, ok)
151+ require.Equal(t, map[PaneID]int{"a": 40, "b": 20, "c": 20}, placementWidths(placements))
152+}
153+
154+func TestWeightCrossAxisSplitTransfersParentShareAndClearsMembers(t *testing.T) {
155+ t.Parallel()
156+
157+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
158+ require.NoError(t, tr.Split("a", Down, true, "c", domain.Rect{Width: 81, Height: 5}))
159+
160+ wrapper := tr.Root.Children[0]
161+ require.Equal(t, Vertical, wrapper.Dir)
162+ require.Equal(t, 3.0, wrapper.Weight)
163+ require.Zero(t, wrapper.Children[0].Weight)
164+ require.Zero(t, wrapper.Children[1].Weight)
165+ placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 5})
166+ require.True(t, ok)
167+ require.Equal(t, 60, contents(placements)["a"].Width)
168+ require.Equal(t, 20, contents(placements)["b"].Width)
169+}
170+
171+func TestWeightStackNewTransfersParentShareAndClearsMembers(t *testing.T) {
172+ t.Parallel()
173+
174+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
175+ require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 81, Height: 3}))
176+
177+ stackNode := tr.Root.Children[0]
178+ require.Equal(t, Stack, stackNode.Kind)
179+ require.Equal(t, 3.0, stackNode.Weight)
180+ require.Zero(t, stackNode.Children[0].Weight)
181+ require.Zero(t, stackNode.Children[1].Weight)
182+ placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 3})
183+ require.True(t, ok)
184+ require.Equal(t, 60, contents(placements)["c"].Width)
185+ require.Equal(t, 20, contents(placements)["b"].Width)
186+}
187+
188+func TestWeightStackNewClearsExistingStackMemberWeights(t *testing.T) {
189+ t.Parallel()
190+
191+ tr := &Tree{Root: &Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 3), weightedLeaf("b", 1)}, Expanded: "a", Weight: 4}, Focus: "a"}
192+ require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 20, Height: 4}))
193+
194+ require.Equal(t, 4.0, tr.Root.Weight)
195+ for _, child := range tr.Root.Children {
196+ require.Zero(t, child.Weight)
197+ }
198+}
199+
200+func TestWeightToggleStackClearsMemberWeights(t *testing.T) {
201+ t.Parallel()
202+
203+ tests := []struct {
204+ name string
205+ root *Node
206+ wantKind Kind
207+ }{
208+ {
209+ name: "split to stack",
210+ root: &Node{Kind: Split, Dir: Vertical, Weight: 3, Children: []*Node{
211+ weightedLeaf("a", 2), weightedLeaf("b", 1),
212+ }},
213+ wantKind: Stack,
214+ },
215+ {
216+ name: "stack to split",
217+ root: &Node{Kind: Stack, Weight: 3, Children: []*Node{
218+ weightedLeaf("a", 2), weightedLeaf("b", 1),
219+ }, Expanded: "a"},
220+ wantKind: Split,
221+ },
222+ }
223+
224+ for _, tt := range tests {
225+ t.Run(tt.name, func(t *testing.T) {
226+ tr := &Tree{Root: split(Horizontal, tt.root, weightedLeaf("c", 1)), Focus: "a"}
227+ area := domain.Rect{Width: 81, Height: 5}
228+ require.NoError(t, tr.ToggleStack("a", area))
229+ container := tr.Root.Children[0]
230+ require.Equal(t, tt.wantKind, container.Kind)
231+ require.Equal(t, 3.0, container.Weight)
232+ for _, child := range container.Children {
233+ require.Zero(t, child.Weight)
234+ }
235+ placements, ok := Solve(tr.Root, area)
236+ require.True(t, ok)
237+ require.Equal(t, 60, contents(placements)["a"].Width)
238+ require.Equal(t, 20, contents(placements)["c"].Width)
239+ if tt.wantKind == Split {
240+ require.Equal(t, map[PaneID]int{"a": 2, "b": 2, "c": 5}, placementHeights(placements))
241+ }
242+ })
243+ }
244+}
245+
246+func TestWeightClosePromotionTransfersContainerShare(t *testing.T) {
247+ t.Parallel()
248+
249+ tests := []struct {
250+ name string
251+ container *Node
252+ }{
253+ {
254+ name: "split",
255+ container: split(Vertical, NewLeaf("a"), NewLeaf("b")),
256+ },
257+ {
258+ name: "stack",
259+ container: &Node{Kind: Stack, Children: []*Node{
260+ NewLeaf("a"), NewLeaf("b"),
261+ }, Expanded: "a"},
262+ },
263+ }
264+
265+ for _, tt := range tests {
266+ t.Run(tt.name, func(t *testing.T) {
267+ tr := &Tree{Root: split(Horizontal,
268+ weightedNode(tt.container, 3),
269+ weightedLeaf("c", 1),
270+ ), Focus: "a"}
271+ area := domain.Rect{Width: 81, Height: 5}
272+ before, ok := Solve(tr.Root, area)
273+ require.True(t, ok)
274+ require.Equal(t, 60, contents(before)["a"].Width)
275+
276+ require.NoError(t, tr.Close("a"))
277+ require.Equal(t, PaneID("b"), tr.Root.Children[0].Leaf)
278+ require.Equal(t, 3.0, tr.Root.Children[0].Weight)
279+ after, ok := Solve(tr.Root, area)
280+ require.True(t, ok)
281+ require.Equal(t, 60, contents(after)["b"].Width)
282+ require.Equal(t, 20, contents(after)["c"].Width)
283+ })
284+ }
285+}
286+
287+func TestWeightSplitStackWrapperTransfersShare(t *testing.T) {
288+ t.Parallel()
289+
290+ tr := &Tree{Root: &Node{Kind: Stack, Weight: 4, Children: []*Node{
291+ weightedLeaf("a", 2), weightedLeaf("b", 1),
292+ }, Expanded: "a"}, Focus: "a"}
293+ require.NoError(t, tr.Split("a", Right, true, "c", domain.Rect{Width: 41, Height: 3}))
294+
295+ require.Equal(t, 4.0, tr.Root.Weight)
296+ require.Zero(t, tr.Root.Children[0].Weight)
297+ require.Equal(t, Stack, tr.Root.Children[0].Kind)
298+ for _, member := range tr.Root.Children[0].Children {
299+ require.Zero(t, member.Weight)
300+ }
301+}
302+
303+func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
304+ t.Parallel()
305+
306+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
307+ clone := tr.Clone()
308+ require.Equal(t, tr, clone)
309+ clone.Root.Children[0].Weight = 9
310+ require.Equal(t, 2.0, tr.Root.Children[0].Weight)
311+}
312+
313+func split(dir SplitDir, children ...*Node) *Node {
314+ return &Node{Kind: Split, Dir: dir, Children: children}
315+}
316+
317+func weightedLeaf(id PaneID, weight float64) *Node {
318+ return &Node{Kind: Leaf, Leaf: id, Weight: weight}
319+}
320+
321+func weightedNode(n *Node, weight float64) *Node {
322+ n.Weight = weight
323+ return n
324+}
325+
326+func placementWidths(placements []Placement) map[PaneID]int {
327+ out := make(map[PaneID]int, len(placements))
328+ for _, placement := range placements {
329+ out[placement.ID] = placement.Content.Width
330+ }
331+ return out
332+}
333+
334+func placementHeights(placements []Placement) map[PaneID]int {
335+ out := make(map[PaneID]int, len(placements))
336+ for _, placement := range placements {
337+ out[placement.ID] = placement.Content.Height
338+ }
339+ return out
340+}
@@ -0,0 +1,340 @@
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 TestWeightedSplitAllocation(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ root *Node
16+ area domain.Rect
17+ want map[PaneID]int
18+ }{
19+ {
20+ name: "zero weights preserve equal split and remainder order",
21+ root: split(Horizontal, NewLeaf("a"), NewLeaf("b"), NewLeaf("c")),
22+ area: domain.Rect{Width: 64, Height: 2},
23+ want: map[PaneID]int{"a": 21, "b": 21, "c": 20},
24+ },
25+ {
26+ name: "two to one weights",
27+ root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)),
28+ area: domain.Rect{Width: 61, Height: 2},
29+ want: map[PaneID]int{"a": 40, "b": 20},
30+ },
31+ {
32+ name: "largest remainder ties use child order",
33+ root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 2)),
34+ area: domain.Rect{Width: 84, Height: 2},
35+ want: map[PaneID]int{"a": 21, "b": 20, "c": 41},
36+ },
37+ {
38+ name: "minimum constrained children pin and remainder redistributes proportionally",
39+ root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 8)),
40+ area: domain.Rect{Width: 102, Height: 2},
41+ want: map[PaneID]int{"a": 20, "b": 20, "c": 60},
42+ },
43+ {
44+ name: "negative and zero in memory weights use default",
45+ root: split(Horizontal, weightedLeaf("a", -4), weightedLeaf("b", 0), weightedLeaf("c", 2)),
46+ area: domain.Rect{Width: 82, Height: 2},
47+ want: map[PaneID]int{"a": 20, "b": 20, "c": 40},
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ placements, ok := Solve(tt.root, tt.area)
54+ require.True(t, ok)
55+ for id, want := range tt.want {
56+ require.Equal(t, want, contents(placements)[id].Width)
57+ }
58+ })
59+ }
60+}
61+
62+func TestWeightedSplitRecursiveMinima(t *testing.T) {
63+ t.Parallel()
64+
65+ tests := []struct {
66+ name string
67+ root *Node
68+ area domain.Rect
69+ wantOK bool
70+ want map[PaneID]domain.Rect
71+ }{
72+ {
73+ name: "same axis nested minima sum children and separators",
74+ root: split(Horizontal,
75+ weightedNode(split(Horizontal, NewLeaf("a"), NewLeaf("b")), 1),
76+ weightedLeaf("c", 9),
77+ ),
78+ area: domain.Rect{Width: 102, Height: 2},
79+ wantOK: true,
80+ want: map[PaneID]domain.Rect{
81+ "a": {Width: 20, Height: 2},
82+ "b": {X: 21, Width: 20, Height: 2},
83+ "c": {X: 42, Width: 60, Height: 2},
84+ },
85+ },
86+ {
87+ name: "cross axis nested minima take maximum",
88+ root: split(Horizontal,
89+ split(Vertical, NewLeaf("a"), NewLeaf("b")),
90+ NewLeaf("c"),
91+ ),
92+ area: domain.Rect{Width: 41, Height: 5},
93+ wantOK: true,
94+ },
95+ {
96+ name: "stack vertical minimum is titles plus one content row",
97+ root: split(Vertical,
98+ weightedNode(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}, 1),
99+ weightedLeaf("d", 9),
100+ ),
101+ area: domain.Rect{Width: 20, Height: 7},
102+ wantOK: true,
103+ },
104+ {
105+ name: "insufficient same axis recursive minimum fails without placements",
106+ root: split(Horizontal,
107+ split(Horizontal, NewLeaf("a"), NewLeaf("b")),
108+ NewLeaf("c"),
109+ ),
110+ area: domain.Rect{Width: 61, Height: 2},
111+ wantOK: false,
112+ },
113+ {
114+ name: "insufficient cross axis recursive minimum fails without placements",
115+ root: split(Horizontal,
116+ split(Vertical, NewLeaf("a"), NewLeaf("b")),
117+ NewLeaf("c"),
118+ ),
119+ area: domain.Rect{Width: 41, Height: 4},
120+ wantOK: false,
121+ },
122+ }
123+
124+ for _, tt := range tests {
125+ t.Run(tt.name, func(t *testing.T) {
126+ placements, ok := Solve(tt.root, tt.area)
127+ require.Equal(t, tt.wantOK, ok)
128+ if !tt.wantOK {
129+ require.Nil(t, placements)
130+ return
131+ }
132+ if tt.want != nil {
133+ require.Equal(t, tt.want, contents(placements))
134+ }
135+ })
136+ }
137+}
138+
139+func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T) {
140+ t.Parallel()
141+
142+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
143+ area := domain.Rect{Width: 82, Height: 2}
144+ require.NoError(t, tr.Split("b", Right, true, "c", area))
145+
146+ require.Equal(t, 54.0, tr.Root.Children[0].Weight)
147+ require.Equal(t, 27.0, tr.Root.Children[1].Weight)
148+ require.Zero(t, tr.Root.Children[2].Weight)
149+ placements, ok := Solve(tr.Root, area)
150+ require.True(t, ok)
151+ require.Equal(t, map[PaneID]int{"a": 40, "b": 20, "c": 20}, placementWidths(placements))
152+}
153+
154+func TestWeightCrossAxisSplitTransfersParentShareAndClearsMembers(t *testing.T) {
155+ t.Parallel()
156+
157+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
158+ require.NoError(t, tr.Split("a", Down, true, "c", domain.Rect{Width: 81, Height: 5}))
159+
160+ wrapper := tr.Root.Children[0]
161+ require.Equal(t, Vertical, wrapper.Dir)
162+ require.Equal(t, 3.0, wrapper.Weight)
163+ require.Zero(t, wrapper.Children[0].Weight)
164+ require.Zero(t, wrapper.Children[1].Weight)
165+ placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 5})
166+ require.True(t, ok)
167+ require.Equal(t, 60, contents(placements)["a"].Width)
168+ require.Equal(t, 20, contents(placements)["b"].Width)
169+}
170+
171+func TestWeightStackNewTransfersParentShareAndClearsMembers(t *testing.T) {
172+ t.Parallel()
173+
174+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
175+ require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 81, Height: 3}))
176+
177+ stackNode := tr.Root.Children[0]
178+ require.Equal(t, Stack, stackNode.Kind)
179+ require.Equal(t, 3.0, stackNode.Weight)
180+ require.Zero(t, stackNode.Children[0].Weight)
181+ require.Zero(t, stackNode.Children[1].Weight)
182+ placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 3})
183+ require.True(t, ok)
184+ require.Equal(t, 60, contents(placements)["c"].Width)
185+ require.Equal(t, 20, contents(placements)["b"].Width)
186+}
187+
188+func TestWeightStackNewClearsExistingStackMemberWeights(t *testing.T) {
189+ t.Parallel()
190+
191+ tr := &Tree{Root: &Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 3), weightedLeaf("b", 1)}, Expanded: "a", Weight: 4}, Focus: "a"}
192+ require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 20, Height: 4}))
193+
194+ require.Equal(t, 4.0, tr.Root.Weight)
195+ for _, child := range tr.Root.Children {
196+ require.Zero(t, child.Weight)
197+ }
198+}
199+
200+func TestWeightToggleStackClearsMemberWeights(t *testing.T) {
201+ t.Parallel()
202+
203+ tests := []struct {
204+ name string
205+ root *Node
206+ wantKind Kind
207+ }{
208+ {
209+ name: "split to stack",
210+ root: &Node{Kind: Split, Dir: Vertical, Weight: 3, Children: []*Node{
211+ weightedLeaf("a", 2), weightedLeaf("b", 1),
212+ }},
213+ wantKind: Stack,
214+ },
215+ {
216+ name: "stack to split",
217+ root: &Node{Kind: Stack, Weight: 3, Children: []*Node{
218+ weightedLeaf("a", 2), weightedLeaf("b", 1),
219+ }, Expanded: "a"},
220+ wantKind: Split,
221+ },
222+ }
223+
224+ for _, tt := range tests {
225+ t.Run(tt.name, func(t *testing.T) {
226+ tr := &Tree{Root: split(Horizontal, tt.root, weightedLeaf("c", 1)), Focus: "a"}
227+ area := domain.Rect{Width: 81, Height: 5}
228+ require.NoError(t, tr.ToggleStack("a", area))
229+ container := tr.Root.Children[0]
230+ require.Equal(t, tt.wantKind, container.Kind)
231+ require.Equal(t, 3.0, container.Weight)
232+ for _, child := range container.Children {
233+ require.Zero(t, child.Weight)
234+ }
235+ placements, ok := Solve(tr.Root, area)
236+ require.True(t, ok)
237+ require.Equal(t, 60, contents(placements)["a"].Width)
238+ require.Equal(t, 20, contents(placements)["c"].Width)
239+ if tt.wantKind == Split {
240+ require.Equal(t, map[PaneID]int{"a": 2, "b": 2, "c": 5}, placementHeights(placements))
241+ }
242+ })
243+ }
244+}
245+
246+func TestWeightClosePromotionTransfersContainerShare(t *testing.T) {
247+ t.Parallel()
248+
249+ tests := []struct {
250+ name string
251+ container *Node
252+ }{
253+ {
254+ name: "split",
255+ container: split(Vertical, NewLeaf("a"), NewLeaf("b")),
256+ },
257+ {
258+ name: "stack",
259+ container: &Node{Kind: Stack, Children: []*Node{
260+ NewLeaf("a"), NewLeaf("b"),
261+ }, Expanded: "a"},
262+ },
263+ }
264+
265+ for _, tt := range tests {
266+ t.Run(tt.name, func(t *testing.T) {
267+ tr := &Tree{Root: split(Horizontal,
268+ weightedNode(tt.container, 3),
269+ weightedLeaf("c", 1),
270+ ), Focus: "a"}
271+ area := domain.Rect{Width: 81, Height: 5}
272+ before, ok := Solve(tr.Root, area)
273+ require.True(t, ok)
274+ require.Equal(t, 60, contents(before)["a"].Width)
275+
276+ require.NoError(t, tr.Close("a"))
277+ require.Equal(t, PaneID("b"), tr.Root.Children[0].Leaf)
278+ require.Equal(t, 3.0, tr.Root.Children[0].Weight)
279+ after, ok := Solve(tr.Root, area)
280+ require.True(t, ok)
281+ require.Equal(t, 60, contents(after)["b"].Width)
282+ require.Equal(t, 20, contents(after)["c"].Width)
283+ })
284+ }
285+}
286+
287+func TestWeightSplitStackWrapperTransfersShare(t *testing.T) {
288+ t.Parallel()
289+
290+ tr := &Tree{Root: &Node{Kind: Stack, Weight: 4, Children: []*Node{
291+ weightedLeaf("a", 2), weightedLeaf("b", 1),
292+ }, Expanded: "a"}, Focus: "a"}
293+ require.NoError(t, tr.Split("a", Right, true, "c", domain.Rect{Width: 41, Height: 3}))
294+
295+ require.Equal(t, 4.0, tr.Root.Weight)
296+ require.Zero(t, tr.Root.Children[0].Weight)
297+ require.Equal(t, Stack, tr.Root.Children[0].Kind)
298+ for _, member := range tr.Root.Children[0].Children {
299+ require.Zero(t, member.Weight)
300+ }
301+}
302+
303+func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
304+ t.Parallel()
305+
306+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
307+ clone := tr.Clone()
308+ require.Equal(t, tr, clone)
309+ clone.Root.Children[0].Weight = 9
310+ require.Equal(t, 2.0, tr.Root.Children[0].Weight)
311+}
312+
313+func split(dir SplitDir, children ...*Node) *Node {
314+ return &Node{Kind: Split, Dir: dir, Children: children}
315+}
316+
317+func weightedLeaf(id PaneID, weight float64) *Node {
318+ return &Node{Kind: Leaf, Leaf: id, Weight: weight}
319+}
320+
321+func weightedNode(n *Node, weight float64) *Node {
322+ n.Weight = weight
323+ return n
324+}
325+
326+func placementWidths(placements []Placement) map[PaneID]int {
327+ out := make(map[PaneID]int, len(placements))
328+ for _, placement := range placements {
329+ out[placement.ID] = placement.Content.Width
330+ }
331+ return out
332+}
333+
334+func placementHeights(placements []Placement) map[PaneID]int {
335+ out := make(map[PaneID]int, len(placements))
336+ for _, placement := range placements {
337+ out[placement.ID] = placement.Content.Height
338+ }
339+ return out
340+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close