1package layout
2
3import (
4 "errors"
5
6 "github.com/bnema/vev/internal/domain"
7)
8
9var (
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.
17func (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, _, 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
66func 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
92func 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
102func 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
137func 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
148func 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// fairShareWeight returns the mean effective sibling weight so an inserted
166// node lands with an equal share while siblings keep their proportions.
167func fairShareWeight(siblings []*Node) float64 {
168 if len(siblings) == 0 {
169 return 0
170 }
171 total := 0.0
172 for _, sibling := range siblings {
173 total += effectiveWeight(sibling.Weight)
174 }
175 return total / float64(len(siblings))
176}
177
178func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) {
179 root := t.Root
180 destinationIndex := sourceIndex - 1
181 if dir == Right {
182 destinationIndex = sourceIndex + 1
183 }
184
185 columnAreas, _ := splitChildRects(root, area)
186 moved := root.Children[sourceIndex]
187 moved.Weight = 0
188 destination := root.Children[destinationIndex]
189
190 switch destination.Kind {
191 case Leaf:
192 weight := destination.Weight
193 destination.Weight = 0
194 destination = &Node{
195 Kind: Split,
196 Dir: Vertical,
197 Children: []*Node{destination, moved},
198 Weight: weight,
199 }
200 case Split:
201 normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex])
202 moved.Weight = fairShareWeight(destination.Children)
203 destination.Children = append(destination.Children, moved)
204 case Stack:
205 destination.Children = append(destination.Children, moved)
206 destination.Expanded = moved.Leaf
207 clearChildWeights(destination)
208 }
209 root.Children[destinationIndex] = destination
210 root.Children = removeNodeAt(root.Children, sourceIndex)
211
212 if len(root.Children) == 1 {
213 promoted := root.Children[0]
214 promoted.Weight = root.Weight
215 t.Root = promoted
216 }
217}
218
219func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) {
220 root := t.Root
221 rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal
222 columns := canonicalColumns(root)
223 source := columns[columnIndex]
224 wrapperWeight := source.Weight
225 if !rootIsHorizontal {
226 wrapperWeight = root.Weight
227 }
228
229 moved := source.Children[memberIndex]
230 moved.Weight = 0
231 source.Children = removeNodeAt(source.Children, memberIndex)
232 if source.Kind == Stack {
233 if source.Expanded == moved.Leaf {
234 source.Expanded = source.Children[0].Leaf
235 }
236 clearChildWeights(source)
237 }
238
239 remaining := source
240 if len(source.Children) == 1 {
241 remaining = source.Children[0]
242 remaining.Weight = source.Weight
243 }
244
245 if !rootIsHorizontal {
246 remaining.Weight = 0
247 children := []*Node{remaining, moved}
248 if dir == Left {
249 children[0], children[1] = children[1], children[0]
250 }
251 t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight}
252 return
253 }
254
255 root.Children[columnIndex] = remaining
256 moved.Weight = fairShareWeight(root.Children)
257 insertionIndex := columnIndex
258 if dir == Right {
259 insertionIndex++
260 }
261 root.Children = insertNodeAt(root.Children, insertionIndex, moved)
262}
263
264func removeNodeAt(nodes []*Node, index int) []*Node {
265 copy(nodes[index:], nodes[index+1:])
266 nodes[len(nodes)-1] = nil
267 return nodes[:len(nodes)-1]
268}
269
270func insertNodeAt(nodes []*Node, index int, node *Node) []*Node {
271 nodes = append(nodes, nil)
272 copy(nodes[index+1:], nodes[index:])
273 nodes[index] = node
274 return nodes
275}