detached 2f5a503

fix: geometric focus navigation now prioritizes panes at same level

commit
2f5a503bf8d8c029adb7c079ddd0c382ecc082b8
author
brice · · 2026-01-01 13:01 UTC
parent
e755f2d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +84 -47
M CHANGELOG.md +1 -0
@@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
1313 - **Flatpak locale crash**: Attempted fix for crash on systems with certain locale configurations by adding proper locale data to the Flatpak bundle.
1414 - **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane.
1515 - **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus.
16+- **Geometric focus navigation**: Fixed focus jumping to wrong pane when navigating left/right in complex layouts. Navigation now prioritizes panes at the same vertical level (for left/right) or horizontal level (for up/down).
1617
1718 ## [0.23.2] - 2025-12-31
1819
@@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
13 - **Flatpak locale crash**: Attempted fix for crash on systems with certain locale configurations by adding proper locale data to the Flatpak bundle.13 - **Flatpak locale crash**: Attempted fix for crash on systems with certain locale configurations by adding proper locale data to the Flatpak bundle.
14 - **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane.14 - **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane.
15 - **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus.15 - **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus.
16+- **Geometric focus navigation**: Fixed focus jumping to wrong pane when navigating left/right in complex layouts. Navigation now prioritizes panes at the same vertical level (for left/right) or horizontal level (for up/down).
16 17
17 ## [0.23.2] - 2025-12-3118 ## [0.23.2] - 2025-12-31
18 19
M internal/application/usecase/manage_panes.go +69 -47
@@ -668,10 +668,11 @@ func (uc *ManagePanesUseCase) ApplyFocusChange(
668668
669669 // NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
670670 // Algorithm:
671-// 1. Get center of active pane
671+// 1. Get active pane rectangle
672672 // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
673-// 3. Score by: primary_distance * 1000 + perpendicular_distance
674-// 4. Return lowest scoring candidate
673+// 3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down)
674+// 4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance
675+// 5. Return lowest scoring candidate
675676 func (uc *ManagePanesUseCase) NavigateFocusGeometric(
676677 ctx context.Context,
677678 input GeometricNavigationInput,
@@ -699,50 +700,7 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric(
699700 return &GeometricNavigationOutput{Found: false}, nil
700701 }
701702
702- acx, acy := activeRect.Center()
703-
704- type candidate struct {
705- paneID entity.PaneID
706- score int
707- }
708- var candidates []candidate
709-
710- for _, rect := range input.PaneRects {
711- if rect.PaneID == input.ActivePaneID {
712- continue
713- }
714-
715- cx, cy := rect.Center()
716- dx := cx - acx
717- dy := cy - acy
718-
719- var inDirection bool
720- var primaryDist, perpDist int
721-
722- switch input.Direction {
723- case NavLeft:
724- inDirection = dx < 0
725- primaryDist = abs(dx)
726- perpDist = abs(dy)
727- case NavRight:
728- inDirection = dx > 0
729- primaryDist = abs(dx)
730- perpDist = abs(dy)
731- case NavUp:
732- inDirection = dy < 0
733- primaryDist = abs(dy)
734- perpDist = abs(dx)
735- case NavDown:
736- inDirection = dy > 0
737- primaryDist = abs(dy)
738- perpDist = abs(dx)
739- }
740-
741- if inDirection {
742- score := primaryDist*1000 + perpDist
743- candidates = append(candidates, candidate{rect.PaneID, score})
744- }
745- }
703+ candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction)
746704
747705 if len(candidates) == 0 {
748706 log.Debug().Msg("no candidates in direction")
@@ -765,6 +723,70 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric(
765723 }, nil
766724 }
767725
726+// navCandidate represents a pane candidate for navigation with its score.
727+type navCandidate struct {
728+ paneID entity.PaneID
729+ score int
730+}
731+
732+// scoreNavigationCandidates scores all panes in the given direction from activeRect.
733+// Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down).
734+func scoreNavigationCandidates(
735+ activeRect entity.PaneRect,
736+ paneRects []entity.PaneRect,
737+ direction NavigateDirection,
738+) []navCandidate {
739+ // Large penalty for panes without perpendicular overlap.
740+ // This ensures panes at the same level are always preferred.
741+ const noOverlapPenalty = 10_000_000
742+
743+ acx, acy := activeRect.Center()
744+ var candidates []navCandidate
745+
746+ for _, rect := range paneRects {
747+ if rect.PaneID == activeRect.PaneID {
748+ continue
749+ }
750+
751+ cx, cy := rect.Center()
752+ dx := cx - acx
753+ dy := cy - acy
754+
755+ inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction)
756+
757+ if inDirection {
758+ score := primaryDist*1000 + perpDist
759+ if !hasOverlap {
760+ score += noOverlapPenalty
761+ }
762+ candidates = append(candidates, navCandidate{rect.PaneID, score})
763+ }
764+ }
765+
766+ return candidates
767+}
768+
769+// evalDirection determines if a candidate rect is in the given direction from activeRect.
770+// Returns: inDirection, primaryDist, perpDist, hasOverlap
771+func evalDirection(
772+ activeRect, rect entity.PaneRect,
773+ dx, dy int,
774+ direction NavigateDirection,
775+) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) {
776+ switch direction {
777+ case NavLeft:
778+ return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
779+ case NavRight:
780+ return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
781+ case NavUp:
782+ return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
783+ case NavDown:
784+ return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
785+ default:
786+ return false, 0, 0, false
787+ }
788+}
789+
768790 // abs returns the absolute value of an integer.
769791 func abs(x int) int {
770792 if x < 0 {
@@ -668,10 +668,11 @@ func (uc *ManagePanesUseCase) ApplyFocusChange(
668 668
669 // NavigateFocusGeometric finds the nearest pane in the given direction using geometry.669 // NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
670 // Algorithm:670 // Algorithm:
671-// 1. Get center of active pane671+// 1. Get active pane rectangle
672 // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)672 // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
673-// 3. Score by: primary_distance * 1000 + perpendicular_distance673+// 3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down)
674-// 4. Return lowest scoring candidate674+// 4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance
675+// 5. Return lowest scoring candidate
675 func (uc *ManagePanesUseCase) NavigateFocusGeometric(676 func (uc *ManagePanesUseCase) NavigateFocusGeometric(
676 ctx context.Context,677 ctx context.Context,
677 input GeometricNavigationInput,678 input GeometricNavigationInput,
@@ -699,50 +700,7 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric(
699 return &GeometricNavigationOutput{Found: false}, nil700 return &GeometricNavigationOutput{Found: false}, nil
700 }701 }
701 702
702- acx, acy := activeRect.Center()703+ candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction)
703-
704- type candidate struct {
705- paneID entity.PaneID
706- score int
707- }
708- var candidates []candidate
709-
710- for _, rect := range input.PaneRects {
711- if rect.PaneID == input.ActivePaneID {
712- continue
713- }
714-
715- cx, cy := rect.Center()
716- dx := cx - acx
717- dy := cy - acy
718-
719- var inDirection bool
720- var primaryDist, perpDist int
721-
722- switch input.Direction {
723- case NavLeft:
724- inDirection = dx < 0
725- primaryDist = abs(dx)
726- perpDist = abs(dy)
727- case NavRight:
728- inDirection = dx > 0
729- primaryDist = abs(dx)
730- perpDist = abs(dy)
731- case NavUp:
732- inDirection = dy < 0
733- primaryDist = abs(dy)
734- perpDist = abs(dx)
735- case NavDown:
736- inDirection = dy > 0
737- primaryDist = abs(dy)
738- perpDist = abs(dx)
739- }
740-
741- if inDirection {
742- score := primaryDist*1000 + perpDist
743- candidates = append(candidates, candidate{rect.PaneID, score})
744- }
745- }
746 704
747 if len(candidates) == 0 {705 if len(candidates) == 0 {
748 log.Debug().Msg("no candidates in direction")706 log.Debug().Msg("no candidates in direction")
@@ -765,6 +723,70 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric(
765 }, nil723 }, nil
766 }724 }
767 725
726+// navCandidate represents a pane candidate for navigation with its score.
727+type navCandidate struct {
728+ paneID entity.PaneID
729+ score int
730+}
731+
732+// scoreNavigationCandidates scores all panes in the given direction from activeRect.
733+// Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down).
734+func scoreNavigationCandidates(
735+ activeRect entity.PaneRect,
736+ paneRects []entity.PaneRect,
737+ direction NavigateDirection,
738+) []navCandidate {
739+ // Large penalty for panes without perpendicular overlap.
740+ // This ensures panes at the same level are always preferred.
741+ const noOverlapPenalty = 10_000_000
742+
743+ acx, acy := activeRect.Center()
744+ var candidates []navCandidate
745+
746+ for _, rect := range paneRects {
747+ if rect.PaneID == activeRect.PaneID {
748+ continue
749+ }
750+
751+ cx, cy := rect.Center()
752+ dx := cx - acx
753+ dy := cy - acy
754+
755+ inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction)
756+
757+ if inDirection {
758+ score := primaryDist*1000 + perpDist
759+ if !hasOverlap {
760+ score += noOverlapPenalty
761+ }
762+ candidates = append(candidates, navCandidate{rect.PaneID, score})
763+ }
764+ }
765+
766+ return candidates
767+}
768+
769+// evalDirection determines if a candidate rect is in the given direction from activeRect.
770+// Returns: inDirection, primaryDist, perpDist, hasOverlap
771+func evalDirection(
772+ activeRect, rect entity.PaneRect,
773+ dx, dy int,
774+ direction NavigateDirection,
775+) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) {
776+ switch direction {
777+ case NavLeft:
778+ return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
779+ case NavRight:
780+ return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
781+ case NavUp:
782+ return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
783+ case NavDown:
784+ return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
785+ default:
786+ return false, 0, 0, false
787+ }
788+}
789+
768 // abs returns the absolute value of an integer.790 // abs returns the absolute value of an integer.
769 func abs(x int) int {791 func abs(x int) int {
770 if x < 0 {792 if x < 0 {
M internal/domain/entity/geometry.go +14 -0
@@ -13,3 +13,17 @@ type PaneRect struct {
1313 func (r PaneRect) Center() (cx, cy int) {
1414 return r.X + r.W/2, r.Y + r.H/2
1515 }
16+
17+// OverlapsVertically returns true if the two rectangles overlap in the Y axis.
18+// Used for left/right navigation to prefer panes at the same vertical level.
19+func (r PaneRect) OverlapsVertically(other PaneRect) bool {
20+ // r spans [r.Y, r.Y+r.H), other spans [other.Y, other.Y+other.H)
21+ return r.Y < other.Y+other.H && other.Y < r.Y+r.H
22+}
23+
24+// OverlapsHorizontally returns true if the two rectangles overlap in the X axis.
25+// Used for up/down navigation to prefer panes at the same horizontal level.
26+func (r PaneRect) OverlapsHorizontally(other PaneRect) bool {
27+ // r spans [r.X, r.X+r.W), other spans [other.X, other.X+other.W)
28+ return r.X < other.X+other.W && other.X < r.X+r.W
29+}
@@ -13,3 +13,17 @@ type PaneRect struct {
13 func (r PaneRect) Center() (cx, cy int) {13 func (r PaneRect) Center() (cx, cy int) {
14 return r.X + r.W/2, r.Y + r.H/214 return r.X + r.W/2, r.Y + r.H/2
15 }15 }
16+
17+// OverlapsVertically returns true if the two rectangles overlap in the Y axis.
18+// Used for left/right navigation to prefer panes at the same vertical level.
19+func (r PaneRect) OverlapsVertically(other PaneRect) bool {
20+ // r spans [r.Y, r.Y+r.H), other spans [other.Y, other.Y+other.H)
21+ return r.Y < other.Y+other.H && other.Y < r.Y+r.H
22+}
23+
24+// OverlapsHorizontally returns true if the two rectangles overlap in the X axis.
25+// Used for up/down navigation to prefer panes at the same horizontal level.
26+func (r PaneRect) OverlapsHorizontally(other PaneRect) bool {
27+ // r spans [r.X, r.X+r.W), other spans [other.X, other.X+other.W)
28+ return r.X < other.X+other.W && other.X < r.X+r.W
29+}

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