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(ctx context.Context, provider PaneGeometryProvider) []entity.PaneRect {
42 log := logging.FromContext(ctx)
43 var rects []entity.PaneRect
44
45 container := provider.ContainerWidget()
46
47 allPaneIDs := provider.GetPaneIDs()
48 log.Debug().Int("total_panes", len(allPaneIDs)).Msg("CollectPaneRects: starting")
49
50 for _, paneID := range allPaneIDs {
51 paneWidget := provider.GetPaneWidget(paneID)
52
53 // Skip panes that aren't visible (includes inactive stacked panes)
54 if paneWidget == nil || !paneWidget.IsVisible() {
55 log.Debug().
56 Str("pane_id", string(paneID)).
57 Bool("widget_nil", paneWidget == nil).
58 Msg("CollectPaneRects: skipping invisible pane")
59 continue
60 }
61
62 // For stacked panes, use stack container geometry (pane itself has no size)
63 var widget layout.Widget
64 if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil {
65 widget = stackWidget
66 } else {
67 widget = paneWidget
68 }
69
70 if widget == nil || !widget.IsVisible() {
71 continue
72 }
73
74 // Get position relative to container
75 x, y, ok := widget.ComputePoint(container)
76 if !ok {
77 continue
78 }
79
80 w := widget.GetAllocatedWidth()
81 h := widget.GetAllocatedHeight()
82
83 // Skip widgets with no size (collapsed/hidden)
84 if w <= 0 || h <= 0 {
85 continue
86 }
87
88 log.Debug().
89 Str("pane_id", string(paneID)).
90 Int("x", int(x)).
91 Int("y", int(y)).
92 Int("w", w).
93 Int("h", h).
94 Msg("CollectPaneRects: added visible pane")
95
96 rects = append(rects, entity.PaneRect{
97 PaneID: paneID,
98 X: int(x),
99 Y: int(y),
100 W: w,
101 H: h,
102 })
103 }
104
105 log.Debug().Int("visible_panes", len(rects)).Msg("CollectPaneRects: done")
106
107 return rects
108}
109
110// NavigateGeometric performs geometric navigation and returns the target pane.
111// It handles stack navigation internally: when navigating up/down and the current
112// pane is in a stack, it will try to navigate within the stack first before
113// escaping to external panes.
114func (m *Manager) NavigateGeometric(
115 ctx context.Context,
116 ws *entity.Workspace,
117 provider PaneGeometryProvider,
118 direction usecase.NavigateDirection,
119) (*entity.PaneNode, error) {
120 log := logging.FromContext(ctx)
121
122 if ws == nil {
123 return nil, nil
124 }
125
126 // Get active pane node
127 activeNode := ws.ActivePane()
128 if activeNode == nil {
129 log.Debug().Msg("no active pane for navigation")
130 return nil, nil
131 }
132
133 // Check if we should navigate within a stack first
134 if direction == usecase.NavUp || direction == usecase.NavDown {
135 if activeNode.Parent != nil && activeNode.Parent.IsStacked {
136 // Try to navigate within the stack
137 stackNode := activeNode.Parent
138 canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction)
139 if canNavigate {
140 // Apply focus change through use case (handles stack index updates)
141 targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, newPaneID)
142 if err != nil {
143 return nil, err
144 }
145 return targetNode, nil
146 }
147 // At stack boundary - fall through to geometric navigation
148 log.Debug().Msg("at stack boundary, using geometric navigation")
149 }
150 }
151
152 // Collect geometry from visible panes
153 rects := m.CollectPaneRects(ctx, provider)
154 if len(rects) == 0 {
155 return nil, nil
156 }
157
158 // Run geometric algorithm
159 output, err := m.panesUC.NavigateFocusGeometric(ctx, usecase.GeometricNavigationInput{
160 ActivePaneID: ws.ActivePaneID,
161 PaneRects: rects,
162 Direction: direction,
163 })
164 if err != nil {
165 return nil, err
166 }
167 if !output.Found {
168 return nil, nil
169 }
170
171 // Apply focus change through use case (handles stack index updates)
172 targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, output.TargetPaneID)
173 if err != nil {
174 return nil, err
175 }
176
177 return targetNode, nil
178}
179
180// navigateWithinStack tries to navigate within a stack.
181// Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
182// boundary and navigation should escape the stack.
183// Note: This method does not modify domain state; the caller should use
184// ApplyFocusChange to apply the focus change.
185func (m *Manager) navigateWithinStack(
186 stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection,
187) (bool, entity.PaneID) {
188 if !stackNode.IsStacked || len(stackNode.Children) == 0 {
189 return false, ""
190 }
191
192 currentIdx := m.findCurrentStackIndex(stackNode, currentNode)
193 if currentIdx < 0 {
194 return false, ""
195 }
196
197 newIdx, ok := m.calculateNewStackIndex(currentIdx, len(stackNode.Children), direction)
198 if !ok {
199 return false, ""
200 }
201
202 return m.getPaneIDFromStackChild(stackNode.Children[newIdx])
203}
204
205// findCurrentStackIndex returns the current pane's index in the stack.
206// Uses ActiveStackIndex from domain model, with fallback to pointer search.
207func (*Manager) findCurrentStackIndex(stackNode, currentNode *entity.PaneNode) int {
208 idx := stackNode.ActiveStackIndex
209 if idx >= 0 && idx < len(stackNode.Children) {
210 return idx
211 }
212 // Fallback to pointer search if ActiveStackIndex is invalid
213 for i, child := range stackNode.Children {
214 if child == currentNode {
215 return i
216 }
217 }
218 return -1
219}
220
221// calculateNewStackIndex computes the new index after navigation.
222// Returns (newIndex, ok) where ok is false if at a boundary.
223func (*Manager) calculateNewStackIndex(
224 currentIdx, childCount int, direction usecase.NavigateDirection,
225) (int, bool) {
226 switch direction {
227 case usecase.NavUp:
228 if currentIdx <= 0 {
229 return 0, false
230 }
231 return currentIdx - 1, true
232 case usecase.NavDown:
233 if currentIdx >= childCount-1 {
234 return 0, false
235 }
236 return currentIdx + 1, true
237 default:
238 return 0, false
239 }
240}
241
242// getPaneIDFromStackChild extracts the pane ID from a stack child node.
243// If the child is not a leaf, finds the first leaf pane.
244func (*Manager) getPaneIDFromStackChild(targetNode *entity.PaneNode) (bool, entity.PaneID) {
245 if targetNode.Pane != nil {
246 return true, targetNode.Pane.ID
247 }
248
249 var leafPaneID entity.PaneID
250 targetNode.Walk(func(n *entity.PaneNode) bool {
251 if n.IsLeaf() && n.Pane != nil {
252 leafPaneID = n.Pane.ID
253 return false
254 }
255 return true
256 })
257
258 if leafPaneID != "" {
259 return true, leafPaneID
260 }
261 return false, ""
262}