1// Package focus provides focus state management and geometric navigation for panes.
2package focus
3
4import (
5 "context"
6
7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/dumber/internal/ui/layout"
11)
12
13// PaneGeometryProvider provides geometry information for panes.
14// This interface allows the focus manager to work without direct component dependencies.
15type PaneGeometryProvider interface {
16 // GetPaneIDs returns all pane IDs in the provider.
17 GetPaneIDs() []entity.PaneID
18 // GetPaneWidget returns the widget for a pane, or nil if not found.
19 GetPaneWidget(paneID entity.PaneID) layout.Widget
20 // GetStackContainerWidget returns the stack container widget for a stacked pane.
21 // Returns nil if the pane is not in a stack.
22 GetStackContainerWidget(paneID entity.PaneID) layout.Widget
23 // ContainerWidget returns the container widget for relative positioning.
24 ContainerWidget() layout.Widget
25}
26
27// Manager handles focus state and geometric navigation.
28type Manager struct {
29 panesUC *usecase.ManagePanesUseCase
30}
31
32// NewManager creates a focus manager.
33func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager {
34 return &Manager{panesUC: panesUC}
35}
36
37// CollectPaneRects gathers geometry from all visible panes.
38// For panes in stacks, uses the stack container's geometry since individual
39// collapsed panes have no allocated size.
40// Only visible panes are included - for stacked panes, only the active one is visible.
41func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {
42 var rects []entity.PaneRect
43
44 container := provider.ContainerWidget()
45
46 for _, paneID := range provider.GetPaneIDs() {
47 paneWidget := provider.GetPaneWidget(paneID)
48
49 // Skip panes that aren't visible (includes inactive stacked panes)
50 if paneWidget == nil || !paneWidget.IsVisible() {
51 continue
52 }
53
54 // For stacked panes, use stack container geometry (pane itself has no size)
55 var widget layout.Widget
56 if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil {
57 widget = stackWidget
58 } else {
59 widget = paneWidget
60 }
61
62 if widget == nil || !widget.IsVisible() {
63 continue
64 }
65
66 // Get position relative to container
67 x, y, ok := widget.ComputePoint(container)
68 if !ok {
69 continue
70 }
71
72 w := widget.GetAllocatedWidth()
73 h := widget.GetAllocatedHeight()
74
75 // Skip widgets with no size (collapsed/hidden)
76 if w <= 0 || h <= 0 {
77 continue
78 }
79
80 rects = append(rects, entity.PaneRect{
81 PaneID: paneID,
82 X: int(x),
83 Y: int(y),
84 W: w,
85 H: h,
86 })
87 }
88
89 return rects
90}
91
92// NavigateGeometric performs geometric navigation and returns the target pane.
93// It handles stack navigation internally: when navigating up/down and the current
94// pane is in a stack, it will try to navigate within the stack first before
95// escaping to external panes.
96func (m *Manager) NavigateGeometric(
97 ctx context.Context,
98 ws *entity.Workspace,
99 provider PaneGeometryProvider,
100 direction usecase.NavigateDirection,
101) (*entity.PaneNode, error) {
102 log := logging.FromContext(ctx)
103
104 if ws == nil {
105 return nil, nil
106 }
107
108 // Get active pane node
109 activeNode := ws.ActivePane()
110 if activeNode == nil {
111 log.Debug().Msg("no active pane for navigation")
112 return nil, nil
113 }
114
115 // Check if we should navigate within a stack first
116 if direction == usecase.NavUp || direction == usecase.NavDown {
117 if activeNode.Parent != nil && activeNode.Parent.IsStacked {
118 // Try to navigate within the stack
119 stackNode := activeNode.Parent
120 canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction)
121 if canNavigate {
122 ws.ActivePaneID = newPaneID
123 return ws.FindPane(newPaneID), nil
124 }
125 // At stack boundary - fall through to geometric navigation
126 log.Debug().Msg("at stack boundary, using geometric navigation")
127 }
128 }
129
130 // Collect geometry from visible panes
131 rects := m.CollectPaneRects(provider)
132 if len(rects) == 0 {
133 return nil, nil
134 }
135
136 // Run geometric algorithm
137 output, err := m.panesUC.NavigateFocusGeometric(ctx, usecase.GeometricNavigationInput{
138 ActivePaneID: ws.ActivePaneID,
139 PaneRects: rects,
140 Direction: direction,
141 })
142 if err != nil {
143 return nil, err
144 }
145 if !output.Found {
146 return nil, nil
147 }
148
149 // Update workspace
150 ws.ActivePaneID = output.TargetPaneID
151
152 // Return target node
153 return ws.FindPane(output.TargetPaneID), nil
154}
155
156// navigateWithinStack tries to navigate within a stack.
157// Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
158// boundary and navigation should escape the stack.
159func (m *Manager) navigateWithinStack(
160 stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection,
161) (bool, entity.PaneID) {
162 if !stackNode.IsStacked || len(stackNode.Children) == 0 {
163 return false, ""
164 }
165
166 currentIdx := m.findCurrentStackIndex(stackNode, currentNode)
167 if currentIdx < 0 {
168 return false, ""
169 }
170
171 newIdx, ok := m.calculateNewStackIndex(currentIdx, len(stackNode.Children), direction)
172 if !ok {
173 return false, ""
174 }
175
176 stackNode.ActiveStackIndex = newIdx
177 return m.getPaneIDFromStackChild(stackNode.Children[newIdx])
178}
179
180// findCurrentStackIndex returns the current pane's index in the stack.
181// Uses ActiveStackIndex from domain model, with fallback to pointer search.
182func (*Manager) findCurrentStackIndex(stackNode, currentNode *entity.PaneNode) int {
183 idx := stackNode.ActiveStackIndex
184 if idx >= 0 && idx < len(stackNode.Children) {
185 return idx
186 }
187 // Fallback to pointer search if ActiveStackIndex is invalid
188 for i, child := range stackNode.Children {
189 if child == currentNode {
190 return i
191 }
192 }
193 return -1
194}
195
196// calculateNewStackIndex computes the new index after navigation.
197// Returns (newIndex, ok) where ok is false if at a boundary.
198func (*Manager) calculateNewStackIndex(
199 currentIdx, childCount int, direction usecase.NavigateDirection,
200) (int, bool) {
201 switch direction {
202 case usecase.NavUp:
203 if currentIdx <= 0 {
204 return 0, false
205 }
206 return currentIdx - 1, true
207 case usecase.NavDown:
208 if currentIdx >= childCount-1 {
209 return 0, false
210 }
211 return currentIdx + 1, true
212 default:
213 return 0, false
214 }
215}
216
217// getPaneIDFromStackChild extracts the pane ID from a stack child node.
218// If the child is not a leaf, finds the first leaf pane.
219func (*Manager) getPaneIDFromStackChild(targetNode *entity.PaneNode) (bool, entity.PaneID) {
220 if targetNode.Pane != nil {
221 return true, targetNode.Pane.ID
222 }
223
224 var leafPaneID entity.PaneID
225 targetNode.Walk(func(n *entity.PaneNode) bool {
226 if n.IsLeaf() && n.Pane != nil {
227 leafPaneID = n.Pane.ID
228 return false
229 }
230 return true
231 })
232
233 if leafPaneID != "" {
234 return true, leafPaneID
235 }
236 return false, ""
237}