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