detached 8bef307

feat(layout): resize and equalize focused panes

commit
8bef307c8823d8e75a56b02f1a3dc619a1174f3b
author
brice · · 2026-07-25 04:17 UTC
parent
cc7049a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +500 -0
M internal/usecase/layout/ops.go +180 -0
@@ -11,9 +11,189 @@ var (
1111 ErrNotFound = errors.New("pane not found")
1212 ErrTooSmall = errors.New("layout too small")
1313 ErrNoPane = errors.New("no pane in direction")
14+ ErrNotInSplit = errors.New("pane is not in a split")
1415 ErrNotToggleable = errors.New("layout node is not toggleable")
1516 )
1617
18+// ResizeFocus changes the focused pane subtree's share in the nearest split on
19+// axis. It commits only after the candidate layout solves successfully.
20+func (t *Tree) ResizeFocus(axis Axis, delta int, area domain.Rect) error {
21+ candidate := t.clone()
22+ if candidate == nil || candidate.Root == nil {
23+ return ErrNotInSplit
24+ }
25+ dir, ok := splitDirForAxis(axis)
26+ if !ok || !hasMatchingSplit(candidate.Root, candidate.Focus, dir) {
27+ return ErrNotInSplit
28+ }
29+ if _, solved := Solve(candidate.Root, area); !solved {
30+ return ErrTooSmall
31+ }
32+
33+ splitNode, splitArea, targetIndex, found := nearestMatchingSplit(candidate.Root, candidate.Focus, dir, area)
34+ if !found {
35+ return ErrTooSmall
36+ }
37+ rects, solved := splitChildRects(splitNode, splitArea)
38+ if !solved {
39+ return ErrTooSmall
40+ }
41+ extents := make([]int, len(rects))
42+ minimums := make([]int, len(rects))
43+ for i, rect := range rects {
44+ extents[i] = rect.Width
45+ if dir == Vertical {
46+ extents[i] = rect.Height
47+ }
48+ minimum, valid := minimumExtent(splitNode.Children[i], dir)
49+ if !valid {
50+ return ErrTooSmall
51+ }
52+ minimums[i] = minimum
53+ splitNode.Children[i].Weight = float64(extents[i])
54+ }
55+
56+ requested := delta
57+ if requested < 0 {
58+ requested = -requested
59+ }
60+ if requested == 0 {
61+ return ErrTooSmall
62+ }
63+
64+ neighbor := -1
65+ transfer := 0
66+ if delta > 0 {
67+ for _, index := range preferredNeighbors(targetIndex, len(extents)) {
68+ available := extents[index] - minimums[index]
69+ if available <= 0 {
70+ continue
71+ }
72+ neighbor = index
73+ transfer = min(requested, available)
74+ break
75+ }
76+ } else {
77+ neighbors := preferredNeighbors(targetIndex, len(extents))
78+ available := extents[targetIndex] - minimums[targetIndex]
79+ if len(neighbors) > 0 && available > 0 {
80+ neighbor = neighbors[0]
81+ transfer = min(requested, available)
82+ }
83+ }
84+ if neighbor < 0 || transfer == 0 {
85+ return ErrTooSmall
86+ }
87+
88+ if delta > 0 {
89+ extents[targetIndex] += transfer
90+ extents[neighbor] -= transfer
91+ } else {
92+ extents[targetIndex] -= transfer
93+ extents[neighbor] += transfer
94+ }
95+ splitNode.Children[targetIndex].Weight = float64(extents[targetIndex])
96+ splitNode.Children[neighbor].Weight = float64(extents[neighbor])
97+ if _, solved := Solve(candidate.Root, area); !solved {
98+ return ErrTooSmall
99+ }
100+ *t = *candidate
101+ return nil
102+}
103+
104+// Equalize resets every split's direct child shares to their defaults. The
105+// candidate must remain solvable before it is committed.
106+func (t *Tree) Equalize(area domain.Rect) error {
107+ candidate := t.clone()
108+ if candidate == nil || candidate.Root == nil {
109+ return ErrTooSmall
110+ }
111+ clearSplitChildWeights(candidate.Root)
112+ if _, ok := Solve(candidate.Root, area); !ok {
113+ return ErrTooSmall
114+ }
115+ *t = *candidate
116+ return nil
117+}
118+
119+// CanResize reports whether the focused pane is contained by any split.
120+func (t *Tree) CanResize() bool {
121+ if t == nil || t.Root == nil {
122+ return false
123+ }
124+ return hasMatchingSplit(t.Root, t.Focus, Horizontal) || hasMatchingSplit(t.Root, t.Focus, Vertical)
125+}
126+
127+func splitDirForAxis(axis Axis) (SplitDir, bool) {
128+ switch axis {
129+ case Width:
130+ return Horizontal, true
131+ case Height:
132+ return Vertical, true
133+ default:
134+ return Horizontal, false
135+ }
136+}
137+
138+func hasMatchingSplit(n *Node, focus PaneID, dir SplitDir) bool {
139+ if n == nil || !containsLeaf(n, focus) {
140+ return false
141+ }
142+ for _, child := range n.Children {
143+ if containsLeaf(child, focus) && hasMatchingSplit(child, focus, dir) {
144+ return true
145+ }
146+ }
147+ return n.Kind == Split && n.Dir == dir
148+}
149+
150+func nearestMatchingSplit(n *Node, focus PaneID, dir SplitDir, area domain.Rect) (*Node, domain.Rect, int, bool) {
151+ if n == nil || !containsLeaf(n, focus) {
152+ return nil, domain.Rect{}, 0, false
153+ }
154+ childAreas := nodeChildAreas(n, area)
155+ for i, child := range n.Children {
156+ if !containsLeaf(child, focus) {
157+ continue
158+ }
159+ childArea := area
160+ if i < len(childAreas) {
161+ childArea = childAreas[i]
162+ }
163+ if match, matchArea, index, ok := nearestMatchingSplit(child, focus, dir, childArea); ok {
164+ return match, matchArea, index, true
165+ }
166+ if n.Kind == Split && n.Dir == dir {
167+ return n, area, i, true
168+ }
169+ return nil, domain.Rect{}, 0, false
170+ }
171+ return nil, domain.Rect{}, 0, false
172+}
173+
174+func preferredNeighbors(target, count int) []int {
175+ neighbors := make([]int, 0, 2)
176+ if target+1 < count {
177+ neighbors = append(neighbors, target+1)
178+ }
179+ if target > 0 {
180+ neighbors = append(neighbors, target-1)
181+ }
182+ return neighbors
183+}
184+
185+func clearSplitChildWeights(n *Node) {
186+ if n == nil {
187+ return
188+ }
189+ if n.Kind == Split {
190+ clearChildWeights(n)
191+ }
192+ for _, child := range n.Children {
193+ clearSplitChildWeights(child)
194+ }
195+}
196+
17197 // Split adds newID next to target. The after flag controls whether the new pane
18198 // is placed after target on the chosen axis.
19199 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {
@@ -11,9 +11,189 @@ var (
11 ErrNotFound = errors.New("pane not found")11 ErrNotFound = errors.New("pane not found")
12 ErrTooSmall = errors.New("layout too small")12 ErrTooSmall = errors.New("layout too small")
13 ErrNoPane = errors.New("no pane in direction")13 ErrNoPane = errors.New("no pane in direction")
14+ ErrNotInSplit = errors.New("pane is not in a split")
14 ErrNotToggleable = errors.New("layout node is not toggleable")15 ErrNotToggleable = errors.New("layout node is not toggleable")
15 )16 )
16 17
18+// ResizeFocus changes the focused pane subtree's share in the nearest split on
19+// axis. It commits only after the candidate layout solves successfully.
20+func (t *Tree) ResizeFocus(axis Axis, delta int, area domain.Rect) error {
21+ candidate := t.clone()
22+ if candidate == nil || candidate.Root == nil {
23+ return ErrNotInSplit
24+ }
25+ dir, ok := splitDirForAxis(axis)
26+ if !ok || !hasMatchingSplit(candidate.Root, candidate.Focus, dir) {
27+ return ErrNotInSplit
28+ }
29+ if _, solved := Solve(candidate.Root, area); !solved {
30+ return ErrTooSmall
31+ }
32+
33+ splitNode, splitArea, targetIndex, found := nearestMatchingSplit(candidate.Root, candidate.Focus, dir, area)
34+ if !found {
35+ return ErrTooSmall
36+ }
37+ rects, solved := splitChildRects(splitNode, splitArea)
38+ if !solved {
39+ return ErrTooSmall
40+ }
41+ extents := make([]int, len(rects))
42+ minimums := make([]int, len(rects))
43+ for i, rect := range rects {
44+ extents[i] = rect.Width
45+ if dir == Vertical {
46+ extents[i] = rect.Height
47+ }
48+ minimum, valid := minimumExtent(splitNode.Children[i], dir)
49+ if !valid {
50+ return ErrTooSmall
51+ }
52+ minimums[i] = minimum
53+ splitNode.Children[i].Weight = float64(extents[i])
54+ }
55+
56+ requested := delta
57+ if requested < 0 {
58+ requested = -requested
59+ }
60+ if requested == 0 {
61+ return ErrTooSmall
62+ }
63+
64+ neighbor := -1
65+ transfer := 0
66+ if delta > 0 {
67+ for _, index := range preferredNeighbors(targetIndex, len(extents)) {
68+ available := extents[index] - minimums[index]
69+ if available <= 0 {
70+ continue
71+ }
72+ neighbor = index
73+ transfer = min(requested, available)
74+ break
75+ }
76+ } else {
77+ neighbors := preferredNeighbors(targetIndex, len(extents))
78+ available := extents[targetIndex] - minimums[targetIndex]
79+ if len(neighbors) > 0 && available > 0 {
80+ neighbor = neighbors[0]
81+ transfer = min(requested, available)
82+ }
83+ }
84+ if neighbor < 0 || transfer == 0 {
85+ return ErrTooSmall
86+ }
87+
88+ if delta > 0 {
89+ extents[targetIndex] += transfer
90+ extents[neighbor] -= transfer
91+ } else {
92+ extents[targetIndex] -= transfer
93+ extents[neighbor] += transfer
94+ }
95+ splitNode.Children[targetIndex].Weight = float64(extents[targetIndex])
96+ splitNode.Children[neighbor].Weight = float64(extents[neighbor])
97+ if _, solved := Solve(candidate.Root, area); !solved {
98+ return ErrTooSmall
99+ }
100+ *t = *candidate
101+ return nil
102+}
103+
104+// Equalize resets every split's direct child shares to their defaults. The
105+// candidate must remain solvable before it is committed.
106+func (t *Tree) Equalize(area domain.Rect) error {
107+ candidate := t.clone()
108+ if candidate == nil || candidate.Root == nil {
109+ return ErrTooSmall
110+ }
111+ clearSplitChildWeights(candidate.Root)
112+ if _, ok := Solve(candidate.Root, area); !ok {
113+ return ErrTooSmall
114+ }
115+ *t = *candidate
116+ return nil
117+}
118+
119+// CanResize reports whether the focused pane is contained by any split.
120+func (t *Tree) CanResize() bool {
121+ if t == nil || t.Root == nil {
122+ return false
123+ }
124+ return hasMatchingSplit(t.Root, t.Focus, Horizontal) || hasMatchingSplit(t.Root, t.Focus, Vertical)
125+}
126+
127+func splitDirForAxis(axis Axis) (SplitDir, bool) {
128+ switch axis {
129+ case Width:
130+ return Horizontal, true
131+ case Height:
132+ return Vertical, true
133+ default:
134+ return Horizontal, false
135+ }
136+}
137+
138+func hasMatchingSplit(n *Node, focus PaneID, dir SplitDir) bool {
139+ if n == nil || !containsLeaf(n, focus) {
140+ return false
141+ }
142+ for _, child := range n.Children {
143+ if containsLeaf(child, focus) && hasMatchingSplit(child, focus, dir) {
144+ return true
145+ }
146+ }
147+ return n.Kind == Split && n.Dir == dir
148+}
149+
150+func nearestMatchingSplit(n *Node, focus PaneID, dir SplitDir, area domain.Rect) (*Node, domain.Rect, int, bool) {
151+ if n == nil || !containsLeaf(n, focus) {
152+ return nil, domain.Rect{}, 0, false
153+ }
154+ childAreas := nodeChildAreas(n, area)
155+ for i, child := range n.Children {
156+ if !containsLeaf(child, focus) {
157+ continue
158+ }
159+ childArea := area
160+ if i < len(childAreas) {
161+ childArea = childAreas[i]
162+ }
163+ if match, matchArea, index, ok := nearestMatchingSplit(child, focus, dir, childArea); ok {
164+ return match, matchArea, index, true
165+ }
166+ if n.Kind == Split && n.Dir == dir {
167+ return n, area, i, true
168+ }
169+ return nil, domain.Rect{}, 0, false
170+ }
171+ return nil, domain.Rect{}, 0, false
172+}
173+
174+func preferredNeighbors(target, count int) []int {
175+ neighbors := make([]int, 0, 2)
176+ if target+1 < count {
177+ neighbors = append(neighbors, target+1)
178+ }
179+ if target > 0 {
180+ neighbors = append(neighbors, target-1)
181+ }
182+ return neighbors
183+}
184+
185+func clearSplitChildWeights(n *Node) {
186+ if n == nil {
187+ return
188+ }
189+ if n.Kind == Split {
190+ clearChildWeights(n)
191+ }
192+ for _, child := range n.Children {
193+ clearSplitChildWeights(child)
194+ }
195+}
196+
17 // Split adds newID next to target. The after flag controls whether the new pane197 // Split adds newID next to target. The after flag controls whether the new pane
18 // is placed after target on the chosen axis.198 // 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 {199 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {
A internal/usecase/layout/resize_test.go +312 -0
@@ -0,0 +1,312 @@
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 TestResizeFocusTransfersSolvedExtent(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ root *Node
16+ focus PaneID
17+ axis Axis
18+ delta int
19+ area domain.Rect
20+ want map[PaneID]domain.Rect
21+ }{
22+ {
23+ name: "grow width takes from right neighbor",
24+ root: split(Horizontal, NewLeaf("a"), NewLeaf("b")),
25+ focus: "a",
26+ axis: Width,
27+ delta: 5,
28+ area: domain.Rect{Width: 51, Height: 2},
29+ want: map[PaneID]domain.Rect{
30+ "a": {Width: 30, Height: 2},
31+ "b": {X: 31, Width: 20, Height: 2},
32+ },
33+ },
34+ {
35+ name: "shrink width gives to right neighbor",
36+ root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)),
37+ focus: "a",
38+ axis: Width,
39+ delta: -5,
40+ area: domain.Rect{Width: 51, Height: 2},
41+ want: map[PaneID]domain.Rect{
42+ "a": {Width: 25, Height: 2},
43+ "b": {X: 26, Width: 25, Height: 2},
44+ },
45+ },
46+ {
47+ name: "grow height takes from bottom neighbor",
48+ root: split(Vertical,
49+ weightedLeaf("a", 4), weightedLeaf("b", 3),
50+ ),
51+ focus: "a",
52+ axis: Height,
53+ delta: 1,
54+ area: domain.Rect{Width: 20, Height: 8},
55+ want: map[PaneID]domain.Rect{
56+ "a": {Width: 20, Height: 5},
57+ "b": {Y: 6, Width: 20, Height: 2},
58+ },
59+ },
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ tr := &Tree{Root: tt.root, Focus: tt.focus}
65+ require.NoError(t, tr.ResizeFocus(tt.axis, tt.delta, tt.area))
66+ placements, ok := Solve(tr.Root, tt.area)
67+ require.True(t, ok)
68+ require.Equal(t, tt.want, contents(placements))
69+ })
70+ }
71+}
72+
73+func TestResizeFocusSelectsNearestTargetAndAdjacentSibling(t *testing.T) {
74+ t.Parallel()
75+
76+ t.Run("nearest matching ancestor", func(t *testing.T) {
77+ inner := split(Horizontal, NewLeaf("a"), NewLeaf("b"))
78+ tr := &Tree{Root: split(Horizontal, weightedNode(inner, 2), weightedLeaf("c", 1)), Focus: "a"}
79+ area := domain.Rect{Width: 122, Height: 2}
80+ before, ok := Solve(tr.Root, area)
81+ require.True(t, ok)
82+
83+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
84+ after, ok := Solve(tr.Root, area)
85+ require.True(t, ok)
86+ require.Equal(t, contents(before)["c"], contents(after)["c"])
87+ require.Equal(t, contents(before)["a"].Width+5, contents(after)["a"].Width)
88+ require.Equal(t, contents(before)["b"].Width-5, contents(after)["b"].Width)
89+ })
90+
91+ t.Run("direct child subtree is resized as one target", func(t *testing.T) {
92+ subtree := split(Vertical, NewLeaf("a"), NewLeaf("d"))
93+ tr := &Tree{Root: split(Horizontal, subtree, NewLeaf("b")), Focus: "a"}
94+ area := domain.Rect{Width: 51, Height: 5}
95+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
96+ placements, ok := Solve(tr.Root, area)
97+ require.True(t, ok)
98+ require.Equal(t, 30, contents(placements)["a"].Width)
99+ require.Equal(t, 30, contents(placements)["d"].Width)
100+ require.Equal(t, 20, contents(placements)["b"].Width)
101+ })
102+
103+ t.Run("stack is resized as one target", func(t *testing.T) {
104+ stackNode := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("d")}, Expanded: "a"}
105+ tr := &Tree{Root: split(Horizontal, stackNode, NewLeaf("b")), Focus: "d"}
106+ area := domain.Rect{Width: 51, Height: 3}
107+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
108+ placements, ok := Solve(tr.Root, area)
109+ require.True(t, ok)
110+ require.Equal(t, 30, contents(placements)["a"].Width)
111+ var collapsedTitleWidth int
112+ for _, placement := range placements {
113+ if placement.ID == "d" {
114+ collapsedTitleWidth = placement.TitleBar.Width
115+ }
116+ }
117+ require.Equal(t, 30, collapsedTitleWidth)
118+ require.Equal(t, 20, contents(placements)["b"].Width)
119+ })
120+
121+ t.Run("right donor at minimum falls back to left", func(t *testing.T) {
122+ tr := &Tree{Root: split(Horizontal,
123+ weightedLeaf("a", 40), weightedLeaf("b", 20), weightedLeaf("c", 20),
124+ ), Focus: "b"}
125+ area := domain.Rect{Width: 82, Height: 2}
126+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
127+ placements, ok := Solve(tr.Root, area)
128+ require.True(t, ok)
129+ require.Equal(t, map[PaneID]int{"a": 35, "b": 25, "c": 20}, placementWidths(placements))
130+ })
131+
132+ t.Run("last target falls back to left neighbor", func(t *testing.T) {
133+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)), Focus: "b"}
134+ area := domain.Rect{Width: 51, Height: 2}
135+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
136+ placements, ok := Solve(tr.Root, area)
137+ require.True(t, ok)
138+ require.Equal(t, map[PaneID]int{"a": 25, "b": 25}, placementWidths(placements))
139+ })
140+}
141+
142+func TestResizeFocusMinimumAndTransactionalFailures(t *testing.T) {
143+ t.Parallel()
144+
145+ tests := []struct {
146+ name string
147+ tree *Tree
148+ axis Axis
149+ delta int
150+ area domain.Rect
151+ err error
152+ want map[PaneID]int
153+ }{
154+ {
155+ name: "partial growth uses every available donor cell",
156+ tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 20), weightedLeaf("b", 22)), Focus: "a"},
157+ axis: Width,
158+ delta: 5,
159+ area: domain.Rect{Width: 43, Height: 2},
160+ want: map[PaneID]int{"a": 22, "b": 20},
161+ },
162+ {
163+ name: "partial growth stops at recursive donor minimum",
164+ tree: &Tree{Root: split(Horizontal,
165+ weightedLeaf("a", 20),
166+ weightedNode(split(Horizontal, NewLeaf("b"), NewLeaf("d")), 43),
167+ ), Focus: "a"},
168+ axis: Width,
169+ delta: 5,
170+ area: domain.Rect{Width: 64, Height: 2},
171+ want: map[PaneID]int{"a": 22, "b": 20, "d": 20},
172+ },
173+ {
174+ name: "no donor capacity",
175+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"},
176+ axis: Width,
177+ delta: 2,
178+ area: domain.Rect{Width: 41, Height: 2},
179+ err: ErrTooSmall,
180+ },
181+ {
182+ name: "target cannot shrink",
183+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), weightedLeaf("b", 2)), Focus: "a"},
184+ axis: Width,
185+ delta: -2,
186+ area: domain.Rect{Width: 61, Height: 2},
187+ err: ErrTooSmall,
188+ },
189+ {
190+ name: "wrong axis",
191+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"},
192+ axis: Height,
193+ delta: 1,
194+ area: domain.Rect{Width: 41, Height: 2},
195+ err: ErrNotInSplit,
196+ },
197+ {
198+ name: "single pane",
199+ tree: NewTree("a"),
200+ axis: Width,
201+ delta: 2,
202+ area: domain.Rect{Width: 20, Height: 2},
203+ err: ErrNotInSplit,
204+ },
205+ {
206+ name: "invalid area rolls back",
207+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"},
208+ axis: Width,
209+ delta: 2,
210+ area: domain.Rect{Width: 20, Height: 2},
211+ err: ErrTooSmall,
212+ },
213+ }
214+
215+ for _, tt := range tests {
216+ t.Run(tt.name, func(t *testing.T) {
217+ before := tt.tree.Clone()
218+ err := tt.tree.ResizeFocus(tt.axis, tt.delta, tt.area)
219+ if tt.err != nil {
220+ require.ErrorIs(t, err, tt.err)
221+ require.Equal(t, before, tt.tree, "failed resize must not mutate the tree")
222+ return
223+ }
224+ require.NoError(t, err)
225+ placements, ok := Solve(tt.tree.Root, tt.area)
226+ require.True(t, ok)
227+ require.Equal(t, tt.want, placementWidths(placements))
228+ })
229+ }
230+}
231+
232+func TestResizeFocusRetainsProportionsAcrossTerminalSizes(t *testing.T) {
233+ t.Parallel()
234+
235+ tr := &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}
236+ small := domain.Rect{Width: 81, Height: 2}
237+ require.NoError(t, tr.ResizeFocus(Width, 10, small))
238+
239+ placements, ok := Solve(tr.Root, domain.Rect{Width: 161, Height: 2})
240+ require.True(t, ok)
241+ require.Equal(t, map[PaneID]int{"a": 100, "b": 60}, placementWidths(placements))
242+ placements, ok = Solve(tr.Root, small)
243+ require.True(t, ok)
244+ require.Equal(t, map[PaneID]int{"a": 50, "b": 30}, placementWidths(placements))
245+}
246+
247+func TestEqualize(t *testing.T) {
248+ t.Parallel()
249+
250+ tests := []struct {
251+ name string
252+ tree *Tree
253+ area domain.Rect
254+ want map[PaneID]int
255+ }{
256+ {
257+ name: "weighted siblings become equal",
258+ tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"},
259+ area: domain.Rect{Width: 81, Height: 2},
260+ want: map[PaneID]int{"a": 40, "b": 40},
261+ },
262+ {
263+ name: "recursive minimum constrains only the large child",
264+ tree: &Tree{Root: split(Horizontal,
265+ weightedNode(split(Horizontal, weightedLeaf("a", 5), weightedLeaf("b", 1)), 9),
266+ weightedLeaf("c", 1), weightedLeaf("d", 1),
267+ ), Focus: "a"},
268+ area: domain.Rect{Width: 102, Height: 2},
269+ want: map[PaneID]int{"a": 20, "b": 20, "c": 30, "d": 29},
270+ },
271+ }
272+
273+ for _, tt := range tests {
274+ t.Run(tt.name, func(t *testing.T) {
275+ require.NoError(t, tt.tree.Equalize(tt.area))
276+ placements, ok := Solve(tt.tree.Root, tt.area)
277+ require.True(t, ok)
278+ require.Equal(t, tt.want, placementWidths(placements))
279+ })
280+ }
281+}
282+
283+func TestEqualizeFailureIsTransactional(t *testing.T) {
284+ t.Parallel()
285+
286+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
287+ before := tr.Clone()
288+ require.ErrorIs(t, tr.Equalize(domain.Rect{Width: 20, Height: 2}), ErrTooSmall)
289+ require.Equal(t, before, tr)
290+}
291+
292+func TestCanResize(t *testing.T) {
293+ t.Parallel()
294+
295+ tests := []struct {
296+ name string
297+ tree *Tree
298+ want bool
299+ }{
300+ {name: "single pane", tree: NewTree("a"), want: false},
301+ {name: "focused split child", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, want: true},
302+ {name: "focused stack member under split", tree: &Tree{Root: split(Horizontal, &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, NewLeaf("c")), Focus: "b"}, want: true},
303+ {name: "stack without split", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, Focus: "a"}, want: false},
304+ {name: "missing focus", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "missing"}, want: false},
305+ }
306+
307+ for _, tt := range tests {
308+ t.Run(tt.name, func(t *testing.T) {
309+ require.Equal(t, tt.want, tt.tree.CanResize())
310+ })
311+ }
312+}
@@ -0,0 +1,312 @@
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 TestResizeFocusTransfersSolvedExtent(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ root *Node
16+ focus PaneID
17+ axis Axis
18+ delta int
19+ area domain.Rect
20+ want map[PaneID]domain.Rect
21+ }{
22+ {
23+ name: "grow width takes from right neighbor",
24+ root: split(Horizontal, NewLeaf("a"), NewLeaf("b")),
25+ focus: "a",
26+ axis: Width,
27+ delta: 5,
28+ area: domain.Rect{Width: 51, Height: 2},
29+ want: map[PaneID]domain.Rect{
30+ "a": {Width: 30, Height: 2},
31+ "b": {X: 31, Width: 20, Height: 2},
32+ },
33+ },
34+ {
35+ name: "shrink width gives to right neighbor",
36+ root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)),
37+ focus: "a",
38+ axis: Width,
39+ delta: -5,
40+ area: domain.Rect{Width: 51, Height: 2},
41+ want: map[PaneID]domain.Rect{
42+ "a": {Width: 25, Height: 2},
43+ "b": {X: 26, Width: 25, Height: 2},
44+ },
45+ },
46+ {
47+ name: "grow height takes from bottom neighbor",
48+ root: split(Vertical,
49+ weightedLeaf("a", 4), weightedLeaf("b", 3),
50+ ),
51+ focus: "a",
52+ axis: Height,
53+ delta: 1,
54+ area: domain.Rect{Width: 20, Height: 8},
55+ want: map[PaneID]domain.Rect{
56+ "a": {Width: 20, Height: 5},
57+ "b": {Y: 6, Width: 20, Height: 2},
58+ },
59+ },
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ tr := &Tree{Root: tt.root, Focus: tt.focus}
65+ require.NoError(t, tr.ResizeFocus(tt.axis, tt.delta, tt.area))
66+ placements, ok := Solve(tr.Root, tt.area)
67+ require.True(t, ok)
68+ require.Equal(t, tt.want, contents(placements))
69+ })
70+ }
71+}
72+
73+func TestResizeFocusSelectsNearestTargetAndAdjacentSibling(t *testing.T) {
74+ t.Parallel()
75+
76+ t.Run("nearest matching ancestor", func(t *testing.T) {
77+ inner := split(Horizontal, NewLeaf("a"), NewLeaf("b"))
78+ tr := &Tree{Root: split(Horizontal, weightedNode(inner, 2), weightedLeaf("c", 1)), Focus: "a"}
79+ area := domain.Rect{Width: 122, Height: 2}
80+ before, ok := Solve(tr.Root, area)
81+ require.True(t, ok)
82+
83+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
84+ after, ok := Solve(tr.Root, area)
85+ require.True(t, ok)
86+ require.Equal(t, contents(before)["c"], contents(after)["c"])
87+ require.Equal(t, contents(before)["a"].Width+5, contents(after)["a"].Width)
88+ require.Equal(t, contents(before)["b"].Width-5, contents(after)["b"].Width)
89+ })
90+
91+ t.Run("direct child subtree is resized as one target", func(t *testing.T) {
92+ subtree := split(Vertical, NewLeaf("a"), NewLeaf("d"))
93+ tr := &Tree{Root: split(Horizontal, subtree, NewLeaf("b")), Focus: "a"}
94+ area := domain.Rect{Width: 51, Height: 5}
95+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
96+ placements, ok := Solve(tr.Root, area)
97+ require.True(t, ok)
98+ require.Equal(t, 30, contents(placements)["a"].Width)
99+ require.Equal(t, 30, contents(placements)["d"].Width)
100+ require.Equal(t, 20, contents(placements)["b"].Width)
101+ })
102+
103+ t.Run("stack is resized as one target", func(t *testing.T) {
104+ stackNode := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("d")}, Expanded: "a"}
105+ tr := &Tree{Root: split(Horizontal, stackNode, NewLeaf("b")), Focus: "d"}
106+ area := domain.Rect{Width: 51, Height: 3}
107+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
108+ placements, ok := Solve(tr.Root, area)
109+ require.True(t, ok)
110+ require.Equal(t, 30, contents(placements)["a"].Width)
111+ var collapsedTitleWidth int
112+ for _, placement := range placements {
113+ if placement.ID == "d" {
114+ collapsedTitleWidth = placement.TitleBar.Width
115+ }
116+ }
117+ require.Equal(t, 30, collapsedTitleWidth)
118+ require.Equal(t, 20, contents(placements)["b"].Width)
119+ })
120+
121+ t.Run("right donor at minimum falls back to left", func(t *testing.T) {
122+ tr := &Tree{Root: split(Horizontal,
123+ weightedLeaf("a", 40), weightedLeaf("b", 20), weightedLeaf("c", 20),
124+ ), Focus: "b"}
125+ area := domain.Rect{Width: 82, Height: 2}
126+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
127+ placements, ok := Solve(tr.Root, area)
128+ require.True(t, ok)
129+ require.Equal(t, map[PaneID]int{"a": 35, "b": 25, "c": 20}, placementWidths(placements))
130+ })
131+
132+ t.Run("last target falls back to left neighbor", func(t *testing.T) {
133+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)), Focus: "b"}
134+ area := domain.Rect{Width: 51, Height: 2}
135+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
136+ placements, ok := Solve(tr.Root, area)
137+ require.True(t, ok)
138+ require.Equal(t, map[PaneID]int{"a": 25, "b": 25}, placementWidths(placements))
139+ })
140+}
141+
142+func TestResizeFocusMinimumAndTransactionalFailures(t *testing.T) {
143+ t.Parallel()
144+
145+ tests := []struct {
146+ name string
147+ tree *Tree
148+ axis Axis
149+ delta int
150+ area domain.Rect
151+ err error
152+ want map[PaneID]int
153+ }{
154+ {
155+ name: "partial growth uses every available donor cell",
156+ tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 20), weightedLeaf("b", 22)), Focus: "a"},
157+ axis: Width,
158+ delta: 5,
159+ area: domain.Rect{Width: 43, Height: 2},
160+ want: map[PaneID]int{"a": 22, "b": 20},
161+ },
162+ {
163+ name: "partial growth stops at recursive donor minimum",
164+ tree: &Tree{Root: split(Horizontal,
165+ weightedLeaf("a", 20),
166+ weightedNode(split(Horizontal, NewLeaf("b"), NewLeaf("d")), 43),
167+ ), Focus: "a"},
168+ axis: Width,
169+ delta: 5,
170+ area: domain.Rect{Width: 64, Height: 2},
171+ want: map[PaneID]int{"a": 22, "b": 20, "d": 20},
172+ },
173+ {
174+ name: "no donor capacity",
175+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"},
176+ axis: Width,
177+ delta: 2,
178+ area: domain.Rect{Width: 41, Height: 2},
179+ err: ErrTooSmall,
180+ },
181+ {
182+ name: "target cannot shrink",
183+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), weightedLeaf("b", 2)), Focus: "a"},
184+ axis: Width,
185+ delta: -2,
186+ area: domain.Rect{Width: 61, Height: 2},
187+ err: ErrTooSmall,
188+ },
189+ {
190+ name: "wrong axis",
191+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"},
192+ axis: Height,
193+ delta: 1,
194+ area: domain.Rect{Width: 41, Height: 2},
195+ err: ErrNotInSplit,
196+ },
197+ {
198+ name: "single pane",
199+ tree: NewTree("a"),
200+ axis: Width,
201+ delta: 2,
202+ area: domain.Rect{Width: 20, Height: 2},
203+ err: ErrNotInSplit,
204+ },
205+ {
206+ name: "invalid area rolls back",
207+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"},
208+ axis: Width,
209+ delta: 2,
210+ area: domain.Rect{Width: 20, Height: 2},
211+ err: ErrTooSmall,
212+ },
213+ }
214+
215+ for _, tt := range tests {
216+ t.Run(tt.name, func(t *testing.T) {
217+ before := tt.tree.Clone()
218+ err := tt.tree.ResizeFocus(tt.axis, tt.delta, tt.area)
219+ if tt.err != nil {
220+ require.ErrorIs(t, err, tt.err)
221+ require.Equal(t, before, tt.tree, "failed resize must not mutate the tree")
222+ return
223+ }
224+ require.NoError(t, err)
225+ placements, ok := Solve(tt.tree.Root, tt.area)
226+ require.True(t, ok)
227+ require.Equal(t, tt.want, placementWidths(placements))
228+ })
229+ }
230+}
231+
232+func TestResizeFocusRetainsProportionsAcrossTerminalSizes(t *testing.T) {
233+ t.Parallel()
234+
235+ tr := &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}
236+ small := domain.Rect{Width: 81, Height: 2}
237+ require.NoError(t, tr.ResizeFocus(Width, 10, small))
238+
239+ placements, ok := Solve(tr.Root, domain.Rect{Width: 161, Height: 2})
240+ require.True(t, ok)
241+ require.Equal(t, map[PaneID]int{"a": 100, "b": 60}, placementWidths(placements))
242+ placements, ok = Solve(tr.Root, small)
243+ require.True(t, ok)
244+ require.Equal(t, map[PaneID]int{"a": 50, "b": 30}, placementWidths(placements))
245+}
246+
247+func TestEqualize(t *testing.T) {
248+ t.Parallel()
249+
250+ tests := []struct {
251+ name string
252+ tree *Tree
253+ area domain.Rect
254+ want map[PaneID]int
255+ }{
256+ {
257+ name: "weighted siblings become equal",
258+ tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"},
259+ area: domain.Rect{Width: 81, Height: 2},
260+ want: map[PaneID]int{"a": 40, "b": 40},
261+ },
262+ {
263+ name: "recursive minimum constrains only the large child",
264+ tree: &Tree{Root: split(Horizontal,
265+ weightedNode(split(Horizontal, weightedLeaf("a", 5), weightedLeaf("b", 1)), 9),
266+ weightedLeaf("c", 1), weightedLeaf("d", 1),
267+ ), Focus: "a"},
268+ area: domain.Rect{Width: 102, Height: 2},
269+ want: map[PaneID]int{"a": 20, "b": 20, "c": 30, "d": 29},
270+ },
271+ }
272+
273+ for _, tt := range tests {
274+ t.Run(tt.name, func(t *testing.T) {
275+ require.NoError(t, tt.tree.Equalize(tt.area))
276+ placements, ok := Solve(tt.tree.Root, tt.area)
277+ require.True(t, ok)
278+ require.Equal(t, tt.want, placementWidths(placements))
279+ })
280+ }
281+}
282+
283+func TestEqualizeFailureIsTransactional(t *testing.T) {
284+ t.Parallel()
285+
286+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
287+ before := tr.Clone()
288+ require.ErrorIs(t, tr.Equalize(domain.Rect{Width: 20, Height: 2}), ErrTooSmall)
289+ require.Equal(t, before, tr)
290+}
291+
292+func TestCanResize(t *testing.T) {
293+ t.Parallel()
294+
295+ tests := []struct {
296+ name string
297+ tree *Tree
298+ want bool
299+ }{
300+ {name: "single pane", tree: NewTree("a"), want: false},
301+ {name: "focused split child", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, want: true},
302+ {name: "focused stack member under split", tree: &Tree{Root: split(Horizontal, &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, NewLeaf("c")), Focus: "b"}, want: true},
303+ {name: "stack without split", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, Focus: "a"}, want: false},
304+ {name: "missing focus", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "missing"}, want: false},
305+ }
306+
307+ for _, tt := range tests {
308+ t.Run(tt.name, func(t *testing.T) {
309+ require.Equal(t, tt.want, tt.tree.CanResize())
310+ })
311+ }
312+}
M internal/usecase/layout/tree.go +8 -0
@@ -37,6 +37,14 @@ const (
3737 MinPaneRows = 2
3838 )
3939
40+// Axis identifies the dimension changed by a resize operation.
41+type Axis int
42+
43+const (
44+ Width Axis = iota
45+ Height
46+)
47+
4048 // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir
4149 // and Children are meaningful for Split nodes; Children and Expanded are
4250 // meaningful for Stack nodes.
@@ -37,6 +37,14 @@ const (
37 MinPaneRows = 237 MinPaneRows = 2
38 )38 )
39 39
40+// Axis identifies the dimension changed by a resize operation.
41+type Axis int
42+
43+const (
44+ Width Axis = iota
45+ Height
46+)
47+
40 // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir48 // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir
41 // and Children are meaningful for Split nodes; Children and Expanded are49 // and Children are meaningful for Split nodes; Children and Expanded are
42 // meaningful for Stack nodes.50 // meaningful for Stack nodes.

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