v0.14.1 0da30ba

workspace_navigation.go RawHistoryFolder

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		log.Printf("[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	log.Printf("[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		log.Printf("[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		log.Printf("[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	log.Printf("[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		log.Printf("[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		log.Printf("[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				log.Printf("[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					log.Printf("[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				log.Printf("[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					log.Printf("[workspace] structuralNeighbor: found leaf at pos=(%d,%d)", lx, ly)
347					return leaf
348				} else {
349					log.Printf("[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	log.Printf("[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		log.Printf("[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				log.Printf("[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				log.Printf("[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				log.Printf("[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				log.Printf("[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		log.Printf("[workspace] closestLeafFromSubtree: ACCEPTED with score=%.0f", score)
417		if score < bestScore {
418			bestScore = score
419			best = leaf
420		}
421	}
422	if best == nil {
423		log.Printf("[workspace] closestLeafFromSubtree: no match found, trying boundaryFallback")
424		return wm.boundaryFallback(node, direction)
425	}
426	log.Printf("[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		log.Printf("[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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close