v0.13.0 976176a

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		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}

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