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.
458func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
459 log := logging.FromContext(ctx)
460 if uc == nil {
461 return nil, fmt.Errorf("manage panes use case is nil")
462 }
463 log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane")
464
465 if ws == nil {
466 return nil, fmt.Errorf("workspace is required")
467 }
468 if paneNode == nil {
469 return nil, fmt.Errorf("pane node is required")
470 }
471 if !paneNode.IsLeaf() {
472 return nil, fmt.Errorf("cannot close non-leaf node")
473 }
474
475 parent := paneNode.Parent
476
477 // If no parent, this is the root (last pane)
478 if parent == nil {
479 log.Info().Msg("closing last pane in workspace")
480 ws.Root = nil
481 ws.ActivePaneID = ""
482 return nil, nil
483 }
484
485 // Find sibling
486 var sibling *entity.PaneNode
487 for _, child := range parent.Children {
488 if child != paneNode {
489 sibling = child
490 break
491 }
492 }
493
494 if sibling == nil {
495 return nil, fmt.Errorf("no sibling found for pane")
496 }
497
498 grandparent := parent.Parent
499
500 // Promote sibling to parent's position
501 if grandparent == nil {
502 // Parent was root, sibling becomes new root
503 ws.Root = sibling
504 sibling.Parent = nil
505 } else {
506 // Replace parent with sibling in grandparent
507 for i, child := range grandparent.Children {
508 if child == parent {
509 grandparent.Children[i] = sibling
510 break
511 }
512 }
513 sibling.Parent = grandparent
514 }
515
516 // Update active pane if needed
517 if ws.ActivePaneID == paneNode.Pane.ID {
518 // Find a new active pane from the sibling subtree
519 var newActive *entity.Pane
520 sibling.Walk(func(node *entity.PaneNode) bool {
521 if node.IsLeaf() && node.Pane != nil {
522 newActive = node.Pane
523 return false // Stop walking
524 }
525 return true
526 })
527 if newActive != nil {
528 ws.ActivePaneID = newActive.ID
529 } else {
530 ws.ActivePaneID = ""
531 }
532 }
533
534 log.Info().
535 Str("closed_pane_id", paneNode.ID).
536 Str("promoted_sibling_id", sibling.ID).
537 Msg("pane closed, sibling promoted")
538
539 return sibling, nil
540}
541
542// Focus sets the active pane in the workspace.
543// Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
544func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
545 log := logging.FromContext(ctx)
546 log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
547
548 _, err := uc.ApplyFocusChange(ctx, ws, paneID)
549 return err
550}
551
552// NavigateFocus moves focus to an adjacent pane.
553// Returns the newly focused pane, or nil if navigation not possible.
554func (uc *ManagePanesUseCase) NavigateFocus(
555 ctx context.Context,
556 ws *entity.Workspace,
557 direction NavigateDirection,
558) (*entity.PaneNode, error) {
559 log := logging.FromContext(ctx)
560 log.Debug().Str("direction", string(direction)).Msg("navigating focus")
561
562 if ws == nil {
563 return nil, fmt.Errorf("workspace is required")
564 }
565
566 activeNode := ws.ActivePane()
567 if activeNode == nil {
568 return nil, fmt.Errorf("no active pane")
569 }
570
571 // Try structural navigation first
572 targetNode := uc.findAdjacentPane(activeNode, direction)
573
574 if targetNode == nil {
575 log.Debug().Msg("no adjacent pane found")
576 return nil, nil
577 }
578
579 ws.ActivePaneID = targetNode.Pane.ID
580
581 log.Info().
582 Str("from", activeNode.ID).
583 Str("to", targetNode.ID).
584 Str("direction", string(direction)).
585 Msg("focus navigated")
586
587 return targetNode, nil
588}
589
590// GeometricNavigationInput contains data for geometric focus navigation.
591type GeometricNavigationInput struct {
592 ActivePaneID entity.PaneID
593 PaneRects []entity.PaneRect // All visible panes with their positions
594 Direction NavigateDirection
595}
596
597// GeometricNavigationOutput contains the result.
598type GeometricNavigationOutput struct {
599 TargetPaneID entity.PaneID
600 Found bool
601}
602
603// ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack.
604// This is the canonical way to change focus to a pane, ensuring stack state is kept in sync.
605func (uc *ManagePanesUseCase) ApplyFocusChange(
606 ctx context.Context,
607 ws *entity.Workspace,
608 targetPaneID entity.PaneID,
609) (*entity.PaneNode, error) {
610 log := logging.FromContext(ctx)
611 if uc == nil {
612 return nil, fmt.Errorf("manage panes use case is nil")
613 }
614 if ws == nil {
615 return nil, fmt.Errorf("workspace is required")
616 }
617
618 targetNode := ws.FindPane(targetPaneID)
619 if targetNode == nil {
620 return nil, fmt.Errorf("pane not found: %s", targetPaneID)
621 }
622
623 oldActive := ws.ActivePaneID
624 ws.ActivePaneID = targetPaneID
625
626 // Update stack index if target is in a stack
627 if targetNode.Parent != nil && targetNode.Parent.IsStacked {
628 oldIndex := targetNode.Parent.ActiveStackIndex
629 for i, child := range targetNode.Parent.Children {
630 if child == targetNode {
631 targetNode.Parent.ActiveStackIndex = i
632 log.Debug().
633 Str("target_pane_id", string(targetPaneID)).
634 Int("old_stack_index", oldIndex).
635 Int("new_stack_index", i).
636 Int("num_children", len(targetNode.Parent.Children)).
637 Msg("updated stack index for focus change")
638 break
639 }
640 }
641 }
642
643 log.Info().
644 Str("from", string(oldActive)).
645 Str("to", string(targetPaneID)).
646 Msg("focus changed")
647
648 return targetNode, nil
649}
650
651// NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
652// Algorithm:
653// 1. Get active pane rectangle
654// 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
655// 3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down)
656// 4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance
657// 5. Return lowest scoring candidate
658func (uc *ManagePanesUseCase) NavigateFocusGeometric(
659 ctx context.Context,
660 input GeometricNavigationInput,
661) (*GeometricNavigationOutput, error) {
662 log := logging.FromContext(ctx)
663 if uc == nil {
664 return nil, fmt.Errorf("manage panes use case is nil")
665 }
666 log.Debug().
667 Str("direction", string(input.Direction)).
668 Str("active", string(input.ActivePaneID)).
669 Int("candidates", len(input.PaneRects)).
670 Msg("geometric navigation")
671
672 // Find active pane rect
673 var activeRect *entity.PaneRect
674 for i := range input.PaneRects {
675 if input.PaneRects[i].PaneID == input.ActivePaneID {
676 activeRect = &input.PaneRects[i]
677 break
678 }
679 }
680 if activeRect == nil {
681 log.Debug().Msg("active pane rect not found")
682 return &GeometricNavigationOutput{Found: false}, nil
683 }
684
685 candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction)
686
687 if len(candidates) == 0 {
688 log.Debug().Msg("no candidates in direction")
689 return &GeometricNavigationOutput{Found: false}, nil
690 }
691
692 // Sort by score (lowest first)
693 sort.Slice(candidates, func(i, j int) bool {
694 return candidates[i].score < candidates[j].score
695 })
696
697 log.Debug().
698 Str("target", string(candidates[0].paneID)).
699 Int("score", candidates[0].score).
700 Msg("geometric navigation found target")
701
702 return &GeometricNavigationOutput{
703 TargetPaneID: candidates[0].paneID,
704 Found: true,
705 }, nil
706}
707
708// navCandidate represents a pane candidate for navigation with its score.
709type navCandidate struct {
710 paneID entity.PaneID
711 score int
712}
713
714// scoreNavigationCandidates scores all panes in the given direction from activeRect.
715// Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down).
716func scoreNavigationCandidates(
717 activeRect entity.PaneRect,
718 paneRects []entity.PaneRect,
719 direction NavigateDirection,
720) []navCandidate {
721 // Large penalty for panes without perpendicular overlap.
722 // This ensures panes at the same level are always preferred.
723 const noOverlapPenalty = 10_000_000
724
725 acx, acy := activeRect.Center()
726 var candidates []navCandidate
727
728 for _, rect := range paneRects {
729 if rect.PaneID == activeRect.PaneID {
730 continue
731 }
732
733 cx, cy := rect.Center()
734 dx := cx - acx
735 dy := cy - acy
736
737 inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction)
738
739 if inDirection {
740 score := primaryDist*1000 + perpDist
741 if !hasOverlap {
742 score += noOverlapPenalty
743 }
744 candidates = append(candidates, navCandidate{rect.PaneID, score})
745 }
746 }
747
748 return candidates
749}
750
751// evalDirection determines if a candidate rect is in the given direction from activeRect.
752// Returns: inDirection, primaryDist, perpDist, hasOverlap
753func evalDirection(
754 activeRect, rect entity.PaneRect,
755 dx, dy int,
756 direction NavigateDirection,
757) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) {
758 switch direction {
759 case NavLeft:
760 return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
761 case NavRight:
762 return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
763 case NavUp:
764 return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
765 case NavDown:
766 return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
767 default:
768 return false, 0, 0, false
769 }
770}
771
772// abs returns the absolute value of an integer.
773func abs(x int) int {
774 if x < 0 {
775 return -x
776 }
777 return x
778}
779
780// findAdjacentPane finds the adjacent pane in the given direction using tree structure.
781func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
782 if node == nil {
783 return nil
784 }
785
786 // Determine if we need to go horizontal or vertical
787 isHorizontal := direction == NavLeft || direction == NavRight
788 isForward := direction == NavRight || direction == NavDown
789
790 // Walk up the tree to find an ancestor with the right split type
791 current := node
792 for current.Parent != nil {
793 parent := current.Parent
794
795 // Check if this parent's split orientation matches our direction
796 parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
797
798 if parentIsHorizontal == isHorizontal {
799 // Find which child we came from
800 childIndex := -1
801 for i, child := range parent.Children {
802 if child == current {
803 childIndex = i
804 break
805 }
806 }
807
808 // Check if we can move in the desired direction
809 var targetIndex int
810 if isForward {
811 targetIndex = childIndex + 1
812 } else {
813 targetIndex = childIndex - 1
814 }
815
816 if targetIndex >= 0 && targetIndex < len(parent.Children) {
817 // Found a valid sibling, descend into it to find a leaf
818 return uc.findLeafInDirection(parent.Children[targetIndex], !isForward)
819 }
820 }
821
822 current = parent
823 }
824
825 return nil
826}
827
828// findLeafInDirection descends into a node to find a leaf pane.
829// If fromEnd is true, prefers the last child; otherwise prefers the first.
830func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd bool) *entity.PaneNode {
831 if node == nil {
832 return nil
833 }
834
835 // If this is a leaf, return it
836 if node.IsLeaf() {
837 return node
838 }
839
840 // If stacked, return active pane
841 if node.IsStacked && len(node.Children) > 0 {
842 activeIdx := node.ActiveStackIndex
843 if activeIdx >= 0 && activeIdx < len(node.Children) {
844 return uc.findLeafInDirection(node.Children[activeIdx], fromEnd)
845 }
846 return uc.findLeafInDirection(node.Children[0], fromEnd)
847 }
848
849 // Descend into children
850 if len(node.Children) > 0 {
851 if fromEnd {
852 return uc.findLeafInDirection(node.Children[len(node.Children)-1], fromEnd)
853 }
854 return uc.findLeafInDirection(node.Children[0], fromEnd)
855 }
856
857 return nil
858}
859
860// CreateStackOutput contains the result of creating a stack from a pane.
861type CreateStackOutput struct {
862 StackNode *entity.PaneNode // The stack container node
863 OriginalNode *entity.PaneNode // The original pane, now a child of the stack
864 NewPaneNode *entity.PaneNode // The new pane added to the stack
865 NewPane *entity.Pane // The new pane entity
866}
867
868// CreateStack converts a pane into a stacked container and adds a new pane.
869// The original pane becomes the first child, a new pane becomes the second child.
870// Returns the stack node and the new pane node for UI operations.
871//
872// Domain tree transformation:
873//
874// Before: parent -> targetNode (leaf with pane)
875// After: parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)]
876//
877// The targetNode keeps its ID but becomes a stack container.
878// This allows the UI layer to update widget mappings incrementally.
879func (uc *ManagePanesUseCase) CreateStack(
880 ctx context.Context,
881 ws *entity.Workspace,
882 paneNode *entity.PaneNode,
883) (*CreateStackOutput, error) {
884 log := logging.FromContext(ctx)
885 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
886
887 if ws == nil {
888 return nil, fmt.Errorf("workspace is required")
889 }
890 if paneNode == nil {
891 return nil, fmt.Errorf("pane node is required")
892 }
893 if !paneNode.IsLeaf() {
894 return nil, fmt.Errorf("can only create stack from leaf pane")
895 }
896 if paneNode.IsStacked {
897 return nil, fmt.Errorf("pane is already stacked")
898 }
899
900 // Save original pane reference
901 originalPane := paneNode.Pane
902 originalPaneID := paneNode.ID
903
904 // Create child node for the original pane
905 originalChildNode := &entity.PaneNode{
906 ID: originalPaneID + "_0",
907 Pane: originalPane,
908 Parent: paneNode,
909 }
910
911 // Create new pane
912 newPaneID := entity.PaneID(uc.idGenerator())
913 newPane := entity.NewPane(newPaneID)
914
915 // Create child node for the new pane
916 newChildNode := &entity.PaneNode{
917 ID: string(newPaneID),
918 Pane: newPane,
919 Parent: paneNode,
920 }
921
922 // Convert targetNode to stack container
923 paneNode.Pane = nil
924 paneNode.IsStacked = true
925 paneNode.ActiveStackIndex = 1 // New pane is active
926 paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode}
927
928 // Update workspace active pane
929 ws.ActivePaneID = newPaneID
930
931 log.Info().
932 Str("stack_id", paneNode.ID).
933 Str("original_pane", string(originalPane.ID)).
934 Str("new_pane", string(newPaneID)).
935 Msg("stack created")
936
937 return &CreateStackOutput{
938 StackNode: paneNode,
939 OriginalNode: originalChildNode,
940 NewPaneNode: newChildNode,
941 NewPane: newPane,
942 }, nil
943}
944
945// AddToStackOutput contains the result of adding a pane to a stack.
946type AddToStackOutput struct {
947 NewPaneNode *entity.PaneNode // The new pane node added to the stack
948 StackIndex int // Index of the new pane in the stack
949}
950
951// AddToStack adds a new pane to an existing stack.
952// Optionally pass a pre-created pane, or nil to create a new one.
953func (uc *ManagePanesUseCase) AddToStack(
954 ctx context.Context,
955 ws *entity.Workspace,
956 stackNode *entity.PaneNode,
957 pane *entity.Pane,
958) (*AddToStackOutput, error) {
959 log := logging.FromContext(ctx)
960
961 if ws == nil {
962 return nil, fmt.Errorf("workspace is required")
963 }
964 if stackNode == nil {
965 return nil, fmt.Errorf("stack node is required")
966 }
967 if !stackNode.IsStacked {
968 return nil, fmt.Errorf("node is not a stack")
969 }
970
971 // Create pane if not provided
972 if pane == nil {
973 paneID := entity.PaneID(uc.idGenerator())
974 pane = entity.NewPane(paneID)
975 }
976
977 log.Debug().
978 Str("stack_id", stackNode.ID).
979 Str("pane_id", string(pane.ID)).
980 Msg("adding pane to stack")
981
982 // Create node for the new pane
983 newNode := &entity.PaneNode{
984 ID: string(pane.ID),
985 Pane: pane,
986 Parent: stackNode,
987 }
988
989 // Insert right after current active pane (not at end)
990 currentActiveIndex := stackNode.ActiveStackIndex
991 if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
992 // Clamp to valid range - default to end-1 if invalid
993 currentActiveIndex = len(stackNode.Children) - 1
994 if currentActiveIndex < 0 {
995 currentActiveIndex = -1 // Will result in insertIndex = 0
996 }
997 }
998 insertIndex := currentActiveIndex + 1
999
1000 // Slice insertion at correct position
1001 stackNode.Children = append(stackNode.Children, nil)
1002 copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1003 stackNode.Children[insertIndex] = newNode
1004 newIndex := insertIndex
1005 stackNode.ActiveStackIndex = newIndex
1006
1007 // Update workspace active pane
1008 ws.ActivePaneID = pane.ID
1009
1010 log.Info().
1011 Str("stack_id", stackNode.ID).
1012 Str("pane_id", string(pane.ID)).
1013 Int("stack_size", len(stackNode.Children)).
1014 Msg("pane added to stack")
1015
1016 return &AddToStackOutput{
1017 NewPaneNode: newNode,
1018 StackIndex: newIndex,
1019 }, nil
1020}
1021
1022// NavigateStack cycles through stacked panes.
1023// direction: NavUp for previous, NavDown for next.
1024//
1025//nolint:revive // receiver required for interface consistency
1026func (uc *ManagePanesUseCase) NavigateStack(
1027 ctx context.Context,
1028 stackNode *entity.PaneNode,
1029 direction NavigateDirection,
1030) (*entity.Pane, error) {
1031 log := logging.FromContext(ctx)
1032
1033 if stackNode == nil {
1034 return nil, fmt.Errorf("stack node is required")
1035 }
1036
1037 log.Debug().
1038 Str("stack_id", stackNode.ID).
1039 Str("direction", string(direction)).
1040 Msg("navigating stack")
1041 if !stackNode.IsStacked {
1042 return nil, fmt.Errorf("node is not a stack")
1043 }
1044 if len(stackNode.Children) == 0 {
1045 return nil, fmt.Errorf("stack is empty")
1046 }
1047
1048 count := len(stackNode.Children)
1049 current := stackNode.ActiveStackIndex
1050
1051 var newIndex int
1052 switch direction {
1053 case NavUp:
1054 newIndex = (current - 1 + count) % count
1055 case NavDown:
1056 newIndex = (current + 1) % count
1057 default:
1058 return nil, fmt.Errorf("invalid stack navigation direction: %s (use up/down)", direction)
1059 }
1060
1061 stackNode.ActiveStackIndex = newIndex
1062
1063 activeChild := stackNode.Children[newIndex]
1064 var activePaneNode *entity.PaneNode
1065 if activeChild.IsLeaf() {
1066 activePaneNode = activeChild
1067 } else {
1068 // Find first leaf in the child
1069 activeChild.Walk(func(n *entity.PaneNode) bool {
1070 if n.IsLeaf() {
1071 activePaneNode = n
1072 return false
1073 }
1074 return true
1075 })
1076 }
1077
1078 if activePaneNode == nil || activePaneNode.Pane == nil {
1079 return nil, fmt.Errorf("no pane found in stack at index %d", newIndex)
1080 }
1081
1082 log.Info().
1083 Str("stack_id", stackNode.ID).
1084 Int("from_index", current).
1085 Int("to_index", newIndex).
1086 Str("pane_id", string(activePaneNode.Pane.ID)).
1087 Msg("stack navigated")
1088
1089 return activePaneNode.Pane, nil
1090}
1091
1092// RemoveFromStack removes a pane from a stack.
1093// If only one pane remains, the stack is dissolved.
1094//
1095//nolint:revive // receiver required for interface consistency
1096func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error {
1097 log := logging.FromContext(ctx)
1098
1099 if stackNode == nil {
1100 return fmt.Errorf("stack node is required")
1101 }
1102
1103 log.Debug().
1104 Str("stack_id", stackNode.ID).
1105 Str("pane_id", string(paneID)).
1106 Msg("removing pane from stack")
1107 if !stackNode.IsStacked {
1108 return fmt.Errorf("node is not a stack")
1109 }
1110
1111 // Find and remove the pane
1112 var removed bool
1113 newChildren := make([]*entity.PaneNode, 0, len(stackNode.Children))
1114 for _, child := range stackNode.Children {
1115 if child.Pane != nil && child.Pane.ID == paneID {
1116 removed = true
1117 continue
1118 }
1119 newChildren = append(newChildren, child)
1120 }
1121
1122 if !removed {
1123 return fmt.Errorf("pane not found in stack: %s", paneID)
1124 }
1125
1126 stackNode.Children = newChildren
1127
1128 // Adjust active index
1129 if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1130 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1131 }
1132 if stackNode.ActiveStackIndex < 0 {
1133 stackNode.ActiveStackIndex = 0
1134 }
1135
1136 log.Info().
1137 Str("stack_id", stackNode.ID).
1138 Str("pane_id", string(paneID)).
1139 Int("remaining", len(stackNode.Children)).
1140 Msg("pane removed from stack")
1141
1142 return nil
1143}
1144
1145func (uc *ManagePanesUseCase) ConsumeOrExpel(
1146 ctx context.Context,
1147 ws *entity.Workspace,
1148 activeNode *entity.PaneNode,
1149 direction ConsumeOrExpelDirection,
1150) (*ConsumeOrExpelResult, error) {
1151 if uc == nil {
1152 return nil, fmt.Errorf("manage panes use case is nil")
1153 }
1154 if ws == nil {
1155 return nil, fmt.Errorf("workspace is required")
1156 }
1157 if ws.Root == nil {
1158 return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1159 }
1160 if activeNode == nil {
1161 return nil, fmt.Errorf("active pane node is required")
1162 }
1163 if activeNode.Pane == nil {
1164 return nil, fmt.Errorf("active pane node has no pane")
1165 }
1166
1167 if ws.PaneCount() <= 1 {
1168 return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1169 }
1170
1171 // Expel if active pane is inside a stack.
1172 if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1173 return uc.expelFromStack(ctx, ws, activeNode, direction)
1174 }
1175
1176 // Consume if active pane is not inside a stack.
1177 return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction)
1178}
1179
1180func (*ManagePanesUseCase) consumeIntoSiblingStack(
1181 ctx context.Context,
1182 ws *entity.Workspace,
1183 activeNode *entity.PaneNode,
1184 direction ConsumeOrExpelDirection,
1185) (*ConsumeOrExpelResult, error) {
1186 log := logging.FromContext(ctx)
1187
1188 sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction)
1189 if sibling == nil {
1190 sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction)
1191 }
1192 if sibling == nil {
1193 return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil
1194 }
1195
1196 // Left/right consume cycles layout for immediate split siblings:
1197 // 1) horizontal split -> vertical split (target on top)
1198 // 2) vertical split -> stack (top then bottom)
1199 if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1200 cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction)
1201 if err != nil {
1202 return nil, err
1203 }
1204 if cycled {
1205 switch cycleAction {
1206 case "vertical_split":
1207 log.Info().
1208 Str("pane_id", activeNode.ID).
1209 Str("sibling_id", sibling.ID).
1210 Msg("pane moved below sibling")
1211 case "stack":
1212 log.Info().
1213 Str("pane_id", activeNode.ID).
1214 Str("sibling_id", sibling.ID).
1215 Msg("split converted to stack")
1216 }
1217 return &ConsumeOrExpelResult{Action: "consumed"}, nil
1218 }
1219 }
1220
1221 moved, err := detachLeafByPromotingSibling(ws, activeNode)
1222 if err != nil {
1223 return nil, err
1224 }
1225 moved.Parent = nil
1226
1227 stackNode := sibling
1228 if stackNode.IsLeaf() {
1229 stackNode = convertLeafToStackContainer(stackNode)
1230 }
1231 if !stackNode.IsStacked {
1232 return nil, fmt.Errorf("target sibling is not stackable")
1233 }
1234
1235 // Insert consumed pane at the bottom/end.
1236 moved.Parent = stackNode
1237 stackNode.Children = append(stackNode.Children, moved)
1238 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1239 ws.ActivePaneID = moved.Pane.ID
1240
1241 log.Info().
1242 Str("pane_id", moved.ID).
1243 Str("stack_id", stackNode.ID).
1244 Int("stack_size", len(stackNode.Children)).
1245 Msg("pane consumed into sibling stack")
1246
1247 return &ConsumeOrExpelResult{Action: "consumed"}, nil
1248}
1249
1250func (uc *ManagePanesUseCase) expelFromStack(
1251 ctx context.Context,
1252 ws *entity.Workspace,
1253 activeNode *entity.PaneNode,
1254 direction ConsumeOrExpelDirection,
1255) (*ConsumeOrExpelResult, error) {
1256 log := logging.FromContext(ctx)
1257
1258 stackNode := activeNode.Parent
1259 if stackNode == nil || !stackNode.IsStacked {
1260 return &ConsumeOrExpelResult{Action: "none"}, nil
1261 }
1262
1263 expelled, err := removeLeafFromStack(stackNode, activeNode)
1264 if err != nil {
1265 return nil, err
1266 }
1267 expelled.Parent = nil
1268
1269 // If only one pane remains, dissolve stack into a leaf node.
1270 if len(stackNode.Children) == 1 {
1271 dissolveStackIntoLeaf(stackNode)
1272 }
1273
1274 // Split around the remaining container (stackNode) with expelled pane.
1275 // For left/right, expel cycles stack -> vertical split first:
1276 // - left: expelled pane above
1277 // - right: expelled pane below
1278 splitDirection := direction
1279 switch direction {
1280 case ConsumeOrExpelLeft:
1281 splitDirection = ConsumeOrExpelUp
1282 case ConsumeOrExpelRight:
1283 splitDirection = ConsumeOrExpelDown
1284 }
1285
1286 if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1287 err := splitExistingNodeWithMarker(
1288 ws,
1289 stackNode,
1290 expelled,
1291 splitDirection,
1292 uc.idGenerator,
1293 consumeOrExpelExpelCycleMarker,
1294 )
1295 if err != nil {
1296 return nil, err
1297 }
1298 } else {
1299 if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil {
1300 return nil, err
1301 }
1302 }
1303 ws.ActivePaneID = expelled.Pane.ID
1304
1305 log.Info().
1306 Str("pane_id", expelled.ID).
1307 Str("direction", string(direction)).
1308 Msg("pane expelled from stack")
1309
1310 return &ConsumeOrExpelResult{Action: "expelled"}, nil
1311}
1312
1313func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string {
1314 switch direction {
1315 case ConsumeOrExpelLeft:
1316 return "No pane to the left"
1317 case ConsumeOrExpelRight:
1318 return "No pane to the right"
1319 case ConsumeOrExpelUp:
1320 return "No pane above"
1321 case ConsumeOrExpelDown:
1322 return "No pane below"
1323 default:
1324 return "No pane in that direction"
1325 }
1326}
1327
1328func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) {
1329 if node == nil {
1330 return nil, ""
1331 }
1332
1333 // Determine if we need to go horizontal or vertical.
1334 isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1335 isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown
1336
1337 // Walk up the tree to find an ancestor split matching our direction axis.
1338 current := node
1339 for current.Parent != nil {
1340 parent := current.Parent
1341 if parent.IsSplit() {
1342 parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
1343 if parentIsHorizontal == isHorizontal {
1344 if isForward {
1345 if parent.Right() != nil && parent.Left() == current {
1346 return parent.Right(), ""
1347 }
1348 } else {
1349 if parent.Left() != nil && parent.Right() == current {
1350 return parent.Left(), ""
1351 }
1352 }
1353 }
1354 }
1355 current = parent
1356 }
1357
1358 return nil, directionNoSiblingMessage(direction)
1359}
1360
1361func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode {
1362 if node == nil || node.Parent == nil {
1363 return nil
1364 }
1365
1366 parent := node.Parent
1367 if !parent.IsSplit() {
1368 return nil
1369 }
1370
1371 axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1372 if axisHorizontal {
1373 if parent.SplitDir == entity.SplitHorizontal {
1374 return nil
1375 }
1376 } else {
1377 if parent.SplitDir == entity.SplitVertical {
1378 return nil
1379 }
1380 }
1381
1382 // Cross-axis fallback is only used to enable the leaf cycling behavior
1383 // (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to
1384 // consume into a stacked/split sibling when pressing an unrelated axis key.
1385 var sibling *entity.PaneNode
1386 if parent.Left() == node {
1387 sibling = parent.Right()
1388 } else if parent.Right() == node {
1389 sibling = parent.Left()
1390 }
1391 if sibling == nil {
1392 return nil
1393 }
1394 if !node.IsLeaf() || !sibling.IsLeaf() {
1395 return nil
1396 }
1397 return sibling
1398}
1399
1400func cycleHorizontalToVerticalThenStack(
1401 ws *entity.Workspace,
1402 activeNode *entity.PaneNode,
1403 sibling *entity.PaneNode,
1404 direction ConsumeOrExpelDirection,
1405) (cycled bool, cycleAction string, err error) {
1406 if ws == nil {
1407 return false, "", fmt.Errorf("workspace is required")
1408 }
1409 if activeNode == nil || sibling == nil {
1410 return false, "", nil
1411 }
1412 if !activeNode.IsLeaf() || !sibling.IsLeaf() {
1413 return false, "", nil
1414 }
1415
1416 parent := activeNode.Parent
1417 if parent == nil || sibling.Parent != parent || !parent.IsSplit() {
1418 return false, "", nil
1419 }
1420
1421 switch parent.SplitDir {
1422 case entity.SplitHorizontal:
1423 // Horizontal -> Vertical: target sibling should be on top.
1424 parent.SplitDir = entity.SplitVertical
1425 if parent.Children[0] != sibling {
1426 parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1427 }
1428 ws.ActivePaneID = activeNode.Pane.ID
1429 return true, "vertical_split", nil
1430 case entity.SplitVertical:
1431 // Vertical -> Horizontal when pressing left/right (reverse of first step).
1432 // This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right.
1433 if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) {
1434 parent.SplitDir = entity.SplitHorizontal
1435 // Active should become the right child.
1436 if parent.Children[1] != activeNode {
1437 parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1438 }
1439 ws.ActivePaneID = activeNode.Pane.ID
1440 return true, "horizontal_split", nil
1441 }
1442
1443 // Otherwise, vertical -> stack.
1444 parent.SplitDir = entity.SplitNone
1445 parent.SplitRatio = 0
1446 parent.IsStacked = true
1447 parent.Pane = nil
1448 if parent.Children[0] == activeNode {
1449 parent.ActiveStackIndex = 0
1450 } else if parent.Children[1] == activeNode {
1451 parent.ActiveStackIndex = 1
1452 } else {
1453 parent.ActiveStackIndex = 0
1454 }
1455 ws.ActivePaneID = activeNode.Pane.ID
1456 return true, "stack", nil
1457 default:
1458 return false, "", nil
1459 }
1460}
1461
1462func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1463 if ws == nil {
1464 return nil, fmt.Errorf("workspace is required")
1465 }
1466 if leaf == nil {
1467 return nil, fmt.Errorf("leaf is required")
1468 }
1469 if !leaf.IsLeaf() {
1470 return nil, fmt.Errorf("can only detach leaf node")
1471 }
1472
1473 parent := leaf.Parent
1474 if parent == nil {
1475 return nil, fmt.Errorf("cannot detach root leaf")
1476 }
1477 if !parent.IsSplit() {
1478 return nil, fmt.Errorf("leaf parent is not a split")
1479 }
1480
1481 var sibling *entity.PaneNode
1482 for _, child := range parent.Children {
1483 if child != leaf {
1484 sibling = child
1485 break
1486 }
1487 }
1488 if sibling == nil {
1489 return nil, fmt.Errorf("no sibling found for leaf")
1490 }
1491
1492 grandparent := parent.Parent
1493 if grandparent == nil {
1494 ws.Root = sibling
1495 sibling.Parent = nil
1496 } else {
1497 for i, child := range grandparent.Children {
1498 if child == parent {
1499 grandparent.Children[i] = sibling
1500 break
1501 }
1502 }
1503 sibling.Parent = grandparent
1504 }
1505
1506 leaf.Parent = nil
1507 return leaf, nil
1508}
1509
1510func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode {
1511 if leaf == nil || !leaf.IsLeaf() {
1512 return leaf
1513 }
1514
1515 originalPane := leaf.Pane
1516 originalChildNode := &entity.PaneNode{
1517 ID: leaf.ID + "_0",
1518 Pane: originalPane,
1519 Parent: leaf,
1520 }
1521
1522 leaf.Pane = nil
1523 leaf.IsStacked = true
1524 leaf.ActiveStackIndex = 0
1525 leaf.Children = []*entity.PaneNode{originalChildNode}
1526
1527 return leaf
1528}
1529
1530func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1531 if stackNode == nil {
1532 return nil, fmt.Errorf("stack node is required")
1533 }
1534 if !stackNode.IsStacked {
1535 return nil, fmt.Errorf("node is not a stack")
1536 }
1537 if leaf == nil || leaf.Pane == nil {
1538 return nil, fmt.Errorf("leaf is required")
1539 }
1540
1541 idx := -1
1542 for i, child := range stackNode.Children {
1543 if child == leaf {
1544 idx = i
1545 break
1546 }
1547 }
1548 if idx < 0 {
1549 return nil, fmt.Errorf("pane not found in stack")
1550 }
1551
1552 stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
1553 if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1554 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1555 }
1556 if stackNode.ActiveStackIndex < 0 {
1557 stackNode.ActiveStackIndex = 0
1558 }
1559
1560 leaf.Parent = nil
1561 return leaf, nil
1562}
1563
1564func dissolveStackIntoLeaf(stackNode *entity.PaneNode) {
1565 if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 {
1566 return
1567 }
1568
1569 remaining := stackNode.Children[0]
1570 stackNode.Pane = remaining.Pane
1571 stackNode.Children = nil
1572 stackNode.IsStacked = false
1573 stackNode.ActiveStackIndex = 0
1574}
1575
1576func splitExistingNode(
1577 ws *entity.Workspace,
1578 targetNode *entity.PaneNode,
1579 newSibling *entity.PaneNode,
1580 direction ConsumeOrExpelDirection,
1581 idGen IDGenerator,
1582) error {
1583 if ws == nil {
1584 return fmt.Errorf("workspace is required")
1585 }
1586 if targetNode == nil {
1587 return fmt.Errorf("target node is required")
1588 }
1589 if newSibling == nil {
1590 return fmt.Errorf("new sibling node is required")
1591 }
1592 if idGen == nil {
1593 return fmt.Errorf("id generator is required")
1594 }
1595
1596 var splitDir entity.SplitDirection
1597 switch direction {
1598 case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1599 splitDir = entity.SplitHorizontal
1600 case ConsumeOrExpelUp, ConsumeOrExpelDown:
1601 splitDir = entity.SplitVertical
1602 default:
1603 return fmt.Errorf("invalid direction: %s", direction)
1604 }
1605
1606 parentNode := &entity.PaneNode{
1607 ID: idGen(),
1608 SplitDir: splitDir,
1609 SplitRatio: 0.5,
1610 Children: make([]*entity.PaneNode, 2),
1611 }
1612
1613 switch direction {
1614 case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1615 parentNode.Children[0] = newSibling
1616 parentNode.Children[1] = targetNode
1617 case ConsumeOrExpelRight, ConsumeOrExpelDown:
1618 parentNode.Children[0] = targetNode
1619 parentNode.Children[1] = newSibling
1620 }
1621
1622 newSibling.Parent = parentNode
1623 oldParent := targetNode.Parent
1624 targetNode.Parent = parentNode
1625
1626 if oldParent == nil {
1627 ws.Root = parentNode
1628 } else {
1629 for i, child := range oldParent.Children {
1630 if child == targetNode {
1631 oldParent.Children[i] = parentNode
1632 break
1633 }
1634 }
1635 parentNode.Parent = oldParent
1636 }
1637
1638 return nil
1639}
1640
1641func splitExistingNodeWithMarker(
1642 ws *entity.Workspace,
1643 targetNode *entity.PaneNode,
1644 newSibling *entity.PaneNode,
1645 direction ConsumeOrExpelDirection,
1646 idGen IDGenerator,
1647 marker string,
1648) error {
1649 if ws == nil {
1650 return fmt.Errorf("workspace is required")
1651 }
1652 if targetNode == nil {
1653 return fmt.Errorf("target node is required")
1654 }
1655 if newSibling == nil {
1656 return fmt.Errorf("new sibling node is required")
1657 }
1658 if idGen == nil {
1659 return fmt.Errorf("id generator is required")
1660 }
1661
1662 var splitDir entity.SplitDirection
1663 switch direction {
1664 case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1665 splitDir = entity.SplitHorizontal
1666 case ConsumeOrExpelUp, ConsumeOrExpelDown:
1667 splitDir = entity.SplitVertical
1668 default:
1669 return fmt.Errorf("invalid direction: %s", direction)
1670 }
1671
1672 parentNode := &entity.PaneNode{
1673 ID: idGen() + marker,
1674 SplitDir: splitDir,
1675 SplitRatio: 0.5,
1676 Children: make([]*entity.PaneNode, 2),
1677 }
1678
1679 switch direction {
1680 case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1681 parentNode.Children[0] = newSibling
1682 parentNode.Children[1] = targetNode
1683 case ConsumeOrExpelRight, ConsumeOrExpelDown:
1684 parentNode.Children[0] = targetNode
1685 parentNode.Children[1] = newSibling
1686 }
1687
1688 newSibling.Parent = parentNode
1689 oldParent := targetNode.Parent
1690 targetNode.Parent = parentNode
1691
1692 if oldParent == nil {
1693 ws.Root = parentNode
1694 } else {
1695 for i, child := range oldParent.Children {
1696 if child == targetNode {
1697 oldParent.Children[i] = parentNode
1698 break
1699 }
1700 }
1701 parentNode.Parent = oldParent
1702 }
1703
1704 return nil
1705}
1706
1707// GetAllPanes returns all leaf panes in a workspace.
1708//
1709//nolint:revive // receiver required for interface consistency
1710func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
1711 if ws == nil || ws.Root == nil {
1712 return nil
1713 }
1714 return ws.AllPanes()
1715}
1716
1717// CountPanes returns the number of panes in a workspace.
1718//
1719//nolint:revive // receiver required for interface consistency
1720func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int {
1721 if ws == nil {
1722 return 0
1723 }
1724 return ws.PaneCount()
1725}