1package usecase
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "math"
8 "sort"
9 "strings"
10
11 "github.com/bnema/dumber/internal/domain/entity"
12 domainurl "github.com/bnema/dumber/internal/domain/url"
13 "github.com/bnema/dumber/internal/logging"
14)
15
16// SplitDirection indicates the direction of a pane split.
17type SplitDirection string
18
19const (
20 SplitLeft SplitDirection = "left"
21 SplitRight SplitDirection = "right"
22 SplitUp SplitDirection = "up"
23 SplitDown SplitDirection = "down"
24)
25
26// NavigateDirection indicates the direction for focus navigation.
27type NavigateDirection string
28
29const (
30 NavLeft NavigateDirection = "left"
31 NavRight NavigateDirection = "right"
32 NavUp NavigateDirection = "up"
33 NavDown NavigateDirection = "down"
34)
35
36// ResizeDirection indicates the direction for pane resizing.
37type ResizeDirection string
38
39const (
40 ResizeIncreaseLeft ResizeDirection = "increase_left"
41 ResizeIncreaseRight ResizeDirection = "increase_right"
42 ResizeIncreaseUp ResizeDirection = "increase_up"
43 ResizeIncreaseDown ResizeDirection = "increase_down"
44
45 ResizeDecreaseLeft ResizeDirection = "decrease_left"
46 ResizeDecreaseRight ResizeDirection = "decrease_right"
47 ResizeDecreaseUp ResizeDirection = "decrease_up"
48 ResizeDecreaseDown ResizeDirection = "decrease_down"
49
50 ResizeIncrease ResizeDirection = "increase"
51 ResizeDecrease ResizeDirection = "decrease"
52)
53
54var ErrNothingToResize = errors.New("nothing to resize")
55
56type ConsumeOrExpelDirection string
57
58const (
59 ConsumeOrExpelLeft ConsumeOrExpelDirection = "left"
60 ConsumeOrExpelRight ConsumeOrExpelDirection = "right"
61 ConsumeOrExpelUp ConsumeOrExpelDirection = "up"
62 ConsumeOrExpelDown ConsumeOrExpelDirection = "down"
63)
64
65type ConsumeOrExpelResult struct {
66 Action string // "consumed", "expelled", or "none"
67 ErrorMessage string
68}
69
70const consumeOrExpelExpelCycleMarker = "_expel_cycle"
71
72// ManagePanesUseCase handles pane tree operations.
73type ManagePanesUseCase struct {
74 idGenerator IDGenerator
75}
76
77// NewManagePanesUseCase creates a new pane management use case.
78func NewManagePanesUseCase(idGenerator IDGenerator) *ManagePanesUseCase {
79 return &ManagePanesUseCase{
80 idGenerator: idGenerator,
81 }
82}
83
84// SplitPaneInput contains parameters for splitting a pane.
85type SplitPaneInput struct {
86 Workspace *entity.Workspace
87 TargetPane *entity.PaneNode
88 Direction SplitDirection
89 NewPane *entity.Pane // Optional: existing pane to insert (for popups)
90 InitialURL string // URL for new pane (default: about:blank)
91}
92
93// SplitPaneOutput contains the result of a split operation.
94type SplitPaneOutput struct {
95 NewPaneNode *entity.PaneNode
96 ParentNode *entity.PaneNode // New parent container
97 SplitRatio float64 // Always 0.5 for new splits
98}
99
100// Split creates a new pane adjacent to the target pane.
101func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (*SplitPaneOutput, error) {
102 log := logging.FromContext(ctx)
103 log.Debug().
104 Str("direction", string(input.Direction)).
105 Str("target_id", input.TargetPane.ID).
106 Msg("splitting pane")
107
108 if input.Workspace == nil {
109 return nil, fmt.Errorf("workspace is required")
110 }
111 if input.TargetPane == nil {
112 return nil, fmt.Errorf("target pane is required")
113 }
114
115 // If target is inside a stack, split around the entire stack
116 // (i.e., create the new pane as a sibling to the stack, not inside it)
117 // This applies to all directions - we don't want to nest split containers inside stacks.
118 targetNode := input.TargetPane
119 if targetNode.Parent != nil && targetNode.Parent.IsStacked {
120 // Promote to the stack container so split happens outside the stack
121 targetNode = targetNode.Parent
122 log.Debug().Msg("target is inside a stack, splitting around stack container")
123 }
124
125 // Create new pane
126 var newPane *entity.Pane
127 if input.NewPane != nil {
128 newPane = input.NewPane
129 } else {
130 paneID := entity.PaneID(uc.idGenerator())
131 newPane = entity.NewPane(paneID)
132 if input.InitialURL != "" {
133 newPane.URI = domainurl.Normalize(input.InitialURL)
134 } else {
135 newPane.URI = "about:blank"
136 }
137 }
138
139 // Create new pane node
140 newPaneNode := &entity.PaneNode{
141 ID: string(newPane.ID),
142 Pane: newPane,
143 }
144
145 // Determine split orientation
146 var splitDir entity.SplitDirection
147 switch input.Direction {
148 case SplitLeft, SplitRight:
149 splitDir = entity.SplitHorizontal
150 case SplitUp, SplitDown:
151 splitDir = entity.SplitVertical
152 }
153
154 // Create parent container node
155 parentID := uc.idGenerator()
156 parentNode := &entity.PaneNode{
157 ID: parentID,
158 SplitDir: splitDir,
159 SplitRatio: 0.5,
160 Children: make([]*entity.PaneNode, 2),
161 }
162
163 // Arrange children based on direction
164 switch input.Direction {
165 case SplitLeft, SplitUp:
166 // New pane goes first (left/top)
167 parentNode.Children[0] = newPaneNode
168 parentNode.Children[1] = targetNode
169 case SplitRight, SplitDown:
170 // Existing pane stays first, new pane goes second (right/bottom)
171 parentNode.Children[0] = targetNode
172 parentNode.Children[1] = newPaneNode
173 }
174
175 // Update parent references
176 newPaneNode.Parent = parentNode
177 oldParent := targetNode.Parent
178 targetNode.Parent = parentNode
179
180 // Insert parent into tree
181 if oldParent == nil {
182 // Target was root
183 input.Workspace.Root = parentNode
184 } else {
185 // Replace target with parent in old parent's children
186 for i, child := range oldParent.Children {
187 if child == targetNode {
188 oldParent.Children[i] = parentNode
189 break
190 }
191 }
192 parentNode.Parent = oldParent
193 }
194
195 log.Info().
196 Str("new_pane_id", string(newPane.ID)).
197 Str("parent_id", parentID).
198 Str("direction", string(input.Direction)).
199 Msg("pane split completed")
200
201 return &SplitPaneOutput{
202 NewPaneNode: newPaneNode,
203 ParentNode: parentNode,
204 SplitRatio: 0.5,
205 }, nil
206}
207
208// Resize adjusts the nearest applicable split ratio for the given direction.
209// stepPercent is applied per keystroke (e.g. 5.0 means 5%).
210// minPanePercent enforces a minimum size for each side of a split.
211func (uc *ManagePanesUseCase) Resize(
212 ctx context.Context,
213 ws *entity.Workspace,
214 paneNode *entity.PaneNode,
215 dir ResizeDirection,
216 stepPercent float64,
217 minPanePercent float64,
218) error {
219 log := logging.FromContext(ctx)
220 if uc == nil {
221 return fmt.Errorf("manage panes use case is nil")
222 }
223
224 if ws == nil {
225 return fmt.Errorf("workspace is required")
226 }
227 if ws.Root == nil {
228 return ErrNothingToResize
229 }
230 if paneNode == nil {
231 return fmt.Errorf("pane node is required")
232 }
233
234 target := paneNode
235 if target.Parent != nil && target.Parent.IsStacked {
236 target = target.Parent
237 }
238
239 actualDir := dir
240 switch dir {
241 case ResizeIncrease:
242 actualDir = findSmartResizeDirection(target, true)
243 case ResizeDecrease:
244 actualDir = findSmartResizeDirection(target, false)
245 }
246 if actualDir == "" {
247 return ErrNothingToResize
248 }
249
250 axis, ok := axisForResizeDirection(actualDir)
251 if !ok {
252 return ErrNothingToResize
253 }
254
255 splitNode := findNearestSplitForAxis(target, axis)
256 if splitNode == nil {
257 return ErrNothingToResize
258 }
259
260 // We treat resize directions as moving the split divider.
261 // SplitRatio is the proportion allocated to the first child (left/top).
262 // - Moving the divider right/down increases SplitRatio.
263 // - Moving the divider left/up decreases SplitRatio.
264 delta := deltaForDividerMove(actualDir, stepPercent)
265
266 minRatio := minPanePercent / 100.0
267 maxRatio := 1.0 - minRatio
268 oldRatio := splitNode.SplitRatio
269 splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio)
270
271 log.Debug().
272 Str("direction", string(dir)).
273 Str("actual_direction", string(actualDir)).
274 Float64("old_ratio", oldRatio).
275 Float64("new_ratio", splitNode.SplitRatio).
276 Msg("pane resized")
277
278 return nil
279}
280
281type SetSplitRatioInput struct {
282 Workspace *entity.Workspace
283 SplitNodeID string
284 Ratio float64
285 MinPanePercent float64
286}
287
288func (uc *ManagePanesUseCase) SetSplitRatio(ctx context.Context, input SetSplitRatioInput) error {
289 log := logging.FromContext(ctx)
290 if uc == nil {
291 return fmt.Errorf("manage panes use case is nil")
292 }
293 if input.Workspace == nil {
294 return fmt.Errorf("workspace is required")
295 }
296 if input.Workspace.Root == nil {
297 return ErrNothingToResize
298 }
299 if input.SplitNodeID == "" {
300 return fmt.Errorf("split node id is required")
301 }
302
303 var splitNode *entity.PaneNode
304 input.Workspace.Root.Walk(func(node *entity.PaneNode) bool {
305 if node.ID == input.SplitNodeID {
306 splitNode = node
307 return false
308 }
309 return true
310 })
311
312 if splitNode == nil || !splitNode.IsSplit() {
313 return fmt.Errorf("split node not found: %s", input.SplitNodeID)
314 }
315
316 minRatio := input.MinPanePercent / 100.0
317 maxRatio := 1.0 - minRatio
318 oldRatio := splitNode.SplitRatio
319 clamped := clampFloat64(input.Ratio, minRatio, maxRatio)
320 splitNode.SplitRatio = roundSplitRatio(clamped)
321
322 log.Debug().
323 Str("split_node_id", input.SplitNodeID).
324 Float64("old_ratio", oldRatio).
325 Float64("new_ratio", splitNode.SplitRatio).
326 Msg("split ratio set")
327
328 return nil
329}
330
331func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection {
332 splitNode, axis, isStartChild := findNearestSplitForResize(target)
333 if splitNode == nil {
334 return ""
335 }
336
337 // Smart resize means "grow/shrink the active pane".
338 // SplitRatio is the proportion allocated to the first child (left/top).
339 // - If active is first child: grow by increasing ratio, shrink by decreasing.
340 // - If active is second child: grow by decreasing ratio, shrink by increasing.
341 growMeansIncreaseRatio := isStartChild
342 if !growActive {
343 growMeansIncreaseRatio = !growMeansIncreaseRatio
344 }
345
346 switch axis {
347 case resizeAxisHorizontal:
348 if growMeansIncreaseRatio {
349 return ResizeIncreaseRight
350 }
351 return ResizeIncreaseLeft
352 case resizeAxisVertical:
353 if growMeansIncreaseRatio {
354 return ResizeIncreaseDown
355 }
356 return ResizeIncreaseUp
357 default:
358 return ""
359 }
360}
361
362// findNearestSplitForResize returns the nearest split ancestor for the active pane.
363// It prefers horizontal splits over vertical when both are available at the same depth.
364func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) {
365 current := node
366 for current != nil && current.Parent != nil {
367 parent := current.Parent
368 if parent.IsSplit() {
369 isStartChild = parent.Left() == current
370 switch parent.SplitDir {
371 case entity.SplitHorizontal:
372 return parent, resizeAxisHorizontal, isStartChild
373 case entity.SplitVertical:
374 return parent, resizeAxisVertical, isStartChild
375 }
376 }
377 current = parent
378 }
379 return nil, resizeAxisNone, false
380}
381
382type resizeAxis int
383
384const (
385 resizeAxisNone resizeAxis = iota
386 resizeAxisHorizontal
387 resizeAxisVertical
388)
389
390func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) {
391 switch dir {
392 case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight:
393 return resizeAxisHorizontal, true
394 case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown:
395 return resizeAxisVertical, true
396 default:
397 return resizeAxisNone, false
398 }
399}
400
401func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 {
402 if stepPercent < 0 {
403 stepPercent = -stepPercent
404 }
405 delta := stepPercent / 100.0
406
407 switch dir {
408 case ResizeIncreaseRight, ResizeIncreaseDown:
409 return delta
410 case ResizeIncreaseLeft, ResizeIncreaseUp:
411 return -delta
412 case ResizeDecreaseRight, ResizeDecreaseDown:
413 return -delta
414 case ResizeDecreaseLeft, ResizeDecreaseUp:
415 return delta
416 default:
417 return 0
418 }
419}
420
421// findNearestSplitForAxis walks up the tree to find the nearest split matching the axis.
422func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode {
423 current := node
424 for current != nil && current.Parent != nil {
425 parent := current.Parent
426 if parent.IsSplit() {
427 if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal {
428 return parent
429 }
430 if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical {
431 return parent
432 }
433 }
434 current = parent
435 }
436 return nil
437}
438
439const splitRatioRoundFactor = 100.0
440
441func roundSplitRatio(ratio float64) float64 {
442 // Keep snapshots stable and readable; avoids persisting noisy float values.
443 return math.Round(ratio*splitRatioRoundFactor) / splitRatioRoundFactor
444}
445
446func clampFloat64(v, minVal, maxVal float64) float64 {
447 if v < minVal {
448 return minVal
449 }
450 if v > maxVal {
451 return maxVal
452 }
453 return v
454}
455
456// Close removes a pane and promotes its sibling.
457// Returns the sibling node that was promoted, or nil if this was the last pane.
458// For stacked panes, removes the pane from the stack via RemoveFromStack.
459func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
460 log := logging.FromContext(ctx)
461 if uc == nil {
462 return nil, fmt.Errorf("manage panes use case is nil")
463 }
464 log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane")
465
466 if ws == nil {
467 return nil, fmt.Errorf("workspace is required")
468 }
469 if paneNode == nil {
470 return nil, fmt.Errorf("pane node is required")
471 }
472 if !paneNode.IsLeaf() {
473 return nil, fmt.Errorf("cannot close non-leaf node")
474 }
475
476 parent := paneNode.Parent
477
478 // If no parent, this is the root (last pane)
479 if parent == nil {
480 log.Info().Msg("closing last pane in workspace")
481 ws.Root = nil
482 ws.ActivePaneID = ""
483 return nil, nil
484 }
485
486 // Handle stacked panes differently - they can have more than 2 children
487 if parent.IsStacked {
488 return uc.closeStackedPane(ctx, ws, paneNode, parent)
489 }
490
491 // Binary split: find sibling (the other child of parent)
492 var sibling *entity.PaneNode
493 for _, child := range parent.Children {
494 if child != paneNode {
495 sibling = child
496 break
497 }
498 }
499
500 if sibling == nil {
501 return nil, fmt.Errorf("no sibling found for pane")
502 }
503
504 grandparent := parent.Parent
505
506 // Promote sibling to parent's position
507 if grandparent == nil {
508 // Parent was root, sibling becomes new root
509 ws.Root = sibling
510 sibling.Parent = nil
511 } else {
512 // Replace parent with sibling in grandparent
513 for i, child := range grandparent.Children {
514 if child == parent {
515 grandparent.Children[i] = sibling
516 break
517 }
518 }
519 sibling.Parent = grandparent
520 }
521
522 // Update active pane if needed
523 if ws.ActivePaneID == paneNode.Pane.ID {
524 // Find a new active pane from the sibling subtree
525 var newActive *entity.Pane
526 sibling.Walk(func(node *entity.PaneNode) bool {
527 if node.IsLeaf() && node.Pane != nil {
528 newActive = node.Pane
529 return false // Stop walking
530 }
531 return true
532 })
533 if newActive != nil {
534 ws.ActivePaneID = newActive.ID
535 } else {
536 ws.ActivePaneID = ""
537 }
538 }
539
540 log.Info().
541 Str("closed_pane_id", paneNode.ID).
542 Str("promoted_sibling_id", sibling.ID).
543 Msg("pane closed, sibling promoted")
544
545 return sibling, nil
546}
547
548// closeStackedPane handles closing a pane within a stack.
549// If multiple panes remain, removes the pane from the stack.
550// If only one pane would remain, dissolves the stack.
551func (uc *ManagePanesUseCase) closeStackedPane(
552 ctx context.Context,
553 ws *entity.Workspace,
554 paneNode *entity.PaneNode,
555 stackNode *entity.PaneNode,
556) (*entity.PaneNode, error) {
557 log := logging.FromContext(ctx)
558
559 paneID := paneNode.Pane.ID
560
561 // If more than 2 panes in stack, just remove this one
562 if len(stackNode.Children) > 2 {
563 if err := uc.RemoveFromStack(ctx, stackNode, paneID); err != nil {
564 return nil, fmt.Errorf("failed to remove pane from stack: %w", err)
565 }
566
567 // Update active pane if we closed the active one
568 if ws.ActivePaneID == paneID {
569 // Use the pane at the current active index
570 if stackNode.ActiveStackIndex >= 0 && stackNode.ActiveStackIndex < len(stackNode.Children) {
571 activeChild := stackNode.Children[stackNode.ActiveStackIndex]
572 if activeChild.Pane != nil {
573 ws.ActivePaneID = activeChild.Pane.ID
574 }
575 }
576 }
577
578 log.Info().
579 Str("pane_id", string(paneID)).
580 Int("remaining", len(stackNode.Children)).
581 Msg("pane removed from stack")
582
583 return stackNode, nil
584 }
585
586 // If exactly 2 panes, remove this one and dissolve the stack
587 if len(stackNode.Children) == 2 {
588 // Find the remaining pane
589 var remaining *entity.PaneNode
590 for _, child := range stackNode.Children {
591 if child.Pane != nil && child.Pane.ID != paneID {
592 remaining = child
593 break
594 }
595 }
596
597 if remaining == nil {
598 return nil, fmt.Errorf("no remaining pane found in stack")
599 }
600
601 // Dissolve the stack: promote remaining pane to stack's position
602 grandparent := stackNode.Parent
603
604 if grandparent == nil {
605 // Stack was root, remaining becomes new root
606 ws.Root = remaining
607 remaining.Parent = nil
608 } else {
609 // Replace stack with remaining in grandparent
610 for i, child := range grandparent.Children {
611 if child == stackNode {
612 grandparent.Children[i] = remaining
613 break
614 }
615 }
616 remaining.Parent = grandparent
617 }
618
619 // Update active pane if we closed the active one
620 if ws.ActivePaneID == paneID && remaining.Pane != nil {
621 ws.ActivePaneID = remaining.Pane.ID
622 }
623
624 log.Info().
625 Str("closed_pane_id", string(paneID)).
626 Str("remaining_pane_id", remaining.ID).
627 Msg("stack dissolved, remaining pane promoted")
628
629 return remaining, nil
630 }
631
632 // Should not happen: stack with less than 2 children
633 return nil, fmt.Errorf("invalid stack: has %d children", len(stackNode.Children))
634}
635
636// Focus sets the active pane in the workspace.
637// Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
638func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
639 log := logging.FromContext(ctx)
640 log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
641
642 _, err := uc.ApplyFocusChange(ctx, ws, paneID)
643 return err
644}
645
646// NavigateFocus moves focus to an adjacent pane.
647// Returns the newly focused pane, or nil if navigation not possible.
648func (uc *ManagePanesUseCase) NavigateFocus(
649 ctx context.Context,
650 ws *entity.Workspace,
651 direction NavigateDirection,
652) (*entity.PaneNode, error) {
653 log := logging.FromContext(ctx)
654 log.Debug().Str("direction", string(direction)).Msg("navigating focus")
655
656 if ws == nil {
657 return nil, fmt.Errorf("workspace is required")
658 }
659
660 activeNode := ws.ActivePane()
661 if activeNode == nil {
662 return nil, fmt.Errorf("no active pane")
663 }
664
665 // Try structural navigation first
666 targetNode := uc.findAdjacentPane(activeNode, direction)
667
668 if targetNode == nil {
669 log.Debug().Msg("no adjacent pane found")
670 return nil, nil
671 }
672
673 ws.ActivePaneID = targetNode.Pane.ID
674
675 log.Info().
676 Str("from", activeNode.ID).
677 Str("to", targetNode.ID).
678 Str("direction", string(direction)).
679 Msg("focus navigated")
680
681 return targetNode, nil
682}
683
684// GeometricNavigationInput contains data for geometric focus navigation.
685type GeometricNavigationInput struct {
686 ActivePaneID entity.PaneID
687 PaneRects []entity.PaneRect // All visible panes with their positions
688 Direction NavigateDirection
689}
690
691// GeometricNavigationOutput contains the result.
692type GeometricNavigationOutput struct {
693 TargetPaneID entity.PaneID
694 Found bool
695}
696
697// ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack.
698// This is the canonical way to change focus to a pane, ensuring stack state is kept in sync.
699func (uc *ManagePanesUseCase) ApplyFocusChange(
700 ctx context.Context,
701 ws *entity.Workspace,
702 targetPaneID entity.PaneID,
703) (*entity.PaneNode, error) {
704 log := logging.FromContext(ctx)
705 if uc == nil {
706 return nil, fmt.Errorf("manage panes use case is nil")
707 }
708 if ws == nil {
709 return nil, fmt.Errorf("workspace is required")
710 }
711
712 targetNode := ws.FindPane(targetPaneID)
713 if targetNode == nil {
714 return nil, fmt.Errorf("pane not found: %s", targetPaneID)
715 }
716
717 oldActive := ws.ActivePaneID
718 ws.ActivePaneID = targetPaneID
719
720 // Update stack index if target is in a stack
721 if targetNode.Parent != nil && targetNode.Parent.IsStacked {
722 oldIndex := targetNode.Parent.ActiveStackIndex
723 for i, child := range targetNode.Parent.Children {
724 if child == targetNode {
725 targetNode.Parent.ActiveStackIndex = i
726 log.Debug().
727 Str("target_pane_id", string(targetPaneID)).
728 Int("old_stack_index", oldIndex).
729 Int("new_stack_index", i).
730 Int("num_children", len(targetNode.Parent.Children)).
731 Msg("updated stack index for focus change")
732 break
733 }
734 }
735 }
736
737 log.Info().
738 Str("from", string(oldActive)).
739 Str("to", string(targetPaneID)).
740 Msg("focus changed")
741
742 return targetNode, nil
743}
744
745// NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
746// Algorithm:
747// 1. Get active pane rectangle
748// 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
749// 3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down)
750// 4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance
751// 5. Return lowest scoring candidate
752func (uc *ManagePanesUseCase) NavigateFocusGeometric(
753 ctx context.Context,
754 input GeometricNavigationInput,
755) (*GeometricNavigationOutput, error) {
756 log := logging.FromContext(ctx)
757 if uc == nil {
758 return nil, fmt.Errorf("manage panes use case is nil")
759 }
760 log.Debug().
761 Str("direction", string(input.Direction)).
762 Str("active", string(input.ActivePaneID)).
763 Int("candidates", len(input.PaneRects)).
764 Msg("geometric navigation")
765
766 // Find active pane rect
767 var activeRect *entity.PaneRect
768 for i := range input.PaneRects {
769 if input.PaneRects[i].PaneID == input.ActivePaneID {
770 activeRect = &input.PaneRects[i]
771 break
772 }
773 }
774 if activeRect == nil {
775 log.Debug().Msg("active pane rect not found")
776 return &GeometricNavigationOutput{Found: false}, nil
777 }
778
779 candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction)
780
781 if len(candidates) == 0 {
782 log.Debug().Msg("no candidates in direction")
783 return &GeometricNavigationOutput{Found: false}, nil
784 }
785
786 // Sort by score (lowest first)
787 sort.Slice(candidates, func(i, j int) bool {
788 return candidates[i].score < candidates[j].score
789 })
790
791 log.Debug().
792 Str("target", string(candidates[0].paneID)).
793 Int("score", candidates[0].score).
794 Msg("geometric navigation found target")
795
796 return &GeometricNavigationOutput{
797 TargetPaneID: candidates[0].paneID,
798 Found: true,
799 }, nil
800}
801
802// navCandidate represents a pane candidate for navigation with its score.
803type navCandidate struct {
804 paneID entity.PaneID
805 score int
806}
807
808// scoreNavigationCandidates scores all panes in the given direction from activeRect.
809// Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down).
810func scoreNavigationCandidates(
811 activeRect entity.PaneRect,
812 paneRects []entity.PaneRect,
813 direction NavigateDirection,
814) []navCandidate {
815 // Large penalty for panes without perpendicular overlap.
816 // This ensures panes at the same level are always preferred.
817 const noOverlapPenalty = 10_000_000
818
819 acx, acy := activeRect.Center()
820 var candidates []navCandidate
821
822 for _, rect := range paneRects {
823 if rect.PaneID == activeRect.PaneID {
824 continue
825 }
826
827 cx, cy := rect.Center()
828 dx := cx - acx
829 dy := cy - acy
830
831 inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction)
832
833 if inDirection {
834 score := primaryDist*1000 + perpDist
835 if !hasOverlap {
836 score += noOverlapPenalty
837 }
838 candidates = append(candidates, navCandidate{rect.PaneID, score})
839 }
840 }
841
842 return candidates
843}
844
845// evalDirection determines if a candidate rect is in the given direction from activeRect.
846// Returns: inDirection, primaryDist, perpDist, hasOverlap
847func evalDirection(
848 activeRect, rect entity.PaneRect,
849 dx, dy int,
850 direction NavigateDirection,
851) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) {
852 switch direction {
853 case NavLeft:
854 return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
855 case NavRight:
856 return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
857 case NavUp:
858 return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
859 case NavDown:
860 return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
861 default:
862 return false, 0, 0, false
863 }
864}
865
866// abs returns the absolute value of an integer.
867func abs(x int) int {
868 if x < 0 {
869 return -x
870 }
871 return x
872}
873
874// findAdjacentPane finds the adjacent pane in the given direction using tree structure.
875func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
876 if node == nil {
877 return nil
878 }
879
880 // Determine if we need to go horizontal or vertical
881 isHorizontal := direction == NavLeft || direction == NavRight
882 isForward := direction == NavRight || direction == NavDown
883
884 // Walk up the tree to find an ancestor with the right split type
885 current := node
886 for current.Parent != nil {
887 parent := current.Parent
888
889 // Check if this parent's split orientation matches our direction
890 parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
891
892 if parentIsHorizontal == isHorizontal {
893 // Find which child we came from
894 childIndex := -1
895 for i, child := range parent.Children {
896 if child == current {
897 childIndex = i
898 break
899 }
900 }
901
902 // Check if we can move in the desired direction
903 var targetIndex int
904 if isForward {
905 targetIndex = childIndex + 1
906 } else {
907 targetIndex = childIndex - 1
908 }
909
910 if targetIndex >= 0 && targetIndex < len(parent.Children) {
911 // Found a valid sibling, descend into it to find a leaf
912 return uc.findLeafInDirection(parent.Children[targetIndex], !isForward)
913 }
914 }
915
916 current = parent
917 }
918
919 return nil
920}
921
922// findLeafInDirection descends into a node to find a leaf pane.
923// If fromEnd is true, prefers the last child; otherwise prefers the first.
924func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd bool) *entity.PaneNode {
925 if node == nil {
926 return nil
927 }
928
929 // If this is a leaf, return it
930 if node.IsLeaf() {
931 return node
932 }
933
934 // If stacked, return active pane
935 if node.IsStacked && len(node.Children) > 0 {
936 activeIdx := node.ActiveStackIndex
937 if activeIdx >= 0 && activeIdx < len(node.Children) {
938 return uc.findLeafInDirection(node.Children[activeIdx], fromEnd)
939 }
940 return uc.findLeafInDirection(node.Children[0], fromEnd)
941 }
942
943 // Descend into children
944 if len(node.Children) > 0 {
945 if fromEnd {
946 return uc.findLeafInDirection(node.Children[len(node.Children)-1], fromEnd)
947 }
948 return uc.findLeafInDirection(node.Children[0], fromEnd)
949 }
950
951 return nil
952}
953
954// CreateStackOutput contains the result of creating a stack from a pane.
955type CreateStackOutput struct {
956 StackNode *entity.PaneNode // The stack container node
957 OriginalNode *entity.PaneNode // The original pane, now a child of the stack
958 NewPaneNode *entity.PaneNode // The new pane added to the stack
959 NewPane *entity.Pane // The new pane entity
960}
961
962// CreateStack converts a pane into a stacked container and adds a new pane.
963// The original pane becomes the first child, a new pane becomes the second child.
964// Returns the stack node and the new pane node for UI operations.
965//
966// Domain tree transformation:
967//
968// Before: parent -> targetNode (leaf with pane)
969// After: parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)]
970//
971// The targetNode keeps its ID but becomes a stack container.
972// This allows the UI layer to update widget mappings incrementally.
973func (uc *ManagePanesUseCase) CreateStack(
974 ctx context.Context,
975 ws *entity.Workspace,
976 paneNode *entity.PaneNode,
977) (*CreateStackOutput, error) {
978 log := logging.FromContext(ctx)
979 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
980
981 if ws == nil {
982 return nil, fmt.Errorf("workspace is required")
983 }
984 if paneNode == nil {
985 return nil, fmt.Errorf("pane node is required")
986 }
987 if !paneNode.IsLeaf() {
988 return nil, fmt.Errorf("can only create stack from leaf pane")
989 }
990 if paneNode.IsStacked {
991 return nil, fmt.Errorf("pane is already stacked")
992 }
993
994 // Save original pane reference
995 originalPane := paneNode.Pane
996 originalPaneID := paneNode.ID
997
998 // Create child node for the original pane
999 originalChildNode := &entity.PaneNode{
1000 ID: originalPaneID + "_0",
1001 Pane: originalPane,
1002 Parent: paneNode,
1003 }
1004
1005 // Create new pane
1006 newPaneID := entity.PaneID(uc.idGenerator())
1007 newPane := entity.NewPane(newPaneID)
1008
1009 // Create child node for the new pane
1010 newChildNode := &entity.PaneNode{
1011 ID: string(newPaneID),
1012 Pane: newPane,
1013 Parent: paneNode,
1014 }
1015
1016 // Convert targetNode to stack container
1017 paneNode.Pane = nil
1018 paneNode.IsStacked = true
1019 paneNode.ActiveStackIndex = 1 // New pane is active
1020 paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode}
1021
1022 // Update workspace active pane
1023 ws.ActivePaneID = newPaneID
1024
1025 log.Info().
1026 Str("stack_id", paneNode.ID).
1027 Str("original_pane", string(originalPane.ID)).
1028 Str("new_pane", string(newPaneID)).
1029 Msg("stack created")
1030
1031 return &CreateStackOutput{
1032 StackNode: paneNode,
1033 OriginalNode: originalChildNode,
1034 NewPaneNode: newChildNode,
1035 NewPane: newPane,
1036 }, nil
1037}
1038
1039// AddToStackOutput contains the result of adding a pane to a stack.
1040type AddToStackOutput struct {
1041 NewPaneNode *entity.PaneNode // The new pane node added to the stack
1042 StackIndex int // Index of the new pane in the stack
1043}
1044
1045// AddToStack adds a new pane to an existing stack.
1046// Optionally pass a pre-created pane, or nil to create a new one.
1047func (uc *ManagePanesUseCase) AddToStack(
1048 ctx context.Context,
1049 ws *entity.Workspace,
1050 stackNode *entity.PaneNode,
1051 pane *entity.Pane,
1052) (*AddToStackOutput, error) {
1053 log := logging.FromContext(ctx)
1054
1055 if ws == nil {
1056 return nil, fmt.Errorf("workspace is required")
1057 }
1058 if stackNode == nil {
1059 return nil, fmt.Errorf("stack node is required")
1060 }
1061 if !stackNode.IsStacked {
1062 return nil, fmt.Errorf("node is not a stack")
1063 }
1064
1065 // Create pane if not provided
1066 if pane == nil {
1067 paneID := entity.PaneID(uc.idGenerator())
1068 pane = entity.NewPane(paneID)
1069 }
1070
1071 log.Debug().
1072 Str("stack_id", stackNode.ID).
1073 Str("pane_id", string(pane.ID)).
1074 Msg("adding pane to stack")
1075
1076 // Create node for the new pane
1077 newNode := &entity.PaneNode{
1078 ID: string(pane.ID),
1079 Pane: pane,
1080 Parent: stackNode,
1081 }
1082
1083 // Insert right after current active pane (not at end)
1084 currentActiveIndex := stackNode.ActiveStackIndex
1085 if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1086 // Clamp to valid range - default to end-1 if invalid
1087 currentActiveIndex = len(stackNode.Children) - 1
1088 if currentActiveIndex < 0 {
1089 currentActiveIndex = -1 // Will result in insertIndex = 0
1090 }
1091 }
1092 insertIndex := currentActiveIndex + 1
1093
1094 // Slice insertion at correct position
1095 stackNode.Children = append(stackNode.Children, nil)
1096 copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1097 stackNode.Children[insertIndex] = newNode
1098 newIndex := insertIndex
1099 stackNode.ActiveStackIndex = newIndex
1100
1101 // Update workspace active pane
1102 ws.ActivePaneID = pane.ID
1103
1104 log.Info().
1105 Str("stack_id", stackNode.ID).
1106 Str("pane_id", string(pane.ID)).
1107 Int("stack_size", len(stackNode.Children)).
1108 Msg("pane added to stack")
1109
1110 return &AddToStackOutput{
1111 NewPaneNode: newNode,
1112 StackIndex: newIndex,
1113 }, nil
1114}
1115
1116// NavigateStack cycles through stacked panes.
1117// direction: NavUp for previous, NavDown for next.
1118//
1119//nolint:revive // receiver required for interface consistency
1120func (uc *ManagePanesUseCase) NavigateStack(
1121 ctx context.Context,
1122 stackNode *entity.PaneNode,
1123 direction NavigateDirection,
1124) (*entity.Pane, error) {
1125 log := logging.FromContext(ctx)
1126
1127 if stackNode == nil {
1128 return nil, fmt.Errorf("stack node is required")
1129 }
1130
1131 log.Debug().
1132 Str("stack_id", stackNode.ID).
1133 Str("direction", string(direction)).
1134 Msg("navigating stack")
1135 if !stackNode.IsStacked {
1136 return nil, fmt.Errorf("node is not a stack")
1137 }
1138 if len(stackNode.Children) == 0 {
1139 return nil, fmt.Errorf("stack is empty")
1140 }
1141
1142 count := len(stackNode.Children)
1143 current := stackNode.ActiveStackIndex
1144
1145 var newIndex int
1146 switch direction {
1147 case NavUp:
1148 newIndex = (current - 1 + count) % count
1149 case NavDown:
1150 newIndex = (current + 1) % count
1151 default:
1152 return nil, fmt.Errorf("invalid stack navigation direction: %s (use up/down)", direction)
1153 }
1154
1155 stackNode.ActiveStackIndex = newIndex
1156
1157 activeChild := stackNode.Children[newIndex]
1158 var activePaneNode *entity.PaneNode
1159 if activeChild.IsLeaf() {
1160 activePaneNode = activeChild
1161 } else {
1162 // Find first leaf in the child
1163 activeChild.Walk(func(n *entity.PaneNode) bool {
1164 if n.IsLeaf() {
1165 activePaneNode = n
1166 return false
1167 }
1168 return true
1169 })
1170 }
1171
1172 if activePaneNode == nil || activePaneNode.Pane == nil {
1173 return nil, fmt.Errorf("no pane found in stack at index %d", newIndex)
1174 }
1175
1176 log.Info().
1177 Str("stack_id", stackNode.ID).
1178 Int("from_index", current).
1179 Int("to_index", newIndex).
1180 Str("pane_id", string(activePaneNode.Pane.ID)).
1181 Msg("stack navigated")
1182
1183 return activePaneNode.Pane, nil
1184}
1185
1186// RemoveFromStack removes a pane from a stack.
1187// If only one pane remains, the stack is dissolved.
1188//
1189//nolint:revive // receiver required for interface consistency
1190func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error {
1191 log := logging.FromContext(ctx)
1192
1193 if stackNode == nil {
1194 return fmt.Errorf("stack node is required")
1195 }
1196
1197 log.Debug().
1198 Str("stack_id", stackNode.ID).
1199 Str("pane_id", string(paneID)).
1200 Msg("removing pane from stack")
1201 if !stackNode.IsStacked {
1202 return fmt.Errorf("node is not a stack")
1203 }
1204
1205 // Find and remove the pane
1206 var removed bool
1207 newChildren := make([]*entity.PaneNode, 0, len(stackNode.Children))
1208 for _, child := range stackNode.Children {
1209 if child.Pane != nil && child.Pane.ID == paneID {
1210 removed = true
1211 continue
1212 }
1213 newChildren = append(newChildren, child)
1214 }
1215
1216 if !removed {
1217 return fmt.Errorf("pane not found in stack: %s", paneID)
1218 }
1219
1220 stackNode.Children = newChildren
1221
1222 // Adjust active index
1223 if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1224 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1225 }
1226 if stackNode.ActiveStackIndex < 0 {
1227 stackNode.ActiveStackIndex = 0
1228 }
1229
1230 log.Info().
1231 Str("stack_id", stackNode.ID).
1232 Str("pane_id", string(paneID)).
1233 Int("remaining", len(stackNode.Children)).
1234 Msg("pane removed from stack")
1235
1236 return nil
1237}
1238
1239func (uc *ManagePanesUseCase) ConsumeOrExpel(
1240 ctx context.Context,
1241 ws *entity.Workspace,
1242 activeNode *entity.PaneNode,
1243 direction ConsumeOrExpelDirection,
1244) (*ConsumeOrExpelResult, error) {
1245 if uc == nil {
1246 return nil, fmt.Errorf("manage panes use case is nil")
1247 }
1248 if ws == nil {
1249 return nil, fmt.Errorf("workspace is required")
1250 }
1251 if ws.Root == nil {
1252 return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1253 }
1254 if activeNode == nil {
1255 return nil, fmt.Errorf("active pane node is required")
1256 }
1257 if activeNode.Pane == nil {
1258 return nil, fmt.Errorf("active pane node has no pane")
1259 }
1260
1261 if ws.PaneCount() <= 1 {
1262 return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1263 }
1264
1265 // Expel if active pane is inside a stack.
1266 if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1267 return uc.expelFromStack(ctx, ws, activeNode, direction)
1268 }
1269
1270 // Consume if active pane is not inside a stack.
1271 return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction)
1272}
1273
1274func (*ManagePanesUseCase) consumeIntoSiblingStack(
1275 ctx context.Context,
1276 ws *entity.Workspace,
1277 activeNode *entity.PaneNode,
1278 direction ConsumeOrExpelDirection,
1279) (*ConsumeOrExpelResult, error) {
1280 log := logging.FromContext(ctx)
1281
1282 sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction)
1283 if sibling == nil {
1284 sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction)
1285 }
1286 if sibling == nil {
1287 return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil
1288 }
1289
1290 // Left/right consume cycles layout for immediate split siblings:
1291 // 1) horizontal split -> vertical split (target on top)
1292 // 2) vertical split -> stack (top then bottom)
1293 if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1294 cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction)
1295 if err != nil {
1296 return nil, err
1297 }
1298 if cycled {
1299 switch cycleAction {
1300 case "vertical_split":
1301 log.Info().
1302 Str("pane_id", activeNode.ID).
1303 Str("sibling_id", sibling.ID).
1304 Msg("pane moved below sibling")
1305 case "stack":
1306 log.Info().
1307 Str("pane_id", activeNode.ID).
1308 Str("sibling_id", sibling.ID).
1309 Msg("split converted to stack")
1310 }
1311 return &ConsumeOrExpelResult{Action: "consumed"}, nil
1312 }
1313 }
1314
1315 moved, err := detachLeafByPromotingSibling(ws, activeNode)
1316 if err != nil {
1317 return nil, err
1318 }
1319 moved.Parent = nil
1320
1321 stackNode := sibling
1322 if stackNode.IsLeaf() {
1323 stackNode = convertLeafToStackContainer(stackNode)
1324 }
1325 if !stackNode.IsStacked {
1326 return nil, fmt.Errorf("target sibling is not stackable")
1327 }
1328
1329 // Insert consumed pane at the bottom/end.
1330 moved.Parent = stackNode
1331 stackNode.Children = append(stackNode.Children, moved)
1332 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1333 ws.ActivePaneID = moved.Pane.ID
1334
1335 log.Info().
1336 Str("pane_id", moved.ID).
1337 Str("stack_id", stackNode.ID).
1338 Int("stack_size", len(stackNode.Children)).
1339 Msg("pane consumed into sibling stack")
1340
1341 return &ConsumeOrExpelResult{Action: "consumed"}, nil
1342}
1343
1344func (uc *ManagePanesUseCase) expelFromStack(
1345 ctx context.Context,
1346 ws *entity.Workspace,
1347 activeNode *entity.PaneNode,
1348 direction ConsumeOrExpelDirection,
1349) (*ConsumeOrExpelResult, error) {
1350 log := logging.FromContext(ctx)
1351
1352 stackNode := activeNode.Parent
1353 if stackNode == nil || !stackNode.IsStacked {
1354 return &ConsumeOrExpelResult{Action: "none"}, nil
1355 }
1356
1357 expelled, err := removeLeafFromStack(stackNode, activeNode)
1358 if err != nil {
1359 return nil, err
1360 }
1361 expelled.Parent = nil
1362
1363 // If only one pane remains, dissolve stack into a leaf node.
1364 if len(stackNode.Children) == 1 {
1365 dissolveStackIntoLeaf(stackNode)
1366 }
1367
1368 // Split around the remaining container (stackNode) with expelled pane.
1369 // For left/right, expel cycles stack -> vertical split first:
1370 // - left: expelled pane above
1371 // - right: expelled pane below
1372 splitDirection := direction
1373 switch direction {
1374 case ConsumeOrExpelLeft:
1375 splitDirection = ConsumeOrExpelUp
1376 case ConsumeOrExpelRight:
1377 splitDirection = ConsumeOrExpelDown
1378 }
1379
1380 if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1381 err := splitExistingNodeWithMarker(
1382 ws,
1383 stackNode,
1384 expelled,
1385 splitDirection,
1386 uc.idGenerator,
1387 consumeOrExpelExpelCycleMarker,
1388 )
1389 if err != nil {
1390 return nil, err
1391 }
1392 } else {
1393 if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil {
1394 return nil, err
1395 }
1396 }
1397 ws.ActivePaneID = expelled.Pane.ID
1398
1399 log.Info().
1400 Str("pane_id", expelled.ID).
1401 Str("direction", string(direction)).
1402 Msg("pane expelled from stack")
1403
1404 return &ConsumeOrExpelResult{Action: "expelled"}, nil
1405}
1406
1407func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string {
1408 switch direction {
1409 case ConsumeOrExpelLeft:
1410 return "No pane to the left"
1411 case ConsumeOrExpelRight:
1412 return "No pane to the right"
1413 case ConsumeOrExpelUp:
1414 return "No pane above"
1415 case ConsumeOrExpelDown:
1416 return "No pane below"
1417 default:
1418 return "No pane in that direction"
1419 }
1420}
1421
1422func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) {
1423 if node == nil {
1424 return nil, ""
1425 }
1426
1427 // Determine if we need to go horizontal or vertical.
1428 isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1429 isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown
1430
1431 // Walk up the tree to find an ancestor split matching our direction axis.
1432 current := node
1433 for current.Parent != nil {
1434 parent := current.Parent
1435 if parent.IsSplit() {
1436 parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
1437 if parentIsHorizontal == isHorizontal {
1438 if isForward {
1439 if parent.Right() != nil && parent.Left() == current {
1440 return parent.Right(), ""
1441 }
1442 } else {
1443 if parent.Left() != nil && parent.Right() == current {
1444 return parent.Left(), ""
1445 }
1446 }
1447 }
1448 }
1449 current = parent
1450 }
1451
1452 return nil, directionNoSiblingMessage(direction)
1453}
1454
1455func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode {
1456 if node == nil || node.Parent == nil {
1457 return nil
1458 }
1459
1460 parent := node.Parent
1461 if !parent.IsSplit() {
1462 return nil
1463 }
1464
1465 axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1466 if axisHorizontal {
1467 if parent.SplitDir == entity.SplitHorizontal {
1468 return nil
1469 }
1470 } else {
1471 if parent.SplitDir == entity.SplitVertical {
1472 return nil
1473 }
1474 }
1475
1476 // Cross-axis fallback is only used to enable the leaf cycling behavior
1477 // (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to
1478 // consume into a stacked/split sibling when pressing an unrelated axis key.
1479 var sibling *entity.PaneNode
1480 if parent.Left() == node {
1481 sibling = parent.Right()
1482 } else if parent.Right() == node {
1483 sibling = parent.Left()
1484 }
1485 if sibling == nil {
1486 return nil
1487 }
1488 if !node.IsLeaf() || !sibling.IsLeaf() {
1489 return nil
1490 }
1491 return sibling
1492}
1493
1494func cycleHorizontalToVerticalThenStack(
1495 ws *entity.Workspace,
1496 activeNode *entity.PaneNode,
1497 sibling *entity.PaneNode,
1498 direction ConsumeOrExpelDirection,
1499) (cycled bool, cycleAction string, err error) {
1500 if ws == nil {
1501 return false, "", fmt.Errorf("workspace is required")
1502 }
1503 if activeNode == nil || sibling == nil {
1504 return false, "", nil
1505 }
1506 if !activeNode.IsLeaf() || !sibling.IsLeaf() {
1507 return false, "", nil
1508 }
1509
1510 parent := activeNode.Parent
1511 if parent == nil || sibling.Parent != parent || !parent.IsSplit() {
1512 return false, "", nil
1513 }
1514
1515 switch parent.SplitDir {
1516 case entity.SplitHorizontal:
1517 // Horizontal -> Vertical: target sibling should be on top.
1518 parent.SplitDir = entity.SplitVertical
1519 if parent.Children[0] != sibling {
1520 parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1521 }
1522 ws.ActivePaneID = activeNode.Pane.ID
1523 return true, "vertical_split", nil
1524 case entity.SplitVertical:
1525 // Vertical -> Horizontal when pressing left/right (reverse of first step).
1526 // This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right.
1527 if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) {
1528 parent.SplitDir = entity.SplitHorizontal
1529 // Active should become the right child.
1530 if parent.Children[1] != activeNode {
1531 parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1532 }
1533 ws.ActivePaneID = activeNode.Pane.ID
1534 return true, "horizontal_split", nil
1535 }
1536
1537 // Otherwise, vertical -> stack.
1538 parent.SplitDir = entity.SplitNone
1539 parent.SplitRatio = 0
1540 parent.IsStacked = true
1541 parent.Pane = nil
1542 if parent.Children[0] == activeNode {
1543 parent.ActiveStackIndex = 0
1544 } else if parent.Children[1] == activeNode {
1545 parent.ActiveStackIndex = 1
1546 } else {
1547 parent.ActiveStackIndex = 0
1548 }
1549 ws.ActivePaneID = activeNode.Pane.ID
1550 return true, "stack", nil
1551 default:
1552 return false, "", nil
1553 }
1554}
1555
1556func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1557 if ws == nil {
1558 return nil, fmt.Errorf("workspace is required")
1559 }
1560 if leaf == nil {
1561 return nil, fmt.Errorf("leaf is required")
1562 }
1563 if !leaf.IsLeaf() {
1564 return nil, fmt.Errorf("can only detach leaf node")
1565 }
1566
1567 parent := leaf.Parent
1568 if parent == nil {
1569 return nil, fmt.Errorf("cannot detach root leaf")
1570 }
1571 if !parent.IsSplit() {
1572 return nil, fmt.Errorf("leaf parent is not a split")
1573 }
1574
1575 var sibling *entity.PaneNode
1576 for _, child := range parent.Children {
1577 if child != leaf {
1578 sibling = child
1579 break
1580 }
1581 }
1582 if sibling == nil {
1583 return nil, fmt.Errorf("no sibling found for leaf")
1584 }
1585
1586 grandparent := parent.Parent
1587 if grandparent == nil {
1588 ws.Root = sibling
1589 sibling.Parent = nil
1590 } else {
1591 for i, child := range grandparent.Children {
1592 if child == parent {
1593 grandparent.Children[i] = sibling
1594 break
1595 }
1596 }
1597 sibling.Parent = grandparent
1598 }
1599
1600 leaf.Parent = nil
1601 return leaf, nil
1602}
1603
1604func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode {
1605 if leaf == nil || !leaf.IsLeaf() {
1606 return leaf
1607 }
1608
1609 originalPane := leaf.Pane
1610 originalChildNode := &entity.PaneNode{
1611 ID: leaf.ID + "_0",
1612 Pane: originalPane,
1613 Parent: leaf,
1614 }
1615
1616 leaf.Pane = nil
1617 leaf.IsStacked = true
1618 leaf.ActiveStackIndex = 0
1619 leaf.Children = []*entity.PaneNode{originalChildNode}
1620
1621 return leaf
1622}
1623
1624func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1625 if stackNode == nil {
1626 return nil, fmt.Errorf("stack node is required")
1627 }
1628 if !stackNode.IsStacked {
1629 return nil, fmt.Errorf("node is not a stack")
1630 }
1631 if leaf == nil || leaf.Pane == nil {
1632 return nil, fmt.Errorf("leaf is required")
1633 }
1634
1635 idx := -1
1636 for i, child := range stackNode.Children {
1637 if child == leaf {
1638 idx = i
1639 break
1640 }
1641 }
1642 if idx < 0 {
1643 return nil, fmt.Errorf("pane not found in stack")
1644 }
1645
1646 stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
1647 if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1648 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1649 }
1650 if stackNode.ActiveStackIndex < 0 {
1651 stackNode.ActiveStackIndex = 0
1652 }
1653
1654 leaf.Parent = nil
1655 return leaf, nil
1656}
1657
1658func dissolveStackIntoLeaf(stackNode *entity.PaneNode) {
1659 if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 {
1660 return
1661 }
1662
1663 remaining := stackNode.Children[0]
1664 stackNode.Pane = remaining.Pane
1665 stackNode.Children = nil
1666 stackNode.IsStacked = false
1667 stackNode.ActiveStackIndex = 0
1668}
1669
1670func splitExistingNode(
1671 ws *entity.Workspace,
1672 targetNode *entity.PaneNode,
1673 newSibling *entity.PaneNode,
1674 direction ConsumeOrExpelDirection,
1675 idGen IDGenerator,
1676) error {
1677 if ws == nil {
1678 return fmt.Errorf("workspace is required")
1679 }
1680 if targetNode == nil {
1681 return fmt.Errorf("target node is required")
1682 }
1683 if newSibling == nil {
1684 return fmt.Errorf("new sibling node is required")
1685 }
1686 if idGen == nil {
1687 return fmt.Errorf("id generator is required")
1688 }
1689
1690 var splitDir entity.SplitDirection
1691 switch direction {
1692 case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1693 splitDir = entity.SplitHorizontal
1694 case ConsumeOrExpelUp, ConsumeOrExpelDown:
1695 splitDir = entity.SplitVertical
1696 default:
1697 return fmt.Errorf("invalid direction: %s", direction)
1698 }
1699
1700 parentNode := &entity.PaneNode{
1701 ID: idGen(),
1702 SplitDir: splitDir,
1703 SplitRatio: 0.5,
1704 Children: make([]*entity.PaneNode, 2),
1705 }
1706
1707 switch direction {
1708 case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1709 parentNode.Children[0] = newSibling
1710 parentNode.Children[1] = targetNode
1711 case ConsumeOrExpelRight, ConsumeOrExpelDown:
1712 parentNode.Children[0] = targetNode
1713 parentNode.Children[1] = newSibling
1714 }
1715
1716 newSibling.Parent = parentNode
1717 oldParent := targetNode.Parent
1718 targetNode.Parent = parentNode
1719
1720 if oldParent == nil {
1721 ws.Root = parentNode
1722 } else {
1723 for i, child := range oldParent.Children {
1724 if child == targetNode {
1725 oldParent.Children[i] = parentNode
1726 break
1727 }
1728 }
1729 parentNode.Parent = oldParent
1730 }
1731
1732 return nil
1733}
1734
1735func splitExistingNodeWithMarker(
1736 ws *entity.Workspace,
1737 targetNode *entity.PaneNode,
1738 newSibling *entity.PaneNode,
1739 direction ConsumeOrExpelDirection,
1740 idGen IDGenerator,
1741 marker string,
1742) error {
1743 if ws == nil {
1744 return fmt.Errorf("workspace is required")
1745 }
1746 if targetNode == nil {
1747 return fmt.Errorf("target node is required")
1748 }
1749 if newSibling == nil {
1750 return fmt.Errorf("new sibling node is required")
1751 }
1752 if idGen == nil {
1753 return fmt.Errorf("id generator is required")
1754 }
1755
1756 var splitDir entity.SplitDirection
1757 switch direction {
1758 case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1759 splitDir = entity.SplitHorizontal
1760 case ConsumeOrExpelUp, ConsumeOrExpelDown:
1761 splitDir = entity.SplitVertical
1762 default:
1763 return fmt.Errorf("invalid direction: %s", direction)
1764 }
1765
1766 parentNode := &entity.PaneNode{
1767 ID: idGen() + marker,
1768 SplitDir: splitDir,
1769 SplitRatio: 0.5,
1770 Children: make([]*entity.PaneNode, 2),
1771 }
1772
1773 switch direction {
1774 case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1775 parentNode.Children[0] = newSibling
1776 parentNode.Children[1] = targetNode
1777 case ConsumeOrExpelRight, ConsumeOrExpelDown:
1778 parentNode.Children[0] = targetNode
1779 parentNode.Children[1] = newSibling
1780 }
1781
1782 newSibling.Parent = parentNode
1783 oldParent := targetNode.Parent
1784 targetNode.Parent = parentNode
1785
1786 if oldParent == nil {
1787 ws.Root = parentNode
1788 } else {
1789 for i, child := range oldParent.Children {
1790 if child == targetNode {
1791 oldParent.Children[i] = parentNode
1792 break
1793 }
1794 }
1795 parentNode.Parent = oldParent
1796 }
1797
1798 return nil
1799}
1800
1801// GetAllPanes returns all leaf panes in a workspace.
1802//
1803//nolint:revive // receiver required for interface consistency
1804func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
1805 if ws == nil || ws.Root == nil {
1806 return nil
1807 }
1808 return ws.AllPanes()
1809}
1810
1811// CountPanes returns the number of panes in a workspace.
1812//
1813//nolint:revive // receiver required for interface consistency
1814func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int {
1815 if ws == nil {
1816 return 0
1817 }
1818 return ws.PaneCount()
1819}