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 "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 log.Printf("[workspace] failed to dispatch focus event: %v", err)
36 } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
37 log.Printf("[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 log.Printf("[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 log.Printf("[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 log.Printf("[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 log.Printf("[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 wm.SetActivePane(neighbor, SourceKeyboard)
212 return true
213 }
214
215 curX, curY, curWidth, curHeight := webkit.WidgetGetAllocation(currentFocused.container)
216 cx := float64(curX) + float64(curWidth)/2.0
217 cy := float64(curY) + float64(curHeight)/2.0
218
219 leaves := wm.collectLeaves()
220 bestScore := math.MaxFloat64
221 var best *paneNode
222 var debugCandidates []string
223
224 for _, candidate := range leaves {
225 if candidate == nil || candidate == currentFocused || candidate.container == nil {
226 continue
227 }
228
229 x, y, width, height := webkit.WidgetGetAllocation(candidate.container)
230 tx := float64(x) + float64(width)/2.0
231 ty := float64(y) + float64(height)/2.0
232
233 dx := tx - cx
234 dy := ty - cy
235
236 var score float64
237 switch direction {
238 case DirectionLeft:
239 if dx >= -focusEpsilon {
240 continue
241 }
242 score = math.Abs(dx)*1000 + math.Abs(dy)
243 case DirectionRight:
244 if dx <= focusEpsilon {
245 continue
246 }
247 score = math.Abs(dx)*1000 + math.Abs(dy)
248 case DirectionUp:
249 if dy >= -focusEpsilon {
250 continue
251 }
252 score = math.Abs(dy)*1000 + math.Abs(dx)
253 case DirectionDown:
254 if dy <= focusEpsilon {
255 continue
256 }
257 score = math.Abs(dy)*1000 + math.Abs(dx)
258 }
259
260 if direction == DirectionUp || direction == DirectionDown {
261 debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
262 }
263
264 if score < bestScore {
265 bestScore = score
266 best = candidate
267 }
268 }
269
270 if best != nil {
271 wm.SetActivePane(best, SourceKeyboard)
272 return true
273 }
274
275 if len(debugCandidates) > 0 {
276 log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%p candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; "))
277 }
278 return false
279}
280
281// structuralNeighbor finds neighbors based on the tree structure rather than geometry
282func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
283 if node == nil || node.container == nil {
284 return nil
285 }
286
287 refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container)
288 cx := float64(refX) + float64(refWidth)/2.0
289 cy := float64(refY) + float64(refHeight)/2.0
290 axisVertical := direction == DirectionUp || direction == DirectionDown
291
292 for parent := node.parent; parent != nil; parent = parent.parent {
293 switch direction {
294 case DirectionUp:
295 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {
296 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
297 return leaf
298 }
299 }
300 case DirectionDown:
301 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {
302 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
303 return leaf
304 }
305 }
306 case DirectionLeft:
307 if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node {
308 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
309 return leaf
310 }
311 }
312 case DirectionRight:
313 if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node {
314 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
315 return leaf
316 }
317 }
318 }
319 node = parent
320 }
321 return nil
322}
323
324// closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
325func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
326 leaves := wm.collectLeavesFrom(node)
327 bestScore := math.MaxFloat64
328 var best *paneNode
329 for _, leaf := range leaves {
330 if leaf == nil || leaf.container == nil {
331 continue
332 }
333
334 x, y, width, height := webkit.WidgetGetAllocation(leaf.container)
335 tx := float64(x) + float64(width)/2.0
336 ty := float64(y) + float64(height)/2.0
337 dx := tx - cx
338 dy := ty - cy
339 var score float64
340 switch direction {
341 case DirectionLeft:
342 if dx >= -focusEpsilon {
343 continue
344 }
345 score = math.Abs(dx)*1000 + math.Abs(dy)
346 case DirectionRight:
347 if dx <= focusEpsilon {
348 continue
349 }
350 score = math.Abs(dx)*1000 + math.Abs(dy)
351 case DirectionUp:
352 if dy >= -focusEpsilon {
353 continue
354 }
355 score = math.Abs(dy)*1000 + math.Abs(dx)
356 case DirectionDown:
357 if dy <= focusEpsilon {
358 continue
359 }
360 score = math.Abs(dy)*1000 + math.Abs(dx)
361 default:
362 continue
363 }
364 if score < bestScore {
365 bestScore = score
366 best = leaf
367 }
368 }
369 if best == nil {
370 return wm.boundaryFallback(node, direction)
371 }
372 return best
373}
374
375// boundaryFallback provides fallback navigation when geometric search fails
376func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
377 return wm.boundaryFallbackWithDepth(node, direction, 0)
378}
379
380// boundaryFallbackWithDepth provides recursive fallback with depth protection
381func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode {
382 // Prevent infinite recursion - max tree depth should be reasonable
383 const maxDepth = 50
384 if depth > maxDepth {
385 log.Printf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption")
386 return nil
387 }
388
389 if node == nil {
390 return nil
391 }
392 if node.isLeaf {
393 return node
394 }
395 switch direction {
396 case DirectionUp:
397 if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
398 return leaf
399 }
400 return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
401 case DirectionDown:
402 if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
403 return leaf
404 }
405 return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
406 case DirectionLeft:
407 if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
408 return leaf
409 }
410 return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
411 case DirectionRight:
412 if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
413 return leaf
414 }
415 return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
416 default:
417 return nil
418 }
419}