detached 6bf432a

fix(navigation): resolve coordinate space mixing in workspace navigation

commit
6bf432a50aa7c49878e28ced40ab9cfd911b4ece
author
bnema · · 2025-11-13 22:53 UTC
committer
bnema · · 2025-11-13 17:38 UTC
parent
adbefcc
signature
unsigned
3 files · +159 -11
M internal/app/browser/workspace_navigation.go +66 -11
@@ -208,13 +208,15 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
208208 }
209209
210210 if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
211+ log.Printf("[workspace] focusAdjacent: using structural neighbor for direction=%s", direction)
211212 wm.SetActivePane(neighbor, SourceKeyboard)
212213 return true
213214 }
214215
215- curX, curY, curWidth, curHeight := webkit.WidgetGetAllocation(currentFocused.container)
216+ curX, curY, curWidth, curHeight := wm.getNavigationAllocation(currentFocused)
216217 cx := float64(curX) + float64(curWidth)/2.0
217218 cy := float64(curY) + float64(curHeight)/2.0
219+ log.Printf("[workspace] focusAdjacent: current pane center=(%.0f, %.0f) direction=%s", cx, cy, direction)
218220
219221 leaves := wm.collectLeaves()
220222 bestScore := math.MaxFloat64
@@ -226,7 +228,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
226228 continue
227229 }
228230
229- x, y, width, height := webkit.WidgetGetAllocation(candidate.container)
231+ x, y, width, height := wm.getNavigationAllocation(candidate)
230232 tx := float64(x) + float64(width)/2.0
231233 ty := float64(y) + float64(height)/2.0
232234
@@ -247,18 +249,18 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
247249 score = math.Abs(dx)*1000 + math.Abs(dy)
248250 case DirectionUp:
249251 if dy >= -focusEpsilon {
252+ debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not above)", candidate.container, tx, ty, dy))
250253 continue
251254 }
252255 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))
253257 case DirectionDown:
254258 if dy <= focusEpsilon {
259+ debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not below)", candidate.container, tx, ty, dy))
255260 continue
256261 }
257262 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))
263+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score))
262264 }
263265
264266 if score < bestScore {
@@ -268,39 +270,83 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
268270 }
269271
270272 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)
271275 wm.SetActivePane(best, SourceKeyboard)
272276 return true
273277 }
274278
275279 if len(debugCandidates) > 0 {
276- log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%p candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; "))
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, "; "))
277281 }
278282 return false
279283 }
280284
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.
289+func (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+
281303 // structuralNeighbor finds neighbors based on the tree structure rather than geometry
282304 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
283305 if node == nil || node.container == nil {
284306 return nil
285307 }
286308
287- refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container)
309+ refX, refY, refWidth, refHeight := wm.getNavigationAllocation(node)
288310 cx := float64(refX) + float64(refWidth)/2.0
289311 cy := float64(refY) + float64(refHeight)/2.0
290312 axisVertical := direction == DirectionUp || direction == DirectionDown
291313
292- for parent := node.parent; parent != nil; parent = parent.parent {
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+
293331 switch direction {
294332 case DirectionUp:
295333 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {
334+ log.Printf("[workspace] structuralNeighbor: DirectionUp - we are RIGHT child, looking in LEFT subtree")
296335 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)
297338 return leaf
298339 }
299340 }
300341 case DirectionDown:
301342 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {
343+ log.Printf("[workspace] structuralNeighbor: DirectionDown - we are LEFT child, looking in RIGHT subtree")
302344 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)
303347 return leaf
348+ } else {
349+ log.Printf("[workspace] structuralNeighbor: closestLeafFromSubtree returned nil for DirectionDown")
304350 }
305351 }
306352 case DirectionLeft:
@@ -323,7 +369,8 @@ func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string)
323369
324370 // closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
325371 func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
326- leaves := wm.collectLeavesFrom(node)
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)
327374 bestScore := math.MaxFloat64
328375 var best *paneNode
329376 for _, leaf := range leaves {
@@ -331,44 +378,52 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6
331378 continue
332379 }
333380
334- x, y, width, height := webkit.WidgetGetAllocation(leaf.container)
381+ x, y, width, height := wm.getNavigationAllocation(leaf)
335382 tx := float64(x) + float64(width)/2.0
336383 ty := float64(y) + float64(height)/2.0
337384 dx := tx - cx
338385 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)
339387 var score float64
340388 switch direction {
341389 case DirectionLeft:
342390 if dx >= -focusEpsilon {
391+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not left)", dx)
343392 continue
344393 }
345394 score = math.Abs(dx)*1000 + math.Abs(dy)
346395 case DirectionRight:
347396 if dx <= focusEpsilon {
397+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not right)", dx)
348398 continue
349399 }
350400 score = math.Abs(dx)*1000 + math.Abs(dy)
351401 case DirectionUp:
352402 if dy >= -focusEpsilon {
403+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not above)", dy)
353404 continue
354405 }
355406 score = math.Abs(dy)*1000 + math.Abs(dx)
356407 case DirectionDown:
357408 if dy <= focusEpsilon {
409+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not below)", dy)
358410 continue
359411 }
360412 score = math.Abs(dy)*1000 + math.Abs(dx)
361413 default:
362414 continue
363415 }
416+ log.Printf("[workspace] closestLeafFromSubtree: ACCEPTED with score=%.0f", score)
364417 if score < bestScore {
365418 bestScore = score
366419 best = leaf
367420 }
368421 }
369422 if best == nil {
423+ log.Printf("[workspace] closestLeafFromSubtree: no match found, trying boundaryFallback")
370424 return wm.boundaryFallback(node, direction)
371425 }
426+ log.Printf("[workspace] closestLeafFromSubtree: returning best=%p", best.container)
372427 return best
373428 }
374429
@@ -208,13 +208,15 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
208 }208 }
209 209
210 if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {210 if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
211+ log.Printf("[workspace] focusAdjacent: using structural neighbor for direction=%s", direction)
211 wm.SetActivePane(neighbor, SourceKeyboard)212 wm.SetActivePane(neighbor, SourceKeyboard)
212 return true213 return true
213 }214 }
214 215
215- curX, curY, curWidth, curHeight := webkit.WidgetGetAllocation(currentFocused.container)216+ curX, curY, curWidth, curHeight := wm.getNavigationAllocation(currentFocused)
216 cx := float64(curX) + float64(curWidth)/2.0217 cx := float64(curX) + float64(curWidth)/2.0
217 cy := float64(curY) + float64(curHeight)/2.0218 cy := float64(curY) + float64(curHeight)/2.0
219+ log.Printf("[workspace] focusAdjacent: current pane center=(%.0f, %.0f) direction=%s", cx, cy, direction)
218 220
219 leaves := wm.collectLeaves()221 leaves := wm.collectLeaves()
220 bestScore := math.MaxFloat64222 bestScore := math.MaxFloat64
@@ -226,7 +228,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
226 continue228 continue
227 }229 }
228 230
229- x, y, width, height := webkit.WidgetGetAllocation(candidate.container)231+ x, y, width, height := wm.getNavigationAllocation(candidate)
230 tx := float64(x) + float64(width)/2.0232 tx := float64(x) + float64(width)/2.0
231 ty := float64(y) + float64(height)/2.0233 ty := float64(y) + float64(height)/2.0
232 234
@@ -247,18 +249,18 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
247 score = math.Abs(dx)*1000 + math.Abs(dy)249 score = math.Abs(dx)*1000 + math.Abs(dy)
248 case DirectionUp:250 case DirectionUp:
249 if dy >= -focusEpsilon {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))
250 continue253 continue
251 }254 }
252 score = math.Abs(dy)*1000 + math.Abs(dx)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))
253 case DirectionDown:257 case DirectionDown:
254 if dy <= focusEpsilon {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))
255 continue260 continue
256 }261 }
257 score = math.Abs(dy)*1000 + math.Abs(dx)262 score = math.Abs(dy)*1000 + math.Abs(dx)
258- }263+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score))
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 }264 }
263 265
264 if score < bestScore {266 if score < bestScore {
@@ -268,39 +270,83 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
268 }270 }
269 271
270 if best != nil {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)
271 wm.SetActivePane(best, SourceKeyboard)275 wm.SetActivePane(best, SourceKeyboard)
272 return true276 return true
273 }277 }
274 278
275 if len(debugCandidates) > 0 {279 if len(debugCandidates) > 0 {
276- log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%p candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; "))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, "; "))
277 }281 }
278 return false282 return false
279 }283 }
280 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.
289+func (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+
281 // structuralNeighbor finds neighbors based on the tree structure rather than geometry303 // structuralNeighbor finds neighbors based on the tree structure rather than geometry
282 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {304 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
283 if node == nil || node.container == nil {305 if node == nil || node.container == nil {
284 return nil306 return nil
285 }307 }
286 308
287- refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container)309+ refX, refY, refWidth, refHeight := wm.getNavigationAllocation(node)
288 cx := float64(refX) + float64(refWidth)/2.0310 cx := float64(refX) + float64(refWidth)/2.0
289 cy := float64(refY) + float64(refHeight)/2.0311 cy := float64(refY) + float64(refHeight)/2.0
290 axisVertical := direction == DirectionUp || direction == DirectionDown312 axisVertical := direction == DirectionUp || direction == DirectionDown
291 313
292- for parent := node.parent; parent != nil; parent = parent.parent {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+
293 switch direction {331 switch direction {
294 case DirectionUp:332 case DirectionUp:
295 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {333 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {
334+ log.Printf("[workspace] structuralNeighbor: DirectionUp - we are RIGHT child, looking in LEFT subtree")
296 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {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)
297 return leaf338 return leaf
298 }339 }
299 }340 }
300 case DirectionDown:341 case DirectionDown:
301 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {342 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {
343+ log.Printf("[workspace] structuralNeighbor: DirectionDown - we are LEFT child, looking in RIGHT subtree")
302 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {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)
303 return leaf347 return leaf
348+ } else {
349+ log.Printf("[workspace] structuralNeighbor: closestLeafFromSubtree returned nil for DirectionDown")
304 }350 }
305 }351 }
306 case DirectionLeft:352 case DirectionLeft:
@@ -323,7 +369,8 @@ func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string)
323 369
324 // closestLeafFromSubtree finds the closest leaf node in a subtree based on direction370 // closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
325 func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {371 func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
326- leaves := wm.collectLeavesFrom(node)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)
327 bestScore := math.MaxFloat64374 bestScore := math.MaxFloat64
328 var best *paneNode375 var best *paneNode
329 for _, leaf := range leaves {376 for _, leaf := range leaves {
@@ -331,44 +378,52 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6
331 continue378 continue
332 }379 }
333 380
334- x, y, width, height := webkit.WidgetGetAllocation(leaf.container)381+ x, y, width, height := wm.getNavigationAllocation(leaf)
335 tx := float64(x) + float64(width)/2.0382 tx := float64(x) + float64(width)/2.0
336 ty := float64(y) + float64(height)/2.0383 ty := float64(y) + float64(height)/2.0
337 dx := tx - cx384 dx := tx - cx
338 dy := ty - cy385 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)
339 var score float64387 var score float64
340 switch direction {388 switch direction {
341 case DirectionLeft:389 case DirectionLeft:
342 if dx >= -focusEpsilon {390 if dx >= -focusEpsilon {
391+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not left)", dx)
343 continue392 continue
344 }393 }
345 score = math.Abs(dx)*1000 + math.Abs(dy)394 score = math.Abs(dx)*1000 + math.Abs(dy)
346 case DirectionRight:395 case DirectionRight:
347 if dx <= focusEpsilon {396 if dx <= focusEpsilon {
397+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not right)", dx)
348 continue398 continue
349 }399 }
350 score = math.Abs(dx)*1000 + math.Abs(dy)400 score = math.Abs(dx)*1000 + math.Abs(dy)
351 case DirectionUp:401 case DirectionUp:
352 if dy >= -focusEpsilon {402 if dy >= -focusEpsilon {
403+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not above)", dy)
353 continue404 continue
354 }405 }
355 score = math.Abs(dy)*1000 + math.Abs(dx)406 score = math.Abs(dy)*1000 + math.Abs(dx)
356 case DirectionDown:407 case DirectionDown:
357 if dy <= focusEpsilon {408 if dy <= focusEpsilon {
409+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not below)", dy)
358 continue410 continue
359 }411 }
360 score = math.Abs(dy)*1000 + math.Abs(dx)412 score = math.Abs(dy)*1000 + math.Abs(dx)
361 default:413 default:
362 continue414 continue
363 }415 }
416+ log.Printf("[workspace] closestLeafFromSubtree: ACCEPTED with score=%.0f", score)
364 if score < bestScore {417 if score < bestScore {
365 bestScore = score418 bestScore = score
366 best = leaf419 best = leaf
367 }420 }
368 }421 }
369 if best == nil {422 if best == nil {
423+ log.Printf("[workspace] closestLeafFromSubtree: no match found, trying boundaryFallback")
370 return wm.boundaryFallback(node, direction)424 return wm.boundaryFallback(node, direction)
371 }425 }
426+ log.Printf("[workspace] closestLeafFromSubtree: returning best=%p", best.container)
372 return best427 return best
373 }428 }
374 429
M internal/app/browser/workspace_navigation_test.go +76 -0
@@ -144,3 +144,79 @@ func TestCaseSensitiveDirections(t *testing.T) {
144144 })
145145 }
146146 }
147+
148+// TestNavigationCoordinateBug documents the coordinate space bug in navigation
149+//
150+// Bug Description:
151+// When comparing positions for geometric navigation, the code mixes two coordinate spaces:
152+// - Normal panes: return window-absolute coordinates from WidgetGetAllocation()
153+// - Stacked panes: return stack-box-relative coordinates from WidgetGetAllocation()
154+//
155+// This causes navigation to fail because we're comparing coordinates in different spaces.
156+//
157+// Real-world example from logs:
158+// Normal pane (top half): center=(957, 532) - window coords
159+// Stack pane (bottom): center=(955, 511) - relative to stack box!
160+// Result: dy=-21 → SKIPPED as "not below"
161+//
162+// The fix: Always use stack wrapper allocation for panes inside stacks.
163+func TestNavigationCoordinateBug(t *testing.T) {
164+ // Simulate the coordinate math from real user scenario
165+ // Window: 1914x1064, vertical split: normal pane (top) + stack (bottom)
166+
167+ t.Run("BugValidation_CoordinateMixing", func(t *testing.T) {
168+ // Normal pane occupies top half - window-absolute coords
169+ normalCX, normalCY := 957.0, 266.0
170+
171+ // Stack pane child returns RELATIVE coordinates (bug!)
172+ // Its allocation is relative to stack box at y=532
173+ stackChildCX, stackChildCY := 957.0, 133.0 // Relative to parent!
174+
175+ dy := stackChildCY - normalCY
176+ t.Logf("Normal pane: center=(%.0f, %.0f)", normalCX, normalCY)
177+ t.Logf("Stack child (buggy relative): center=(%.0f, %.0f)", stackChildCX, stackChildCY)
178+ t.Logf("dy = %.0f (negative = appears above)", dy)
179+
180+ // Navigation DOWN requires dy > 0, but we get negative!
181+ const focusEpsilon = 0.5
182+ if dy <= focusEpsilon {
183+ t.Logf("✓ BUG: dy=%.0f, navigation DOWN would FAIL", dy)
184+ }
185+ })
186+
187+ t.Run("CorrectBehavior_UseStackWrapper", func(t *testing.T) {
188+ // Normal pane (same as before)
189+ normalCX, normalCY := 957.0, 266.0
190+
191+ // Stack wrapper has correct window-absolute coordinates
192+ stackWrapperCX, stackWrapperCY := 957.0, 798.0
193+
194+ dy := stackWrapperCY - normalCY
195+ t.Logf("Normal pane: center=(%.0f, %.0f)", normalCX, normalCY)
196+ t.Logf("Stack wrapper (correct): center=(%.0f, %.0f)", stackWrapperCX, stackWrapperCY)
197+ t.Logf("dy = %.0f (positive = correctly below)", dy)
198+
199+ const focusEpsilon = 0.5
200+ if dy > focusEpsilon {
201+ t.Logf("✓ CORRECT: Navigation DOWN would SUCCEED")
202+ }
203+
204+ // Also verify UP navigation
205+ dyUp := normalCY - stackWrapperCY
206+ if dyUp < -focusEpsilon {
207+ t.Logf("✓ CORRECT: Navigation UP would SUCCEED")
208+ }
209+ })
210+}
211+
212+// TestGetNavigationAllocation tests the proposed fix helper function
213+// This will be uncommented and updated after implementing the helper
214+func TestGetNavigationAllocation(t *testing.T) {
215+ t.Skip("Enable this test after implementing getNavigationAllocation() helper")
216+
217+ // TODO: After implementing getNavigationAllocation(), test:
218+ // 1. Regular leaf pane → returns pane.container allocation
219+ // 2. Pane inside stack → returns stack.stackWrapper allocation
220+ // 3. Stack container itself → returns stack.stackWrapper allocation
221+ // 4. Nil safety: node=nil, container=nil, parent=nil, stackWrapper=nil
222+}
@@ -144,3 +144,79 @@ func TestCaseSensitiveDirections(t *testing.T) {
144 })144 })
145 }145 }
146 }146 }
147+
148+// TestNavigationCoordinateBug documents the coordinate space bug in navigation
149+//
150+// Bug Description:
151+// When comparing positions for geometric navigation, the code mixes two coordinate spaces:
152+// - Normal panes: return window-absolute coordinates from WidgetGetAllocation()
153+// - Stacked panes: return stack-box-relative coordinates from WidgetGetAllocation()
154+//
155+// This causes navigation to fail because we're comparing coordinates in different spaces.
156+//
157+// Real-world example from logs:
158+// Normal pane (top half): center=(957, 532) - window coords
159+// Stack pane (bottom): center=(955, 511) - relative to stack box!
160+// Result: dy=-21 → SKIPPED as "not below"
161+//
162+// The fix: Always use stack wrapper allocation for panes inside stacks.
163+func TestNavigationCoordinateBug(t *testing.T) {
164+ // Simulate the coordinate math from real user scenario
165+ // Window: 1914x1064, vertical split: normal pane (top) + stack (bottom)
166+
167+ t.Run("BugValidation_CoordinateMixing", func(t *testing.T) {
168+ // Normal pane occupies top half - window-absolute coords
169+ normalCX, normalCY := 957.0, 266.0
170+
171+ // Stack pane child returns RELATIVE coordinates (bug!)
172+ // Its allocation is relative to stack box at y=532
173+ stackChildCX, stackChildCY := 957.0, 133.0 // Relative to parent!
174+
175+ dy := stackChildCY - normalCY
176+ t.Logf("Normal pane: center=(%.0f, %.0f)", normalCX, normalCY)
177+ t.Logf("Stack child (buggy relative): center=(%.0f, %.0f)", stackChildCX, stackChildCY)
178+ t.Logf("dy = %.0f (negative = appears above)", dy)
179+
180+ // Navigation DOWN requires dy > 0, but we get negative!
181+ const focusEpsilon = 0.5
182+ if dy <= focusEpsilon {
183+ t.Logf("✓ BUG: dy=%.0f, navigation DOWN would FAIL", dy)
184+ }
185+ })
186+
187+ t.Run("CorrectBehavior_UseStackWrapper", func(t *testing.T) {
188+ // Normal pane (same as before)
189+ normalCX, normalCY := 957.0, 266.0
190+
191+ // Stack wrapper has correct window-absolute coordinates
192+ stackWrapperCX, stackWrapperCY := 957.0, 798.0
193+
194+ dy := stackWrapperCY - normalCY
195+ t.Logf("Normal pane: center=(%.0f, %.0f)", normalCX, normalCY)
196+ t.Logf("Stack wrapper (correct): center=(%.0f, %.0f)", stackWrapperCX, stackWrapperCY)
197+ t.Logf("dy = %.0f (positive = correctly below)", dy)
198+
199+ const focusEpsilon = 0.5
200+ if dy > focusEpsilon {
201+ t.Logf("✓ CORRECT: Navigation DOWN would SUCCEED")
202+ }
203+
204+ // Also verify UP navigation
205+ dyUp := normalCY - stackWrapperCY
206+ if dyUp < -focusEpsilon {
207+ t.Logf("✓ CORRECT: Navigation UP would SUCCEED")
208+ }
209+ })
210+}
211+
212+// TestGetNavigationAllocation tests the proposed fix helper function
213+// This will be uncommented and updated after implementing the helper
214+func TestGetNavigationAllocation(t *testing.T) {
215+ t.Skip("Enable this test after implementing getNavigationAllocation() helper")
216+
217+ // TODO: After implementing getNavigationAllocation(), test:
218+ // 1. Regular leaf pane → returns pane.container allocation
219+ // 2. Pane inside stack → returns stack.stackWrapper allocation
220+ // 3. Stack container itself → returns stack.stackWrapper allocation
221+ // 4. Nil safety: node=nil, container=nil, parent=nil, stackWrapper=nil
222+}
M pkg/webkit/helpers.go +17 -0
@@ -171,6 +171,23 @@ func WidgetGetBounds(w gtk.Widgetter) (x, y, width, height float64) {
171171 return 0, 0, 0, 0
172172 }
173173
174+// WidgetGetWindowBounds returns the widget's bounds relative to the toplevel window
175+// This provides window-absolute coordinates needed for navigation geometry calculations
176+func WidgetGetWindowBounds(w gtk.Widgetter) (x, y, width, height int) {
177+ if widget := getWidget(w); widget != nil {
178+ // Get the toplevel window (GtkWindow)
179+ root := widget.Root()
180+ if root != nil {
181+ rect, ok := widget.ComputeBounds(root)
182+ if ok && rect != nil {
183+ return int(rect.X()), int(rect.Y()),
184+ int(rect.Width()), int(rect.Height())
185+ }
186+ }
187+ }
188+ return 0, 0, 0, 0
189+}
190+
174191 // WidgetSetFocusChild sets the focus child of a widget
175192 func WidgetSetFocusChild(w gtk.Widgetter, child gtk.Widgetter) {
176193 if widget := getWidget(w); widget != nil {
@@ -171,6 +171,23 @@ func WidgetGetBounds(w gtk.Widgetter) (x, y, width, height float64) {
171 return 0, 0, 0, 0171 return 0, 0, 0, 0
172 }172 }
173 173
174+// WidgetGetWindowBounds returns the widget's bounds relative to the toplevel window
175+// This provides window-absolute coordinates needed for navigation geometry calculations
176+func WidgetGetWindowBounds(w gtk.Widgetter) (x, y, width, height int) {
177+ if widget := getWidget(w); widget != nil {
178+ // Get the toplevel window (GtkWindow)
179+ root := widget.Root()
180+ if root != nil {
181+ rect, ok := widget.ComputeBounds(root)
182+ if ok && rect != nil {
183+ return int(rect.X()), int(rect.Y()),
184+ int(rect.Width()), int(rect.Height())
185+ }
186+ }
187+ }
188+ return 0, 0, 0, 0
189+}
190+
174 // WidgetSetFocusChild sets the focus child of a widget191 // WidgetSetFocusChild sets the focus child of a widget
175 func WidgetSetFocusChild(w gtk.Widgetter, child gtk.Widgetter) {192 func WidgetSetFocusChild(w gtk.Widgetter, child gtk.Widgetter) {
176 if widget := getWidget(w); widget != nil {193 if widget := getWidget(w); widget != nil {

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