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