fix: geometric focus navigation now prioritizes panes at same level
- commit
- 2f5a503bf8d8c029adb7c079ddd0c382ecc082b8
- author
- brice · · 2026-01-01 13:01 UTC
- parent
- e755f2d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |||
| 13 | 13 | - **Flatpak locale crash**: Attempted fix for crash on systems with certain locale configurations by adding proper locale data to the Flatpak bundle. | |
| 14 | 14 | - **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane. | |
| 15 | 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 | 18 | ## [0.23.2] - 2025-12-31 | |
| 18 | 19 | ||
| @@ -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-31 | 18 | ## [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( | |||
| 668 | 668 | ||
| 669 | 669 | // NavigateFocusGeometric finds the nearest pane in the given direction using geometry. | |
| 670 | 670 | // Algorithm: | |
| 671 | - | // 1. Get center of active pane | |
| 671 | + | // 1. Get active pane rectangle | |
| 672 | 672 | // 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 | |
| 675 | 676 | func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |
| 676 | 677 | ctx context.Context, | |
| 677 | 678 | input GeometricNavigationInput, | |
| @@ -699,50 +700,7 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |||
| 699 | 700 | return &GeometricNavigationOutput{Found: false}, nil | |
| 700 | 701 | } | |
| 701 | 702 | ||
| 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) | |
| 746 | 704 | ||
| 747 | 705 | if len(candidates) == 0 { | |
| 748 | 706 | log.Debug().Msg("no candidates in direction") | |
| @@ -765,6 +723,70 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |||
| 765 | 723 | }, 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 | 790 | // abs returns the absolute value of an integer. | |
| 769 | 791 | func abs(x int) int { | |
| 770 | 792 | 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 pane | 671 | + | // 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_distance | 673 | + | // 3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down) |
| 674 | - | // 4. Return lowest scoring candidate | 674 | + | // 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}, nil | 700 | 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 | }, nil | 723 | }, 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 { | |||
| 13 | 13 | func (r PaneRect) Center() (cx, cy int) { | |
| 14 | 14 | 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 | + | } | |
| @@ -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/2 | 14 | 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 | + | } | |||