1// workspace_navigation.go - Navigation and focus management for workspace panes
2package browser
3
4import (
5 "fmt"
6 "math"
7 "strings"
8 "time"
9
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/pkg/webkit"
12 "github.com/diamondburned/gotk4/pkg/gtk/v4"
13)
14
15const (
16 // focusThrottleInterval prevents rapid focus changes that could cause infinite loops
17 focusThrottleInterval = 100 * time.Millisecond
18)
19
20// DispatchPaneFocusEvent sends a workspace focus event to a pane's webview
21func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) {
22 if node == nil || node.pane == nil || node.pane.webView == nil {
23 return
24 }
25
26 detail := map[string]any{
27 "active": active,
28 "webview": fmt.Sprintf("%p", node.pane.webView),
29 "webviewId": node.pane.webView.ID(),
30 "hasGUI": node.pane.HasGUI(),
31 "timestamp": time.Now().UnixMilli(),
32 }
33
34 if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
35 logging.Error(fmt.Sprintf("[workspace] failed to dispatch focus event: %v", err))
36 } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
37 logging.Debug(fmt.Sprintf("[workspace] dispatched focus event for webview %d (active=%v)", node.pane.webView.ID(), active))
38 }
39}
40
41// focusAfterPaneMode restores focus when a pane mode operation completes without
42// stealing focus from the active pane inside a stack that just spawned a sibling.
43func (wm *WorkspaceManager) focusAfterPaneMode(node *paneNode) {
44 wm.focusRespectingStack(node, "focus-after-pane-mode")
45}
46
47// focusRespectingStack handles focus while respecting stacked pane hierarchies
48func (wm *WorkspaceManager) focusRespectingStack(node *paneNode, reason string) {
49 if node == nil {
50 return
51 }
52
53 if stack := node.parent; stack != nil && stack.isStacked {
54 activeIndex := stack.activeStackIndex
55 if activeIndex >= 0 && activeIndex < len(stack.stackedPanes) {
56 activePane := stack.stackedPanes[activeIndex]
57 if activePane != nil && activePane != node {
58 if reason != "" {
59 logging.Info(fmt.Sprintf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex))
60 }
61 wm.SetActivePane(activePane, SourceKeyboard)
62 return
63 }
64 }
65 }
66
67 wm.SetActivePane(node, SourceKeyboard)
68}
69
70// focusByView changes focus to the pane containing the specified WebView
71func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
72 if wm == nil || view == nil {
73 return
74 }
75
76 // Throttle focus changes to prevent infinite loops
77 wm.focusThrottleMutex.Lock()
78 if time.Since(wm.lastFocusChange) < focusThrottleInterval {
79 wm.focusThrottleMutex.Unlock()
80 return
81 }
82 wm.lastFocusChange = time.Now()
83 wm.focusThrottleMutex.Unlock()
84
85 if node, ok := wm.viewToNode[view]; ok {
86 if wm.GetActiveNode() != node {
87 wm.focusRespectingStack(node, "focus-by-view")
88 }
89 }
90}
91
92// Note: ensureHover and detachHover are now implemented in workspace_hover.go
93
94// detachFocus removes the GTK focus controller attached to a pane, if any.
95func (wm *WorkspaceManager) detachFocus(node *paneNode) {
96 if wm == nil || node == nil || node.focusControllerToken == 0 {
97 return
98 }
99
100 if wm.focusStateMachine != nil {
101 wm.focusStateMachine.detachGTKController(node, node.focusControllerToken)
102 }
103
104 node.focusControllerToken = 0
105}
106
107// FocusNeighbor moves focus to the nearest pane in the requested direction using the
108// actual widget geometry to determine adjacency. For stacked panes, DirectionUp and DirectionDown
109// navigate within the stack.
110func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
111 if wm == nil {
112 return false
113 }
114 switch strings.ToLower(direction) {
115 case DirectionUp, DirectionDown:
116 // Check if current pane is part of a stack and handle stack navigation
117 if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) {
118 return true
119 }
120 // Fall back to regular adjacency navigation
121 return wm.focusAdjacent(strings.ToLower(direction))
122 case DirectionLeft, DirectionRight:
123 return wm.focusAdjacent(strings.ToLower(direction))
124 default:
125 logging.Error(fmt.Sprintf("[workspace] invalid focus direction: %s (expected: up, down, left, right)", direction))
126 return false
127 }
128}
129
130// navigateStack handles navigation within a stacked pane container.
131func (wm *WorkspaceManager) navigateStack(direction string) bool {
132 currentFocused := wm.GetActiveNode()
133 if currentFocused == nil {
134 return false
135 }
136
137 // Find the stack container this pane belongs to
138 var stackNode *paneNode
139 current := currentFocused
140
141 // Check if current pane is directly in a stack
142 if current.parent != nil && current.parent.isStacked {
143 stackNode = current.parent
144 } else {
145 // Current pane might be the stack container itself if it was the first pane converted to stack
146 if current.isStacked {
147 stackNode = current
148 }
149 }
150
151 if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
152 return false // Not in a stack or stack has only one pane
153 }
154
155 // Find current pane's index in the stack
156 currentIndex := -1
157 for i, pane := range stackNode.stackedPanes {
158 if pane == current || (pane.pane != nil && current.pane != nil && pane.pane.webView == current.pane.webView) {
159 currentIndex = i
160 break
161 }
162 }
163
164 if currentIndex == -1 {
165 logging.Error(fmt.Sprintf("[workspace] navigateStack: current pane not found in stack"))
166 return false
167 }
168
169 // Calculate new index based on direction
170 var newIndex int
171 switch direction {
172 case DirectionUp:
173 newIndex = currentIndex - 1
174 if newIndex < 0 {
175 newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
176 }
177 case DirectionDown:
178 newIndex = currentIndex + 1
179 if newIndex >= len(stackNode.stackedPanes) {
180 newIndex = 0 // Wrap to first
181 }
182 default:
183 return false
184 }
185
186 if newIndex == currentIndex {
187 return false // No change
188 }
189
190 // Update active stack index and visibility
191 stackNode.activeStackIndex = newIndex
192 wm.stackedPaneManager.UpdateStackVisibility(stackNode)
193
194 // Focus the new active pane
195 newActivePane := stackNode.stackedPanes[newIndex]
196 wm.SetActivePane(newActivePane, SourceStackNav)
197
198 logging.Debug(fmt.Sprintf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
199 direction, currentIndex, newIndex, len(stackNode.stackedPanes)))
200 return true
201}
202
203// focusAdjacent uses geometry-based calculations to find and focus adjacent panes
204func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
205 currentFocused := wm.GetActiveNode()
206 if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil {
207 return false
208 }
209
210 if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
211 logging.Debug(fmt.Sprintf("[workspace] focusAdjacent: using structural neighbor for direction=%s", direction))
212 wm.SetActivePane(neighbor, SourceKeyboard)
213 return true
214 }
215
216 curX, curY, curWidth, curHeight := wm.getNavigationAllocation(currentFocused)
217 cx := float64(curX) + float64(curWidth)/2.0
218 cy := float64(curY) + float64(curHeight)/2.0
219 logging.Debug(fmt.Sprintf("[workspace] focusAdjacent: current pane center=(%.0f, %.0f) direction=%s", cx, cy, direction))
220
221 leaves := wm.collectLeaves()
222 bestScore := math.MaxFloat64
223 var best *paneNode
224 var debugCandidates []string
225
226 for _, candidate := range leaves {
227 if candidate == nil || candidate == currentFocused || candidate.container == nil {
228 continue
229 }
230
231 x, y, width, height := wm.getNavigationAllocation(candidate)
232 tx := float64(x) + float64(width)/2.0
233 ty := float64(y) + float64(height)/2.0
234
235 dx := tx - cx
236 dy := ty - cy
237
238 var score float64
239 switch direction {
240 case DirectionLeft:
241 if dx >= -focusEpsilon {
242 continue
243 }
244 score = math.Abs(dx)*1000 + math.Abs(dy)
245 case DirectionRight:
246 if dx <= focusEpsilon {
247 continue
248 }
249 score = math.Abs(dx)*1000 + math.Abs(dy)
250 case DirectionUp:
251 if dy >= -focusEpsilon {
252 debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not above)", candidate.container, tx, ty, dy))
253 continue
254 }
255 score = math.Abs(dy)*1000 + math.Abs(dx)
256 debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score))
257 case DirectionDown:
258 if dy <= focusEpsilon {
259 debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not below)", candidate.container, tx, ty, dy))
260 continue
261 }
262 score = math.Abs(dy)*1000 + math.Abs(dx)
263 debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score))
264 }
265
266 if score < bestScore {
267 bestScore = score
268 best = candidate
269 }
270 }
271
272 if best != nil {
273 bx, by, _, _ := webkit.WidgetGetAllocation(best.container)
274 logging.Debug(fmt.Sprintf("[workspace] focusAdjacent: found best candidate at pos=(%d,%d) for direction=%s", bx, by, direction))
275 wm.SetActivePane(best, SourceKeyboard)
276 return true
277 }
278
279 if len(debugCandidates) > 0 {
280 logging.Debug(fmt.Sprintf("[workspace] focusAdjacent: NO candidate found for direction=%s current=%p center=(%.0f,%.0f) candidates=[%s]", direction, currentFocused.container, cx, cy, strings.Join(debugCandidates, "; ")))
281 }
282 return false
283}
284
285// getNavigationAllocation returns window-absolute coordinates for navigation geometric checks.
286// Uses ComputeBounds relative to the window root to get actual screen positions.
287// For panes inside a stack, returns the stack wrapper's bounds.
288// For regular panes, returns the pane's own bounds.
289func (wm *WorkspaceManager) getNavigationAllocation(node *paneNode) (x, y, width, height int) {
290 if node == nil || node.container == nil {
291 return 0, 0, 0, 0
292 }
293
294 // If this pane is inside a stack, use the stack wrapper's window bounds
295 if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
296 return webkit.WidgetGetWindowBounds(node.parent.stackWrapper)
297 }
298
299 // For regular panes or stack containers themselves, use their own window bounds
300 return webkit.WidgetGetWindowBounds(node.container)
301}
302
303// structuralNeighbor finds neighbors based on the tree structure rather than geometry
304func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
305 if node == nil || node.container == nil {
306 return nil
307 }
308
309 refX, refY, refWidth, refHeight := wm.getNavigationAllocation(node)
310 cx := float64(refX) + float64(refWidth)/2.0
311 cy := float64(refY) + float64(refHeight)/2.0
312 axisVertical := direction == DirectionUp || direction == DirectionDown
313
314 logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: node=%p pos=(%d,%d) direction=%s", node.container, refX, refY, direction))
315
316 // Start from the node's parent, but skip stack containers for vertical navigation
317 // Stack containers are transparent to vertical navigation - we want to navigate
318 // from stack to external panes, not within the stack (that's handled by NavigateStack)
319 startParent := node.parent
320 if startParent != nil && startParent.isStacked && axisVertical {
321 logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: skipping stack container parent, using stack's parent instead"))
322 node = startParent // Treat the stack container as the navigation node
323 startParent = startParent.parent
324 }
325
326 for parent := startParent; parent != nil; parent = parent.parent {
327 isLeft := parent.left == node
328 isRight := parent.right == node
329 logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: checking parent orientation=%v isLeft=%v isRight=%v", parent.orientation, isLeft, isRight))
330
331 switch direction {
332 case DirectionUp:
333 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {
334 logging.Info(fmt.Sprintf("[workspace] structuralNeighbor: DirectionUp - we are RIGHT child, looking in LEFT subtree"))
335 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
336 lx, ly, _, _ := webkit.WidgetGetAllocation(leaf.container)
337 logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: found leaf at pos=(%d,%d)", lx, ly))
338 return leaf
339 }
340 }
341 case DirectionDown:
342 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {
343 logging.Info(fmt.Sprintf("[workspace] structuralNeighbor: DirectionDown - we are LEFT child, looking in RIGHT subtree"))
344 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
345 lx, ly, _, _ := wm.getNavigationAllocation(leaf)
346 logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: found leaf at pos=(%d,%d)", lx, ly))
347 return leaf
348 } else {
349 logging.Error(fmt.Sprintf("[workspace] structuralNeighbor: closestLeafFromSubtree returned nil for DirectionDown"))
350 }
351 }
352 case DirectionLeft:
353 if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node {
354 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
355 return leaf
356 }
357 }
358 case DirectionRight:
359 if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node {
360 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
361 return leaf
362 }
363 }
364 }
365 node = parent
366 }
367 return nil
368}
369
370// closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
371func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
372 leaves := wm.collectLeavesFromWithDirection(node, direction)
373 logging.Debug(fmt.Sprintf("[workspace] closestLeafFromSubtree: found %d leaves for direction=%s from cx=%.0f cy=%.0f", len(leaves), direction, cx, cy))
374 bestScore := math.MaxFloat64
375 var best *paneNode
376 for _, leaf := range leaves {
377 if leaf == nil || leaf.container == nil {
378 continue
379 }
380
381 x, y, width, height := wm.getNavigationAllocation(leaf)
382 tx := float64(x) + float64(width)/2.0
383 ty := float64(y) + float64(height)/2.0
384 dx := tx - cx
385 dy := ty - cy
386 logging.Info(fmt.Sprintf("[workspace] closestLeafFromSubtree: leaf=%p pos=(%d,%d) center=(%.0f,%.0f) dx=%.0f dy=%.0f", leaf.container, x, y, tx, ty, dx, dy))
387 var score float64
388 switch direction {
389 case DirectionLeft:
390 if dx >= -focusEpsilon {
391 logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not left)", dx))
392 continue
393 }
394 score = math.Abs(dx)*1000 + math.Abs(dy)
395 case DirectionRight:
396 if dx <= focusEpsilon {
397 logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not right)", dx))
398 continue
399 }
400 score = math.Abs(dx)*1000 + math.Abs(dy)
401 case DirectionUp:
402 if dy >= -focusEpsilon {
403 logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not above)", dy))
404 continue
405 }
406 score = math.Abs(dy)*1000 + math.Abs(dx)
407 case DirectionDown:
408 if dy <= focusEpsilon {
409 logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not below)", dy))
410 continue
411 }
412 score = math.Abs(dy)*1000 + math.Abs(dx)
413 default:
414 continue
415 }
416 logging.Debug(fmt.Sprintf("[workspace] closestLeafFromSubtree: ACCEPTED with score=%.0f", score))
417 if score < bestScore {
418 bestScore = score
419 best = leaf
420 }
421 }
422 if best == nil {
423 logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: no match found, trying boundaryFallback"))
424 return wm.boundaryFallback(node, direction)
425 }
426 logging.Info(fmt.Sprintf("[workspace] closestLeafFromSubtree: returning best=%p", best.container))
427 return best
428}
429
430// boundaryFallback provides fallback navigation when geometric search fails
431func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
432 return wm.boundaryFallbackWithDepth(node, direction, 0)
433}
434
435// boundaryFallbackWithDepth provides recursive fallback with depth protection
436func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode {
437 // Prevent infinite recursion - max tree depth should be reasonable
438 const maxDepth = 50
439 if depth > maxDepth {
440 logging.Error(fmt.Sprintf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption"))
441 return nil
442 }
443
444 if node == nil {
445 return nil
446 }
447 if node.isLeaf {
448 return node
449 }
450 switch direction {
451 case DirectionUp:
452 if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
453 return leaf
454 }
455 return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
456 case DirectionDown:
457 if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
458 return leaf
459 }
460 return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
461 case DirectionLeft:
462 if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
463 return leaf
464 }
465 return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
466 case DirectionRight:
467 if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
468 return leaf
469 }
470 return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
471 default:
472 return nil
473 }
474}