detached 3f0da0d

feat(layout): add directional pane consume and expel

commit
3f0da0daf2d7d20b897f5546de7685d8ed477fac
author
brice · · 2026-07-27 13:28 UTC
parent
c26f90f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +730 -0
A internal/usecase/layout/consume_expel.go +260 -0
@@ -0,0 +1,260 @@
1+package layout
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+var (
10+ ErrUnsupportedColumnLayout = errors.New("unsupported column layout")
11+ ErrInvalidDirection = errors.New("invalid direction")
12+)
13+
14+// ConsumeOrExpelPane moves target horizontally between canonical root columns.
15+// A singleton column is consumed into its immediate neighbor; a member of a
16+// vertical or stack column is expelled into a new singleton column.
17+func (t *Tree) ConsumeOrExpelPane(target PaneID, dir Direction, area domain.Rect) (bool, error) {
18+ if dir != Left && dir != Right {
19+ return false, ErrInvalidDirection
20+ }
21+ if !validCanonicalColumnTree(t) {
22+ return false, ErrUnsupportedColumnLayout
23+ }
24+
25+ columns := canonicalColumns(t.Root)
26+ columnIndex, memberIndex, found := findColumnMember(columns, target)
27+ if !found {
28+ return false, ErrNotFound
29+ }
30+
31+ if columns[columnIndex].Kind == Leaf {
32+ destinationIndex := columnIndex - 1
33+ if dir == Right {
34+ destinationIndex = columnIndex + 1
35+ }
36+ if destinationIndex < 0 || destinationIndex >= len(columns) {
37+ return false, nil
38+ }
39+ }
40+
41+ candidate := t.clone()
42+ if _, solved := Solve(candidate.Root, area); !solved {
43+ return false, ErrTooSmall
44+ }
45+
46+ columns = canonicalColumns(candidate.Root)
47+ columnIndex, memberIndex, _ = findColumnMember(columns, target)
48+ if candidate.Root.Kind == Split && candidate.Root.Dir == Horizontal {
49+ normalizeChildWeightsFromArea(candidate.Root, area)
50+ }
51+
52+ if columns[columnIndex].Kind == Leaf {
53+ consumeSingletonColumn(candidate, columnIndex, dir, area)
54+ } else {
55+ expelColumnMember(candidate, columnIndex, memberIndex, dir)
56+ }
57+ candidate.Focus = target
58+
59+ if _, solved := Solve(candidate.Root, area); !solved {
60+ return false, ErrTooSmall
61+ }
62+ *t = *candidate
63+ return true, nil
64+}
65+
66+func validCanonicalColumnTree(t *Tree) bool {
67+ if t == nil || t.Root == nil {
68+ return false
69+ }
70+
71+ columns := canonicalColumns(t.Root)
72+ if len(columns) == 0 {
73+ return false
74+ }
75+ if t.Root.Kind == Split && t.Root.Dir == Horizontal && len(columns) < 2 {
76+ return false
77+ }
78+
79+ ids := make(map[PaneID]struct{})
80+ for _, column := range columns {
81+ if !validCanonicalColumn(column, ids) {
82+ return false
83+ }
84+ }
85+ if t.Focus == "" {
86+ return false
87+ }
88+ _, focused := ids[t.Focus]
89+ return focused
90+}
91+
92+func canonicalColumns(root *Node) []*Node {
93+ if root == nil {
94+ return nil
95+ }
96+ if root.Kind == Split && root.Dir == Horizontal {
97+ return root.Children
98+ }
99+ return []*Node{root}
100+}
101+
102+func validCanonicalColumn(column *Node, ids map[PaneID]struct{}) bool {
103+ if column == nil {
104+ return false
105+ }
106+
107+ switch column.Kind {
108+ case Leaf:
109+ if len(column.Children) != 0 {
110+ return false
111+ }
112+ return addUniqueLeafID(ids, column.Leaf)
113+ case Split:
114+ if column.Dir != Vertical || len(column.Children) < 2 {
115+ return false
116+ }
117+ case Stack:
118+ if len(column.Children) < 2 {
119+ return false
120+ }
121+ default:
122+ return false
123+ }
124+
125+ expanded := column.Kind != Stack
126+ for _, member := range column.Children {
127+ if member == nil || member.Kind != Leaf || len(member.Children) != 0 || !addUniqueLeafID(ids, member.Leaf) {
128+ return false
129+ }
130+ if column.Kind == Stack && member.Leaf == column.Expanded {
131+ expanded = true
132+ }
133+ }
134+ return expanded
135+}
136+
137+func addUniqueLeafID(ids map[PaneID]struct{}, id PaneID) bool {
138+ if id == "" {
139+ return false
140+ }
141+ if _, exists := ids[id]; exists {
142+ return false
143+ }
144+ ids[id] = struct{}{}
145+ return true
146+}
147+
148+func findColumnMember(columns []*Node, target PaneID) (int, int, bool) {
149+ for columnIndex, column := range columns {
150+ if column.Kind == Leaf {
151+ if column.Leaf == target {
152+ return columnIndex, -1, true
153+ }
154+ continue
155+ }
156+ for memberIndex, member := range column.Children {
157+ if member.Leaf == target {
158+ return columnIndex, memberIndex, true
159+ }
160+ }
161+ }
162+ return 0, 0, false
163+}
164+
165+func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) {
166+ root := t.Root
167+ destinationIndex := sourceIndex - 1
168+ if dir == Right {
169+ destinationIndex = sourceIndex + 1
170+ }
171+
172+ columnAreas, _ := splitChildRects(root, area)
173+ moved := root.Children[sourceIndex]
174+ moved.Weight = 0
175+ destination := root.Children[destinationIndex]
176+
177+ switch destination.Kind {
178+ case Leaf:
179+ weight := destination.Weight
180+ destination.Weight = 0
181+ destination = &Node{
182+ Kind: Split,
183+ Dir: Vertical,
184+ Children: []*Node{destination, moved},
185+ Weight: weight,
186+ }
187+ case Split:
188+ normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex])
189+ destination.Children = append(destination.Children, moved)
190+ case Stack:
191+ destination.Children = append(destination.Children, moved)
192+ destination.Expanded = moved.Leaf
193+ clearChildWeights(destination)
194+ }
195+ root.Children[destinationIndex] = destination
196+ root.Children = removeNodeAt(root.Children, sourceIndex)
197+
198+ if len(root.Children) == 1 {
199+ promoted := root.Children[0]
200+ promoted.Weight = root.Weight
201+ t.Root = promoted
202+ }
203+}
204+
205+func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) {
206+ root := t.Root
207+ rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal
208+ columns := canonicalColumns(root)
209+ source := columns[columnIndex]
210+ wrapperWeight := source.Weight
211+ if !rootIsHorizontal {
212+ wrapperWeight = root.Weight
213+ }
214+
215+ moved := source.Children[memberIndex]
216+ moved.Weight = 0
217+ source.Children = removeNodeAt(source.Children, memberIndex)
218+ if source.Kind == Stack {
219+ if source.Expanded == moved.Leaf {
220+ source.Expanded = source.Children[0].Leaf
221+ }
222+ clearChildWeights(source)
223+ }
224+
225+ remaining := source
226+ if len(source.Children) == 1 {
227+ remaining = source.Children[0]
228+ remaining.Weight = source.Weight
229+ }
230+
231+ if !rootIsHorizontal {
232+ remaining.Weight = 0
233+ children := []*Node{remaining, moved}
234+ if dir == Left {
235+ children[0], children[1] = children[1], children[0]
236+ }
237+ t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight}
238+ return
239+ }
240+
241+ root.Children[columnIndex] = remaining
242+ insertionIndex := columnIndex
243+ if dir == Right {
244+ insertionIndex++
245+ }
246+ root.Children = insertNodeAt(root.Children, insertionIndex, moved)
247+}
248+
249+func removeNodeAt(nodes []*Node, index int) []*Node {
250+ copy(nodes[index:], nodes[index+1:])
251+ nodes[len(nodes)-1] = nil
252+ return nodes[:len(nodes)-1]
253+}
254+
255+func insertNodeAt(nodes []*Node, index int, node *Node) []*Node {
256+ nodes = append(nodes, nil)
257+ copy(nodes[index+1:], nodes[index:])
258+ nodes[index] = node
259+ return nodes
260+}
@@ -0,0 +1,260 @@
1+package layout
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+var (
10+ ErrUnsupportedColumnLayout = errors.New("unsupported column layout")
11+ ErrInvalidDirection = errors.New("invalid direction")
12+)
13+
14+// ConsumeOrExpelPane moves target horizontally between canonical root columns.
15+// A singleton column is consumed into its immediate neighbor; a member of a
16+// vertical or stack column is expelled into a new singleton column.
17+func (t *Tree) ConsumeOrExpelPane(target PaneID, dir Direction, area domain.Rect) (bool, error) {
18+ if dir != Left && dir != Right {
19+ return false, ErrInvalidDirection
20+ }
21+ if !validCanonicalColumnTree(t) {
22+ return false, ErrUnsupportedColumnLayout
23+ }
24+
25+ columns := canonicalColumns(t.Root)
26+ columnIndex, memberIndex, found := findColumnMember(columns, target)
27+ if !found {
28+ return false, ErrNotFound
29+ }
30+
31+ if columns[columnIndex].Kind == Leaf {
32+ destinationIndex := columnIndex - 1
33+ if dir == Right {
34+ destinationIndex = columnIndex + 1
35+ }
36+ if destinationIndex < 0 || destinationIndex >= len(columns) {
37+ return false, nil
38+ }
39+ }
40+
41+ candidate := t.clone()
42+ if _, solved := Solve(candidate.Root, area); !solved {
43+ return false, ErrTooSmall
44+ }
45+
46+ columns = canonicalColumns(candidate.Root)
47+ columnIndex, memberIndex, _ = findColumnMember(columns, target)
48+ if candidate.Root.Kind == Split && candidate.Root.Dir == Horizontal {
49+ normalizeChildWeightsFromArea(candidate.Root, area)
50+ }
51+
52+ if columns[columnIndex].Kind == Leaf {
53+ consumeSingletonColumn(candidate, columnIndex, dir, area)
54+ } else {
55+ expelColumnMember(candidate, columnIndex, memberIndex, dir)
56+ }
57+ candidate.Focus = target
58+
59+ if _, solved := Solve(candidate.Root, area); !solved {
60+ return false, ErrTooSmall
61+ }
62+ *t = *candidate
63+ return true, nil
64+}
65+
66+func validCanonicalColumnTree(t *Tree) bool {
67+ if t == nil || t.Root == nil {
68+ return false
69+ }
70+
71+ columns := canonicalColumns(t.Root)
72+ if len(columns) == 0 {
73+ return false
74+ }
75+ if t.Root.Kind == Split && t.Root.Dir == Horizontal && len(columns) < 2 {
76+ return false
77+ }
78+
79+ ids := make(map[PaneID]struct{})
80+ for _, column := range columns {
81+ if !validCanonicalColumn(column, ids) {
82+ return false
83+ }
84+ }
85+ if t.Focus == "" {
86+ return false
87+ }
88+ _, focused := ids[t.Focus]
89+ return focused
90+}
91+
92+func canonicalColumns(root *Node) []*Node {
93+ if root == nil {
94+ return nil
95+ }
96+ if root.Kind == Split && root.Dir == Horizontal {
97+ return root.Children
98+ }
99+ return []*Node{root}
100+}
101+
102+func validCanonicalColumn(column *Node, ids map[PaneID]struct{}) bool {
103+ if column == nil {
104+ return false
105+ }
106+
107+ switch column.Kind {
108+ case Leaf:
109+ if len(column.Children) != 0 {
110+ return false
111+ }
112+ return addUniqueLeafID(ids, column.Leaf)
113+ case Split:
114+ if column.Dir != Vertical || len(column.Children) < 2 {
115+ return false
116+ }
117+ case Stack:
118+ if len(column.Children) < 2 {
119+ return false
120+ }
121+ default:
122+ return false
123+ }
124+
125+ expanded := column.Kind != Stack
126+ for _, member := range column.Children {
127+ if member == nil || member.Kind != Leaf || len(member.Children) != 0 || !addUniqueLeafID(ids, member.Leaf) {
128+ return false
129+ }
130+ if column.Kind == Stack && member.Leaf == column.Expanded {
131+ expanded = true
132+ }
133+ }
134+ return expanded
135+}
136+
137+func addUniqueLeafID(ids map[PaneID]struct{}, id PaneID) bool {
138+ if id == "" {
139+ return false
140+ }
141+ if _, exists := ids[id]; exists {
142+ return false
143+ }
144+ ids[id] = struct{}{}
145+ return true
146+}
147+
148+func findColumnMember(columns []*Node, target PaneID) (int, int, bool) {
149+ for columnIndex, column := range columns {
150+ if column.Kind == Leaf {
151+ if column.Leaf == target {
152+ return columnIndex, -1, true
153+ }
154+ continue
155+ }
156+ for memberIndex, member := range column.Children {
157+ if member.Leaf == target {
158+ return columnIndex, memberIndex, true
159+ }
160+ }
161+ }
162+ return 0, 0, false
163+}
164+
165+func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) {
166+ root := t.Root
167+ destinationIndex := sourceIndex - 1
168+ if dir == Right {
169+ destinationIndex = sourceIndex + 1
170+ }
171+
172+ columnAreas, _ := splitChildRects(root, area)
173+ moved := root.Children[sourceIndex]
174+ moved.Weight = 0
175+ destination := root.Children[destinationIndex]
176+
177+ switch destination.Kind {
178+ case Leaf:
179+ weight := destination.Weight
180+ destination.Weight = 0
181+ destination = &Node{
182+ Kind: Split,
183+ Dir: Vertical,
184+ Children: []*Node{destination, moved},
185+ Weight: weight,
186+ }
187+ case Split:
188+ normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex])
189+ destination.Children = append(destination.Children, moved)
190+ case Stack:
191+ destination.Children = append(destination.Children, moved)
192+ destination.Expanded = moved.Leaf
193+ clearChildWeights(destination)
194+ }
195+ root.Children[destinationIndex] = destination
196+ root.Children = removeNodeAt(root.Children, sourceIndex)
197+
198+ if len(root.Children) == 1 {
199+ promoted := root.Children[0]
200+ promoted.Weight = root.Weight
201+ t.Root = promoted
202+ }
203+}
204+
205+func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) {
206+ root := t.Root
207+ rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal
208+ columns := canonicalColumns(root)
209+ source := columns[columnIndex]
210+ wrapperWeight := source.Weight
211+ if !rootIsHorizontal {
212+ wrapperWeight = root.Weight
213+ }
214+
215+ moved := source.Children[memberIndex]
216+ moved.Weight = 0
217+ source.Children = removeNodeAt(source.Children, memberIndex)
218+ if source.Kind == Stack {
219+ if source.Expanded == moved.Leaf {
220+ source.Expanded = source.Children[0].Leaf
221+ }
222+ clearChildWeights(source)
223+ }
224+
225+ remaining := source
226+ if len(source.Children) == 1 {
227+ remaining = source.Children[0]
228+ remaining.Weight = source.Weight
229+ }
230+
231+ if !rootIsHorizontal {
232+ remaining.Weight = 0
233+ children := []*Node{remaining, moved}
234+ if dir == Left {
235+ children[0], children[1] = children[1], children[0]
236+ }
237+ t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight}
238+ return
239+ }
240+
241+ root.Children[columnIndex] = remaining
242+ insertionIndex := columnIndex
243+ if dir == Right {
244+ insertionIndex++
245+ }
246+ root.Children = insertNodeAt(root.Children, insertionIndex, moved)
247+}
248+
249+func removeNodeAt(nodes []*Node, index int) []*Node {
250+ copy(nodes[index:], nodes[index+1:])
251+ nodes[len(nodes)-1] = nil
252+ return nodes[:len(nodes)-1]
253+}
254+
255+func insertNodeAt(nodes []*Node, index int, node *Node) []*Node {
256+ nodes = append(nodes, nil)
257+ copy(nodes[index+1:], nodes[index:])
258+ nodes[index] = node
259+ return nodes
260+}
A internal/usecase/layout/consume_expel_test.go +304 -0
@@ -0,0 +1,304 @@
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 TestConsumeOrExpelPaneTransitions(t *testing.T) {
11+ t.Parallel()
12+
13+ area := domain.Rect{Width: 101, Height: 12}
14+ tests := []struct {
15+ name string
16+ tree *Tree
17+ target PaneID
18+ dir Direction
19+ want *Tree
20+ wantChange bool
21+ }{
22+ {
23+ name: "consume singleton left into leaf",
24+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
25+ target: "b",
26+ dir: Left,
27+ want: &Tree{Root: vertical("a", "b"), Focus: "b"},
28+ wantChange: true,
29+ },
30+ {
31+ name: "consume singleton right into leaf",
32+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
33+ target: "a",
34+ dir: Right,
35+ want: &Tree{Root: vertical("b", "a"), Focus: "a"},
36+ wantChange: true,
37+ },
38+ {
39+ name: "consume singleton left into vertical column",
40+ tree: &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"},
41+ target: "c",
42+ dir: Left,
43+ want: &Tree{Root: verticalNodes(
44+ consumeExpelWeightedLeaf("a", 6),
45+ consumeExpelWeightedLeaf("b", 5),
46+ NewLeaf("c"),
47+ ), Focus: "c"},
48+ wantChange: true,
49+ },
50+ {
51+ name: "consume singleton right into vertical column",
52+ tree: &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"},
53+ target: "a",
54+ dir: Right,
55+ want: &Tree{Root: verticalNodes(
56+ consumeExpelWeightedLeaf("b", 6),
57+ consumeExpelWeightedLeaf("c", 5),
58+ NewLeaf("a"),
59+ ), Focus: "a"},
60+ wantChange: true,
61+ },
62+ {
63+ name: "consume singleton left into stack appends and expands",
64+ tree: &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"},
65+ target: "c",
66+ dir: Left,
67+ want: &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"},
68+ wantChange: true,
69+ },
70+ {
71+ name: "consume singleton right into stack appends and expands",
72+ tree: &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"},
73+ target: "a",
74+ dir: Right,
75+ want: &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"},
76+ wantChange: true,
77+ },
78+ {
79+ name: "expel middle vertical member left from sole root column",
80+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
81+ target: "b",
82+ dir: Left,
83+ want: &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"},
84+ wantChange: true,
85+ },
86+ {
87+ name: "expel middle vertical member right from sole root column",
88+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
89+ target: "b",
90+ dir: Right,
91+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
92+ wantChange: true,
93+ },
94+ {
95+ name: "expel vertical member left beside its source column",
96+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
97+ target: "b",
98+ dir: Left,
99+ want: &Tree{Root: horizontal(
100+ NewLeaf("b"),
101+ consumeExpelWeightedNode(vertical("a", "c"), 50),
102+ consumeExpelWeightedLeaf("d", 50),
103+ ), Focus: "b"},
104+ wantChange: true,
105+ },
106+ {
107+ name: "expel vertical member right beside its source column",
108+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
109+ target: "b",
110+ dir: Right,
111+ want: &Tree{Root: horizontal(
112+ consumeExpelWeightedNode(vertical("a", "c"), 50),
113+ NewLeaf("b"),
114+ consumeExpelWeightedLeaf("d", 50),
115+ ), Focus: "b"},
116+ wantChange: true,
117+ },
118+ {
119+ name: "expel stack member left from sole root column",
120+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
121+ target: "b",
122+ dir: Left,
123+ want: &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"},
124+ wantChange: true,
125+ },
126+ {
127+ name: "expel stack member right from sole root column",
128+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
129+ target: "b",
130+ dir: Right,
131+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
132+ wantChange: true,
133+ },
134+ {
135+ name: "expelling expanded stack member repairs source expansion",
136+ tree: &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"},
137+ target: "b",
138+ dir: Right,
139+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
140+ wantChange: true,
141+ },
142+ {
143+ name: "expel collapses two-member vertical column",
144+ tree: &Tree{Root: vertical("a", "b"), Focus: "a"},
145+ target: "a",
146+ dir: Left,
147+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
148+ wantChange: true,
149+ },
150+ {
151+ name: "expel collapses two-member stack column",
152+ tree: &Tree{Root: stack("b", "a", "b"), Focus: "b"},
153+ target: "b",
154+ dir: Right,
155+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
156+ wantChange: true,
157+ },
158+ {
159+ name: "real move focuses explicitly targeted non-focused pane",
160+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "a"},
161+ target: "b",
162+ dir: Right,
163+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
164+ wantChange: true,
165+ },
166+ {
167+ name: "left edge singleton is exact no-op and preserves different focus",
168+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
169+ target: "a",
170+ dir: Left,
171+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
172+ wantChange: false,
173+ },
174+ {
175+ name: "right edge singleton is exact no-op and preserves different focus",
176+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
177+ target: "b",
178+ dir: Right,
179+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
180+ wantChange: false,
181+ },
182+ {
183+ name: "sole singleton root is exact no-op",
184+ tree: NewTree("a"),
185+ target: "a",
186+ dir: Left,
187+ want: NewTree("a"),
188+ wantChange: false,
189+ },
190+ }
191+
192+ for _, tt := range tests {
193+ t.Run(tt.name, func(t *testing.T) {
194+ before := tt.tree.Clone()
195+ beforeIDs := LeafIDs(tt.tree.Root)
196+ requireUniquePaneIDs(t, beforeIDs)
197+
198+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area)
199+ require.NoError(t, err)
200+ require.Equal(t, tt.wantChange, changed)
201+ require.Equal(t, tt.want, tt.tree)
202+ if !tt.wantChange {
203+ require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree")
204+ }
205+
206+ afterIDs := LeafIDs(tt.tree.Root)
207+ require.ElementsMatch(t, beforeIDs, afterIDs)
208+ require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs")
209+ requireUniquePaneIDs(t, afterIDs)
210+ _, solved := Solve(tt.tree.Root, area)
211+ require.True(t, solved, "every successful result must solve")
212+ })
213+ }
214+}
215+
216+func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) {
217+ t.Parallel()
218+
219+ area := domain.Rect{Width: 101, Height: 12}
220+ tests := []struct {
221+ name string
222+ tree *Tree
223+ target PaneID
224+ dir Direction
225+ area domain.Rect
226+ want error
227+ }{
228+ {name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
229+ {name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
230+ {name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound},
231+ {name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
232+ {name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout},
233+ {name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
234+ {name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
235+ {name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
236+ {name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
237+ {name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
238+ {name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
239+ {name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
240+ {name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
241+ {name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
242+ {name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
243+ {name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
244+ {name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
245+ {name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection},
246+ {name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection},
247+ {name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection},
248+ {name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall},
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ before := tt.tree.Clone()
254+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
255+ require.False(t, changed)
256+ require.ErrorIs(t, err, tt.want)
257+ require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree")
258+ })
259+ }
260+}
261+
262+func horizontal(columns ...*Node) *Node {
263+ return &Node{Kind: Split, Dir: Horizontal, Children: columns}
264+}
265+
266+func vertical(ids ...PaneID) *Node {
267+ children := make([]*Node, 0, len(ids))
268+ for _, id := range ids {
269+ children = append(children, NewLeaf(id))
270+ }
271+ return verticalNodes(children...)
272+}
273+
274+func verticalNodes(children ...*Node) *Node {
275+ return &Node{Kind: Split, Dir: Vertical, Children: children}
276+}
277+
278+func stack(expanded PaneID, ids ...PaneID) *Node {
279+ children := make([]*Node, 0, len(ids))
280+ for _, id := range ids {
281+ children = append(children, NewLeaf(id))
282+ }
283+ return &Node{Kind: Stack, Children: children, Expanded: expanded}
284+}
285+
286+func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node {
287+ return &Node{Kind: Leaf, Leaf: id, Weight: weight}
288+}
289+
290+func consumeExpelWeightedNode(node *Node, weight float64) *Node {
291+ node.Weight = weight
292+ return node
293+}
294+
295+func requireUniquePaneIDs(t *testing.T, ids []PaneID) {
296+ t.Helper()
297+ seen := make(map[PaneID]struct{}, len(ids))
298+ for _, id := range ids {
299+ require.NotEmpty(t, id)
300+ _, duplicate := seen[id]
301+ require.False(t, duplicate, "pane ID %q occurs more than once", id)
302+ seen[id] = struct{}{}
303+ }
304+}
@@ -0,0 +1,304 @@
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 TestConsumeOrExpelPaneTransitions(t *testing.T) {
11+ t.Parallel()
12+
13+ area := domain.Rect{Width: 101, Height: 12}
14+ tests := []struct {
15+ name string
16+ tree *Tree
17+ target PaneID
18+ dir Direction
19+ want *Tree
20+ wantChange bool
21+ }{
22+ {
23+ name: "consume singleton left into leaf",
24+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
25+ target: "b",
26+ dir: Left,
27+ want: &Tree{Root: vertical("a", "b"), Focus: "b"},
28+ wantChange: true,
29+ },
30+ {
31+ name: "consume singleton right into leaf",
32+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
33+ target: "a",
34+ dir: Right,
35+ want: &Tree{Root: vertical("b", "a"), Focus: "a"},
36+ wantChange: true,
37+ },
38+ {
39+ name: "consume singleton left into vertical column",
40+ tree: &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"},
41+ target: "c",
42+ dir: Left,
43+ want: &Tree{Root: verticalNodes(
44+ consumeExpelWeightedLeaf("a", 6),
45+ consumeExpelWeightedLeaf("b", 5),
46+ NewLeaf("c"),
47+ ), Focus: "c"},
48+ wantChange: true,
49+ },
50+ {
51+ name: "consume singleton right into vertical column",
52+ tree: &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"},
53+ target: "a",
54+ dir: Right,
55+ want: &Tree{Root: verticalNodes(
56+ consumeExpelWeightedLeaf("b", 6),
57+ consumeExpelWeightedLeaf("c", 5),
58+ NewLeaf("a"),
59+ ), Focus: "a"},
60+ wantChange: true,
61+ },
62+ {
63+ name: "consume singleton left into stack appends and expands",
64+ tree: &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"},
65+ target: "c",
66+ dir: Left,
67+ want: &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"},
68+ wantChange: true,
69+ },
70+ {
71+ name: "consume singleton right into stack appends and expands",
72+ tree: &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"},
73+ target: "a",
74+ dir: Right,
75+ want: &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"},
76+ wantChange: true,
77+ },
78+ {
79+ name: "expel middle vertical member left from sole root column",
80+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
81+ target: "b",
82+ dir: Left,
83+ want: &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"},
84+ wantChange: true,
85+ },
86+ {
87+ name: "expel middle vertical member right from sole root column",
88+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
89+ target: "b",
90+ dir: Right,
91+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
92+ wantChange: true,
93+ },
94+ {
95+ name: "expel vertical member left beside its source column",
96+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
97+ target: "b",
98+ dir: Left,
99+ want: &Tree{Root: horizontal(
100+ NewLeaf("b"),
101+ consumeExpelWeightedNode(vertical("a", "c"), 50),
102+ consumeExpelWeightedLeaf("d", 50),
103+ ), Focus: "b"},
104+ wantChange: true,
105+ },
106+ {
107+ name: "expel vertical member right beside its source column",
108+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
109+ target: "b",
110+ dir: Right,
111+ want: &Tree{Root: horizontal(
112+ consumeExpelWeightedNode(vertical("a", "c"), 50),
113+ NewLeaf("b"),
114+ consumeExpelWeightedLeaf("d", 50),
115+ ), Focus: "b"},
116+ wantChange: true,
117+ },
118+ {
119+ name: "expel stack member left from sole root column",
120+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
121+ target: "b",
122+ dir: Left,
123+ want: &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"},
124+ wantChange: true,
125+ },
126+ {
127+ name: "expel stack member right from sole root column",
128+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
129+ target: "b",
130+ dir: Right,
131+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
132+ wantChange: true,
133+ },
134+ {
135+ name: "expelling expanded stack member repairs source expansion",
136+ tree: &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"},
137+ target: "b",
138+ dir: Right,
139+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
140+ wantChange: true,
141+ },
142+ {
143+ name: "expel collapses two-member vertical column",
144+ tree: &Tree{Root: vertical("a", "b"), Focus: "a"},
145+ target: "a",
146+ dir: Left,
147+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
148+ wantChange: true,
149+ },
150+ {
151+ name: "expel collapses two-member stack column",
152+ tree: &Tree{Root: stack("b", "a", "b"), Focus: "b"},
153+ target: "b",
154+ dir: Right,
155+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
156+ wantChange: true,
157+ },
158+ {
159+ name: "real move focuses explicitly targeted non-focused pane",
160+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "a"},
161+ target: "b",
162+ dir: Right,
163+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
164+ wantChange: true,
165+ },
166+ {
167+ name: "left edge singleton is exact no-op and preserves different focus",
168+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
169+ target: "a",
170+ dir: Left,
171+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
172+ wantChange: false,
173+ },
174+ {
175+ name: "right edge singleton is exact no-op and preserves different focus",
176+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
177+ target: "b",
178+ dir: Right,
179+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
180+ wantChange: false,
181+ },
182+ {
183+ name: "sole singleton root is exact no-op",
184+ tree: NewTree("a"),
185+ target: "a",
186+ dir: Left,
187+ want: NewTree("a"),
188+ wantChange: false,
189+ },
190+ }
191+
192+ for _, tt := range tests {
193+ t.Run(tt.name, func(t *testing.T) {
194+ before := tt.tree.Clone()
195+ beforeIDs := LeafIDs(tt.tree.Root)
196+ requireUniquePaneIDs(t, beforeIDs)
197+
198+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area)
199+ require.NoError(t, err)
200+ require.Equal(t, tt.wantChange, changed)
201+ require.Equal(t, tt.want, tt.tree)
202+ if !tt.wantChange {
203+ require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree")
204+ }
205+
206+ afterIDs := LeafIDs(tt.tree.Root)
207+ require.ElementsMatch(t, beforeIDs, afterIDs)
208+ require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs")
209+ requireUniquePaneIDs(t, afterIDs)
210+ _, solved := Solve(tt.tree.Root, area)
211+ require.True(t, solved, "every successful result must solve")
212+ })
213+ }
214+}
215+
216+func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) {
217+ t.Parallel()
218+
219+ area := domain.Rect{Width: 101, Height: 12}
220+ tests := []struct {
221+ name string
222+ tree *Tree
223+ target PaneID
224+ dir Direction
225+ area domain.Rect
226+ want error
227+ }{
228+ {name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
229+ {name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
230+ {name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound},
231+ {name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
232+ {name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout},
233+ {name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
234+ {name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
235+ {name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
236+ {name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
237+ {name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
238+ {name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
239+ {name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
240+ {name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
241+ {name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
242+ {name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
243+ {name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
244+ {name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
245+ {name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection},
246+ {name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection},
247+ {name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection},
248+ {name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall},
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ before := tt.tree.Clone()
254+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
255+ require.False(t, changed)
256+ require.ErrorIs(t, err, tt.want)
257+ require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree")
258+ })
259+ }
260+}
261+
262+func horizontal(columns ...*Node) *Node {
263+ return &Node{Kind: Split, Dir: Horizontal, Children: columns}
264+}
265+
266+func vertical(ids ...PaneID) *Node {
267+ children := make([]*Node, 0, len(ids))
268+ for _, id := range ids {
269+ children = append(children, NewLeaf(id))
270+ }
271+ return verticalNodes(children...)
272+}
273+
274+func verticalNodes(children ...*Node) *Node {
275+ return &Node{Kind: Split, Dir: Vertical, Children: children}
276+}
277+
278+func stack(expanded PaneID, ids ...PaneID) *Node {
279+ children := make([]*Node, 0, len(ids))
280+ for _, id := range ids {
281+ children = append(children, NewLeaf(id))
282+ }
283+ return &Node{Kind: Stack, Children: children, Expanded: expanded}
284+}
285+
286+func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node {
287+ return &Node{Kind: Leaf, Leaf: id, Weight: weight}
288+}
289+
290+func consumeExpelWeightedNode(node *Node, weight float64) *Node {
291+ node.Weight = weight
292+ return node
293+}
294+
295+func requireUniquePaneIDs(t *testing.T, ids []PaneID) {
296+ t.Helper()
297+ seen := make(map[PaneID]struct{}, len(ids))
298+ for _, id := range ids {
299+ require.NotEmpty(t, id)
300+ _, duplicate := seen[id]
301+ require.False(t, duplicate, "pane ID %q occurs more than once", id)
302+ seen[id] = struct{}{}
303+ }
304+}
M internal/usecase/layout/weights_test.go +166 -0
@@ -323,6 +323,172 @@ func TestWeightSplitStackWrapperTransfersShare(t *testing.T) {
323323 }
324324 }
325325
326+func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
327+ t.Parallel()
328+
329+ tests := []struct {
330+ name string
331+ tree *Tree
332+ target PaneID
333+ dir Direction
334+ area domain.Rect
335+ check func(*testing.T, *Tree, []Placement, []Placement)
336+ }{
337+ {
338+ name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight",
339+ tree: &Tree{Root: weightedNode(horizontal(
340+ weightedNode(vertical("a", "b"), 3),
341+ weightedLeaf("c", 1),
342+ ), 11), Focus: "b"},
343+ target: "b",
344+ dir: Right,
345+ area: domain.Rect{Width: 202, Height: 5},
346+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
347+ require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before))
348+ require.Equal(t, 11.0, tree.Root.Weight)
349+ require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root))
350+ require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
351+ require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after))
352+ },
353+ },
354+ {
355+ name: "vertical consume preserves solved member shares and gives moved pane default weight",
356+ tree: &Tree{Root: weightedNode(horizontal(
357+ weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2),
358+ weightedLeaf("c", 1),
359+ ), 9), Focus: "c"},
360+ target: "c",
361+ dir: Left,
362+ area: domain.Rect{Width: 101, Height: 20},
363+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
364+ require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before))
365+ require.Equal(t, Vertical, tree.Root.Dir)
366+ require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share")
367+ require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root))
368+ require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after))
369+ },
370+ },
371+ {
372+ name: "stack consume clears member weights and preserves normalized outer column share",
373+ tree: &Tree{Root: horizontal(
374+ weightedLeaf("x", 1),
375+ weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3),
376+ weightedLeaf("c", 1),
377+ ), Focus: "c"},
378+ target: "c",
379+ dir: Left,
380+ area: domain.Rect{Width: 103, Height: 4},
381+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
382+ require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before))
383+ require.Len(t, tree.Root.Children, 2)
384+ stackColumn := tree.Root.Children[1]
385+ require.Equal(t, Stack, stackColumn.Kind)
386+ require.Equal(t, 61.0, stackColumn.Weight)
387+ require.Equal(t, PaneID("c"), stackColumn.Expanded)
388+ require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn))
389+ require.Equal(t, 77, placementSpanWidth(after, "a"))
390+ require.Equal(t, 77, placementSpanWidth(after, "b"))
391+ require.Equal(t, 77, placementSpanWidth(after, "c"))
392+ },
393+ },
394+ {
395+ name: "stack expel clears remaining weights and repairs expanded member",
396+ tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{
397+ weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3),
398+ }, Expanded: "b"}, 7), Focus: "b"},
399+ target: "b",
400+ dir: Right,
401+ area: domain.Rect{Width: 101, Height: 4},
402+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
403+ require.Equal(t, Horizontal, tree.Root.Dir)
404+ require.Equal(t, 7.0, tree.Root.Weight)
405+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
406+ stackColumn := tree.Root.Children[0]
407+ require.Equal(t, PaneID("a"), stackColumn.Expanded)
408+ require.Equal(t, []float64{0, 0}, childWeights(stackColumn))
409+ require.Equal(t, 50, placementSpanWidth(after, "a"))
410+ require.Equal(t, 50, placementSpanWidth(after, "c"))
411+ require.Equal(t, 50, placementSpanWidth(after, "b"))
412+ },
413+ },
414+ {
415+ name: "column collapse copies normalized container weight to promoted leaf",
416+ tree: &Tree{Root: horizontal(
417+ weightedLeaf("x", 1),
418+ weightedNode(vertical("a", "b"), 3),
419+ weightedLeaf("y", 1),
420+ ), Focus: "a"},
421+ target: "a",
422+ dir: Left,
423+ area: domain.Rect{Width: 103, Height: 5},
424+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
425+ require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root))
426+ require.Equal(t, Leaf, tree.Root.Children[2].Kind)
427+ require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf)
428+ require.Equal(t, 61.0, tree.Root.Children[2].Weight)
429+ require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root))
430+ require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after))
431+ },
432+ },
433+ {
434+ name: "sole-column expel creates equal default horizontal children and retains wrapper weight",
435+ tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"},
436+ target: "a",
437+ dir: Left,
438+ area: domain.Rect{Width: 101, Height: 5},
439+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
440+ require.Equal(t, Horizontal, tree.Root.Dir)
441+ require.Equal(t, 9.0, tree.Root.Weight)
442+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
443+ require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after))
444+ },
445+ },
446+ }
447+
448+ for _, tt := range tests {
449+ t.Run(tt.name, func(t *testing.T) {
450+ beforeIDs := LeafIDs(tt.tree.Root)
451+ requireUniquePaneIDs(t, beforeIDs)
452+ before, solved := Solve(tt.tree.Root, tt.area)
453+ require.True(t, solved, "weight fixture must solve before surgery")
454+
455+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
456+ require.NoError(t, err)
457+ require.True(t, changed)
458+ require.Equal(t, tt.target, tt.tree.Focus)
459+
460+ afterIDs := LeafIDs(tt.tree.Root)
461+ require.ElementsMatch(t, beforeIDs, afterIDs)
462+ require.Len(t, afterIDs, len(beforeIDs))
463+ requireUniquePaneIDs(t, afterIDs)
464+ after, solved := Solve(tt.tree.Root, tt.area)
465+ require.True(t, solved, "weighted result must solve")
466+ tt.check(t, tt.tree, before, after)
467+ })
468+ }
469+}
470+
471+func childWeights(node *Node) []float64 {
472+ weights := make([]float64, len(node.Children))
473+ for i, child := range node.Children {
474+ weights[i] = child.Weight
475+ }
476+ return weights
477+}
478+
479+func placementSpanWidth(placements []Placement, id PaneID) int {
480+ for _, placement := range placements {
481+ if placement.ID != id {
482+ continue
483+ }
484+ if placement.Content.Width > 0 {
485+ return placement.Content.Width
486+ }
487+ return placement.TitleBar.Width
488+ }
489+ return 0
490+}
491+
326492 func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
327493 t.Parallel()
328494
@@ -323,6 +323,172 @@ func TestWeightSplitStackWrapperTransfersShare(t *testing.T) {
323 }323 }
324 }324 }
325 325
326+func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
327+ t.Parallel()
328+
329+ tests := []struct {
330+ name string
331+ tree *Tree
332+ target PaneID
333+ dir Direction
334+ area domain.Rect
335+ check func(*testing.T, *Tree, []Placement, []Placement)
336+ }{
337+ {
338+ name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight",
339+ tree: &Tree{Root: weightedNode(horizontal(
340+ weightedNode(vertical("a", "b"), 3),
341+ weightedLeaf("c", 1),
342+ ), 11), Focus: "b"},
343+ target: "b",
344+ dir: Right,
345+ area: domain.Rect{Width: 202, Height: 5},
346+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
347+ require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before))
348+ require.Equal(t, 11.0, tree.Root.Weight)
349+ require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root))
350+ require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
351+ require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after))
352+ },
353+ },
354+ {
355+ name: "vertical consume preserves solved member shares and gives moved pane default weight",
356+ tree: &Tree{Root: weightedNode(horizontal(
357+ weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2),
358+ weightedLeaf("c", 1),
359+ ), 9), Focus: "c"},
360+ target: "c",
361+ dir: Left,
362+ area: domain.Rect{Width: 101, Height: 20},
363+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
364+ require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before))
365+ require.Equal(t, Vertical, tree.Root.Dir)
366+ require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share")
367+ require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root))
368+ require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after))
369+ },
370+ },
371+ {
372+ name: "stack consume clears member weights and preserves normalized outer column share",
373+ tree: &Tree{Root: horizontal(
374+ weightedLeaf("x", 1),
375+ weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3),
376+ weightedLeaf("c", 1),
377+ ), Focus: "c"},
378+ target: "c",
379+ dir: Left,
380+ area: domain.Rect{Width: 103, Height: 4},
381+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
382+ require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before))
383+ require.Len(t, tree.Root.Children, 2)
384+ stackColumn := tree.Root.Children[1]
385+ require.Equal(t, Stack, stackColumn.Kind)
386+ require.Equal(t, 61.0, stackColumn.Weight)
387+ require.Equal(t, PaneID("c"), stackColumn.Expanded)
388+ require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn))
389+ require.Equal(t, 77, placementSpanWidth(after, "a"))
390+ require.Equal(t, 77, placementSpanWidth(after, "b"))
391+ require.Equal(t, 77, placementSpanWidth(after, "c"))
392+ },
393+ },
394+ {
395+ name: "stack expel clears remaining weights and repairs expanded member",
396+ tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{
397+ weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3),
398+ }, Expanded: "b"}, 7), Focus: "b"},
399+ target: "b",
400+ dir: Right,
401+ area: domain.Rect{Width: 101, Height: 4},
402+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
403+ require.Equal(t, Horizontal, tree.Root.Dir)
404+ require.Equal(t, 7.0, tree.Root.Weight)
405+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
406+ stackColumn := tree.Root.Children[0]
407+ require.Equal(t, PaneID("a"), stackColumn.Expanded)
408+ require.Equal(t, []float64{0, 0}, childWeights(stackColumn))
409+ require.Equal(t, 50, placementSpanWidth(after, "a"))
410+ require.Equal(t, 50, placementSpanWidth(after, "c"))
411+ require.Equal(t, 50, placementSpanWidth(after, "b"))
412+ },
413+ },
414+ {
415+ name: "column collapse copies normalized container weight to promoted leaf",
416+ tree: &Tree{Root: horizontal(
417+ weightedLeaf("x", 1),
418+ weightedNode(vertical("a", "b"), 3),
419+ weightedLeaf("y", 1),
420+ ), Focus: "a"},
421+ target: "a",
422+ dir: Left,
423+ area: domain.Rect{Width: 103, Height: 5},
424+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
425+ require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root))
426+ require.Equal(t, Leaf, tree.Root.Children[2].Kind)
427+ require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf)
428+ require.Equal(t, 61.0, tree.Root.Children[2].Weight)
429+ require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root))
430+ require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after))
431+ },
432+ },
433+ {
434+ name: "sole-column expel creates equal default horizontal children and retains wrapper weight",
435+ tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"},
436+ target: "a",
437+ dir: Left,
438+ area: domain.Rect{Width: 101, Height: 5},
439+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
440+ require.Equal(t, Horizontal, tree.Root.Dir)
441+ require.Equal(t, 9.0, tree.Root.Weight)
442+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
443+ require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after))
444+ },
445+ },
446+ }
447+
448+ for _, tt := range tests {
449+ t.Run(tt.name, func(t *testing.T) {
450+ beforeIDs := LeafIDs(tt.tree.Root)
451+ requireUniquePaneIDs(t, beforeIDs)
452+ before, solved := Solve(tt.tree.Root, tt.area)
453+ require.True(t, solved, "weight fixture must solve before surgery")
454+
455+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
456+ require.NoError(t, err)
457+ require.True(t, changed)
458+ require.Equal(t, tt.target, tt.tree.Focus)
459+
460+ afterIDs := LeafIDs(tt.tree.Root)
461+ require.ElementsMatch(t, beforeIDs, afterIDs)
462+ require.Len(t, afterIDs, len(beforeIDs))
463+ requireUniquePaneIDs(t, afterIDs)
464+ after, solved := Solve(tt.tree.Root, tt.area)
465+ require.True(t, solved, "weighted result must solve")
466+ tt.check(t, tt.tree, before, after)
467+ })
468+ }
469+}
470+
471+func childWeights(node *Node) []float64 {
472+ weights := make([]float64, len(node.Children))
473+ for i, child := range node.Children {
474+ weights[i] = child.Weight
475+ }
476+ return weights
477+}
478+
479+func placementSpanWidth(placements []Placement, id PaneID) int {
480+ for _, placement := range placements {
481+ if placement.ID != id {
482+ continue
483+ }
484+ if placement.Content.Width > 0 {
485+ return placement.Content.Width
486+ }
487+ return placement.TitleBar.Width
488+ }
489+ return 0
490+}
491+
326 func TestWeightCloneCopiesWeightIndependently(t *testing.T) {492 func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
327 t.Parallel()493 t.Parallel()
328 494

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