Merge pull request #386 from bnema/feat/vim-activate-selected-link
feat(vim): activate selected heading links
16 files · +252 -56
M CHANGELOG.md +1 -1
| @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. | |||
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | 8 | ||
| 9 | - | - **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections. | |
| 9 | + | - **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. Pressing `Enter` on a selected CEF heading opens its associated link before leaving Vim Mode. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections. | |
| 10 | 10 | - **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split. | |
| 11 | 11 | - **WebP favicon decoding**: Added safe WebP decoding for sites that publish WebP favicons. | |
| 12 | 12 | ||
| @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. | |||||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | 8 | ||||
| 9 | - | - **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections. | 9 | + | - **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. Pressing `Enter` on a selected CEF heading opens its associated link before leaving Vim Mode. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections. |
| 10 | - **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split. | 10 | - **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split. | ||
| 11 | - **WebP favicon decoding**: Added safe WebP decoding for sites that publish WebP favicons. | 11 | - **WebP favicon decoding**: Added safe WebP decoding for sites that publish WebP favicons. | ||
| 12 | 12 | ||||
M docs/config/index.md +3 -2
| @@ -449,7 +449,7 @@ desc = "Jump to previous heading" | |||
| 449 | 449 | ||
| 450 | 450 | [workspace.vim_mode.actions.confirm] | |
| 451 | 451 | keys = ["enter"] | |
| 452 | - | desc = "Confirm action" | |
| 452 | + | desc = "Open selected heading link and exit mode" | |
| 453 | 453 | ||
| 454 | 454 | [workspace.vim_mode.actions.cancel] | |
| 455 | 455 | keys = ["escape"] | |
| @@ -460,10 +460,11 @@ Notes: | |||
| 460 | 460 | - Vim Mode scrolls and navigates the active webpage inside the active pane only. | |
| 461 | 461 | - `gi` focuses the next visible editable page input, starting at the first when no input is active and wrapping after the last. | |
| 462 | 462 | - `]]` and `[[` move through visible `h1`–`h6` headings and use the current Dumber theme accent for the selected-heading outline. | |
| 463 | + | - `confirm` opens the first link inside the selected heading, or a link wrapping that heading, then exits Vim Mode. If the selected heading has no link, it only exits the mode. | |
| 463 | 464 | - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused. | |
| 464 | 465 | - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active. | |
| 465 | 466 | - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused. | |
| 466 | - | - Live input, heading, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending. | |
| 467 | + | - Live input, heading, heading-link activation, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending. | |
| 467 | 468 | - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself. | |
| 468 | 469 | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals. | |
| 469 | 470 | - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready. | |
| @@ -449,7 +449,7 @@ desc = "Jump to previous heading" | |||||
| 449 | 449 | ||||
| 450 | [workspace.vim_mode.actions.confirm] | 450 | [workspace.vim_mode.actions.confirm] | ||
| 451 | keys = ["enter"] | 451 | keys = ["enter"] | ||
| 452 | - | desc = "Confirm action" | 452 | + | desc = "Open selected heading link and exit mode" |
| 453 | 453 | ||||
| 454 | [workspace.vim_mode.actions.cancel] | 454 | [workspace.vim_mode.actions.cancel] | ||
| 455 | keys = ["escape"] | 455 | keys = ["escape"] | ||
| @@ -460,10 +460,11 @@ Notes: | |||||
| 460 | - Vim Mode scrolls and navigates the active webpage inside the active pane only. | 460 | - Vim Mode scrolls and navigates the active webpage inside the active pane only. | ||
| 461 | - `gi` focuses the next visible editable page input, starting at the first when no input is active and wrapping after the last. | 461 | - `gi` focuses the next visible editable page input, starting at the first when no input is active and wrapping after the last. | ||
| 462 | - `]]` and `[[` move through visible `h1`–`h6` headings and use the current Dumber theme accent for the selected-heading outline. | 462 | - `]]` and `[[` move through visible `h1`–`h6` headings and use the current Dumber theme accent for the selected-heading outline. | ||
| 463 | + | - `confirm` opens the first link inside the selected heading, or a link wrapping that heading, then exits Vim Mode. If the selected heading has no link, it only exits the mode. | |||
| 463 | - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused. | 464 | - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused. | ||
| 464 | - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active. | 465 | - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active. | ||
| 465 | - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused. | 466 | - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused. | ||
| 466 | - | - Live input, heading, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending. | 467 | + | - Live input, heading, heading-link activation, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending. |
| 467 | - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself. | 468 | - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself. | ||
| 468 | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals. | 469 | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals. | ||
| 469 | - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready. | 470 | - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready. | ||
M docs/getting-started.md +5 -4
| @@ -37,10 +37,11 @@ Vim Mode stays local to the active pane and automatically leaves the mode when f | |||
| 37 | 37 | 3. Use `Shift+J` / `Shift+K` for faster vertical jumps | |
| 38 | 38 | 4. Arrow keys continue to use the browser engine's native page navigation while Vim Mode is active | |
| 39 | 39 | 5. Use `gi` to cycle through visible page inputs; use `]]` and `[[` to move through headings | |
| 40 | - | 6. `Tab` and `Shift+Tab` traverse focusable page controls while a page input is focused | |
| 41 | - | 7. Other app-level shortcuts stay suspended until you leave Vim Mode | |
| 42 | - | 8. Press `Escape` or `Enter` to leave the mode | |
| 43 | - | 9. `Ctrl+Y` can activate Vim Mode even when a page input or editor is already focused | |
| 40 | + | 6. With CEF, press `Enter` to open a link in the selected heading and leave Vim Mode; if the heading has no link, it only leaves the mode | |
| 41 | + | 7. `Tab` and `Shift+Tab` traverse focusable page controls while a page input is focused | |
| 42 | + | 8. Other app-level shortcuts stay suspended until you leave Vim Mode | |
| 43 | + | 9. Press `Escape` to leave the mode without activating the selected heading | |
| 44 | + | 10. `Ctrl+Y` can activate Vim Mode even when a page input or editor is already focused | |
| 44 | 45 | ||
| 45 | 46 | > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the | |
| 46 | 47 | > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller | |
| @@ -37,10 +37,11 @@ Vim Mode stays local to the active pane and automatically leaves the mode when f | |||||
| 37 | 3. Use `Shift+J` / `Shift+K` for faster vertical jumps | 37 | 3. Use `Shift+J` / `Shift+K` for faster vertical jumps | ||
| 38 | 4. Arrow keys continue to use the browser engine's native page navigation while Vim Mode is active | 38 | 4. Arrow keys continue to use the browser engine's native page navigation while Vim Mode is active | ||
| 39 | 5. Use `gi` to cycle through visible page inputs; use `]]` and `[[` to move through headings | 39 | 5. Use `gi` to cycle through visible page inputs; use `]]` and `[[` to move through headings | ||
| 40 | - | 6. `Tab` and `Shift+Tab` traverse focusable page controls while a page input is focused | 40 | + | 6. With CEF, press `Enter` to open a link in the selected heading and leave Vim Mode; if the heading has no link, it only leaves the mode |
| 41 | - | 7. Other app-level shortcuts stay suspended until you leave Vim Mode | 41 | + | 7. `Tab` and `Shift+Tab` traverse focusable page controls while a page input is focused |
| 42 | - | 8. Press `Escape` or `Enter` to leave the mode | 42 | + | 8. Other app-level shortcuts stay suspended until you leave Vim Mode |
| 43 | - | 9. `Ctrl+Y` can activate Vim Mode even when a page input or editor is already focused | 43 | + | 9. Press `Escape` to leave the mode without activating the selected heading |
| 44 | + | 10. `Ctrl+Y` can activate Vim Mode even when a page input or editor is already focused | |||
| 44 | 45 | ||||
| 45 | > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the | 46 | > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the | ||
| 46 | > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller | 47 | > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller | ||
M docs/reference/keybindings.md +3 -3
| @@ -59,7 +59,7 @@ Vim Mode is an explicit Vim-style navigation mode for the active pane only. It a | |||
| 59 | 59 | ||
| 60 | 60 | CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort. | |
| 61 | 61 | ||
| 62 | - | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, and page-focus motions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic motions yet. | |
| 62 | + | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. `Enter` activates the first link inside the selected heading, or a link wrapping that heading, then leaves Vim Mode; without an associated link, it only leaves the mode. `Escape` leaves without activation. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, activation, and page-focus actions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic actions yet. | |
| 63 | 63 | ||
| 64 | 64 | | Action | Keys | | |
| 65 | 65 | |--------|------| | |
| @@ -73,8 +73,8 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||
| 73 | 73 | | Next heading | `]]` | | |
| 74 | 74 | | Previous heading | `[[` | | |
| 75 | 75 | | Page focus traversal | `Tab`, `Shift+Tab` | | |
| 76 | - | | Confirm | `Enter` | | |
| 77 | - | | Cancel | `Escape` | | |
| 76 | + | | Activate selected heading link and exit | `Enter` | | |
| 77 | + | | Exit without activation | `Escape` | | |
| 78 | 78 | ||
| 79 | 79 | ## Resize Mode (`Ctrl+N`) | |
| 80 | 80 | ||
| @@ -59,7 +59,7 @@ Vim Mode is an explicit Vim-style navigation mode for the active pane only. It a | |||||
| 59 | 59 | ||||
| 60 | CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort. | 60 | CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort. | ||
| 61 | 61 | ||||
| 62 | - | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, and page-focus motions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic motions yet. | 62 | + | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. `Enter` activates the first link inside the selected heading, or a link wrapping that heading, then leaves Vim Mode; without an associated link, it only leaves the mode. `Escape` leaves without activation. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, activation, and page-focus actions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic actions yet. |
| 63 | 63 | ||||
| 64 | | Action | Keys | | 64 | | Action | Keys | | ||
| 65 | |--------|------| | 65 | |--------|------| | ||
| @@ -73,8 +73,8 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||||
| 73 | | Next heading | `]]` | | 73 | | Next heading | `]]` | | ||
| 74 | | Previous heading | `[[` | | 74 | | Previous heading | `[[` | | ||
| 75 | | Page focus traversal | `Tab`, `Shift+Tab` | | 75 | | Page focus traversal | `Tab`, `Shift+Tab` | | ||
| 76 | - | | Confirm | `Enter` | | 76 | + | | Activate selected heading link and exit | `Enter` | |
| 77 | - | | Cancel | `Escape` | | 77 | + | | Exit without activation | `Escape` | |
| 78 | 78 | ||||
| 79 | ## Resize Mode (`Ctrl+N`) | 79 | ## Resize Mode (`Ctrl+N`) | ||
| 80 | 80 | ||||
M internal/application/port/webview.go +6 -0
| @@ -465,6 +465,12 @@ type SemanticNavigable interface { | |||
| 465 | 465 | NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error | |
| 466 | 466 | } | |
| 467 | 467 | ||
| 468 | + | // SemanticNavigationActivator is an optional WebView capability for activating | |
| 469 | + | // the link associated with the current semantic navigation target. | |
| 470 | + | type SemanticNavigationActivator interface { | |
| 471 | + | ActivateSemanticNavigationTarget(ctx context.Context) error | |
| 472 | + | } | |
| 473 | + | ||
| 468 | 474 | // SemanticNavigationHighlightClearer is an optional WebView capability for | |
| 469 | 475 | // removing the visual target left by semantic navigation. | |
| 470 | 476 | type SemanticNavigationHighlightClearer interface { | |
| @@ -465,6 +465,12 @@ type SemanticNavigable interface { | |||||
| 465 | NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error | 465 | NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error | ||
| 466 | } | 466 | } | ||
| 467 | 467 | ||||
| 468 | + | // SemanticNavigationActivator is an optional WebView capability for activating | |||
| 469 | + | // the link associated with the current semantic navigation target. | |||
| 470 | + | type SemanticNavigationActivator interface { | |||
| 471 | + | ActivateSemanticNavigationTarget(ctx context.Context) error | |||
| 472 | + | } | |||
| 473 | + | ||||
| 468 | // SemanticNavigationHighlightClearer is an optional WebView capability for | 474 | // SemanticNavigationHighlightClearer is an optional WebView capability for | ||
| 469 | // removing the visual target left by semantic navigation. | 475 | // removing the visual target left by semantic navigation. | ||
| 470 | type SemanticNavigationHighlightClearer interface { | 476 | type SemanticNavigationHighlightClearer interface { | ||
M internal/application/usecase/vim_navigation.go +8 -1
| @@ -29,7 +29,14 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio | |||
| 29 | 29 | return errors.New("vim navigation: nil webview") | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | - | if action == "focus-input" { | |
| 32 | + | switch action { | |
| 33 | + | case "confirm": | |
| 34 | + | activator, ok := wv.(port.SemanticNavigationActivator) | |
| 35 | + | if !ok { | |
| 36 | + | return errUnsupportedVimNavigationEngine | |
| 37 | + | } | |
| 38 | + | return activator.ActivateSemanticNavigationTarget(ctx) | |
| 39 | + | case "focus-input": | |
| 33 | 40 | focuser, ok := wv.(port.PageInputFocuser) | |
| 34 | 41 | if !ok { | |
| 35 | 42 | return errUnsupportedVimNavigationEngine | |
| @@ -29,7 +29,14 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio | |||||
| 29 | return errors.New("vim navigation: nil webview") | 29 | return errors.New("vim navigation: nil webview") | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | - | if action == "focus-input" { | 32 | + | switch action { |
| 33 | + | case "confirm": | |||
| 34 | + | activator, ok := wv.(port.SemanticNavigationActivator) | |||
| 35 | + | if !ok { | |||
| 36 | + | return errUnsupportedVimNavigationEngine | |||
| 37 | + | } | |||
| 38 | + | return activator.ActivateSemanticNavigationTarget(ctx) | |||
| 39 | + | case "focus-input": | |||
| 33 | focuser, ok := wv.(port.PageInputFocuser) | 40 | focuser, ok := wv.(port.PageInputFocuser) | ||
| 34 | if !ok { | 41 | if !ok { | ||
| 35 | return errUnsupportedVimNavigationEngine | 42 | return errUnsupportedVimNavigationEngine | ||
M internal/application/usecase/vim_navigation_test.go +8 -0
| @@ -15,6 +15,7 @@ type vimNavigationWebView struct { | |||
| 15 | 15 | focusNextInputCalls int | |
| 16 | 16 | pageFocusBackward []bool | |
| 17 | 17 | semanticRequests []dto.SemanticNavigationRequest | |
| 18 | + | activateNavigationTargetCalls int | |
| 18 | 19 | clearNavigationHighlightCalls int | |
| 19 | 20 | } | |
| 20 | 21 | ||
| @@ -31,6 +32,11 @@ func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto. | |||
| 31 | 32 | return nil | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 35 | + | func (wv *vimNavigationWebView) ActivateSemanticNavigationTarget(context.Context) error { | |
| 36 | + | wv.activateNavigationTargetCalls++ | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | ||
| 34 | 40 | func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error { | |
| 35 | 41 | wv.clearNavigationHighlightCalls++ | |
| 36 | 42 | return nil | |
| @@ -42,8 +48,10 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |||
| 42 | 48 | ||
| 43 | 49 | require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, "")) | |
| 44 | 50 | require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")) | |
| 51 | + | require.NoError(t, uc.Execute(context.Background(), wv, "confirm", 1, "")) | |
| 45 | 52 | ||
| 46 | 53 | assert.Equal(t, 1, wv.focusNextInputCalls) | |
| 54 | + | assert.Equal(t, 1, wv.activateNavigationTargetCalls) | |
| 47 | 55 | require.Len(t, wv.semanticRequests, 1) | |
| 48 | 56 | assert.Equal(t, dto.SemanticNavigationRequest{ | |
| 49 | 57 | Target: dto.SemanticNavigationTargetHeading, | |
| @@ -15,6 +15,7 @@ type vimNavigationWebView struct { | |||||
| 15 | focusNextInputCalls int | 15 | focusNextInputCalls int | ||
| 16 | pageFocusBackward []bool | 16 | pageFocusBackward []bool | ||
| 17 | semanticRequests []dto.SemanticNavigationRequest | 17 | semanticRequests []dto.SemanticNavigationRequest | ||
| 18 | + | activateNavigationTargetCalls int | |||
| 18 | clearNavigationHighlightCalls int | 19 | clearNavigationHighlightCalls int | ||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| @@ -31,6 +32,11 @@ func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto. | |||||
| 31 | return nil | 32 | return nil | ||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 35 | + | func (wv *vimNavigationWebView) ActivateSemanticNavigationTarget(context.Context) error { | |||
| 36 | + | wv.activateNavigationTargetCalls++ | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 34 | func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error { | 40 | func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error { | ||
| 35 | wv.clearNavigationHighlightCalls++ | 41 | wv.clearNavigationHighlightCalls++ | ||
| 36 | return nil | 42 | return nil | ||
| @@ -42,8 +48,10 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |||||
| 42 | 48 | ||||
| 43 | require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, "")) | 49 | require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, "")) | ||
| 44 | require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")) | 50 | require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")) | ||
| 51 | + | require.NoError(t, uc.Execute(context.Background(), wv, "confirm", 1, "")) | |||
| 45 | 52 | ||||
| 46 | assert.Equal(t, 1, wv.focusNextInputCalls) | 53 | assert.Equal(t, 1, wv.focusNextInputCalls) | ||
| 54 | + | assert.Equal(t, 1, wv.activateNavigationTargetCalls) | |||
| 47 | require.Len(t, wv.semanticRequests, 1) | 55 | require.Len(t, wv.semanticRequests, 1) | ||
| 48 | assert.Equal(t, dto.SemanticNavigationRequest{ | 56 | assert.Equal(t, dto.SemanticNavigationRequest{ | ||
| 49 | Target: dto.SemanticNavigationTargetHeading, | 57 | Target: dto.SemanticNavigationTargetHeading, | ||
M internal/infrastructure/cef/semantic_navigation.go +22 -0
| @@ -32,6 +32,15 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNav | |||
| 32 | 32 | return nil | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | + | // ActivateSemanticNavigationTarget follows the link associated with the most | |
| 36 | + | // recent Vim semantic navigation target, if that target has one. | |
| 37 | + | func (wv *WebView) ActivateSemanticNavigationTarget(_ context.Context) error { | |
| 38 | + | if wv == nil || wv.destroyed.Load() { | |
| 39 | + | return errDestroyed | |
| 40 | + | } | |
| 41 | + | return wv.scheduleJavaScript(activateSemanticNavigationTargetScript(wv.vimHeadingHighlightNamespace())) | |
| 42 | + | } | |
| 43 | + | ||
| 35 | 44 | // ClearSemanticNavigationHighlight removes the visual target left by the most | |
| 36 | 45 | // recent Vim semantic navigation in this WebView. | |
| 37 | 46 | func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error { | |
| @@ -135,6 +144,19 @@ func headingNavigationScript(direction, count int, highlightColor, namespace str | |||
| 135 | 144 | })();` | |
| 136 | 145 | } | |
| 137 | 146 | ||
| 147 | + | func activateSemanticNavigationTargetScript(namespace string) string { | |
| 148 | + | return `(() => { | |
| 149 | + | const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `; | |
| 150 | + | const target = window[stateKey]; | |
| 151 | + | if (!(target instanceof Element) || !target.isConnected) return; | |
| 152 | + | ||
| 153 | + | let link = target.closest("a[href]"); | |
| 154 | + | if (!link) link = target.querySelector("a[href]"); | |
| 155 | + | if (!link) return; | |
| 156 | + | link.click(); | |
| 157 | + | })();` | |
| 158 | + | } | |
| 159 | + | ||
| 138 | 160 | func clearHeadingNavigationHighlightScript(namespace string) string { | |
| 139 | 161 | return `(() => { | |
| 140 | 162 | const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `; | |
| @@ -32,6 +32,15 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNav | |||||
| 32 | return nil | 32 | return nil | ||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | + | // ActivateSemanticNavigationTarget follows the link associated with the most | |||
| 36 | + | // recent Vim semantic navigation target, if that target has one. | |||
| 37 | + | func (wv *WebView) ActivateSemanticNavigationTarget(_ context.Context) error { | |||
| 38 | + | if wv == nil || wv.destroyed.Load() { | |||
| 39 | + | return errDestroyed | |||
| 40 | + | } | |||
| 41 | + | return wv.scheduleJavaScript(activateSemanticNavigationTargetScript(wv.vimHeadingHighlightNamespace())) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 35 | // ClearSemanticNavigationHighlight removes the visual target left by the most | 44 | // ClearSemanticNavigationHighlight removes the visual target left by the most | ||
| 36 | // recent Vim semantic navigation in this WebView. | 45 | // recent Vim semantic navigation in this WebView. | ||
| 37 | func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error { | 46 | func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error { | ||
| @@ -135,6 +144,19 @@ func headingNavigationScript(direction, count int, highlightColor, namespace str | |||||
| 135 | })();` | 144 | })();` | ||
| 136 | } | 145 | } | ||
| 137 | 146 | ||||
| 147 | + | func activateSemanticNavigationTargetScript(namespace string) string { | |||
| 148 | + | return `(() => { | |||
| 149 | + | const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `; | |||
| 150 | + | const target = window[stateKey]; | |||
| 151 | + | if (!(target instanceof Element) || !target.isConnected) return; | |||
| 152 | + | ||||
| 153 | + | let link = target.closest("a[href]"); | |||
| 154 | + | if (!link) link = target.querySelector("a[href]"); | |||
| 155 | + | if (!link) return; | |||
| 156 | + | link.click(); | |||
| 157 | + | })();` | |||
| 158 | + | } | |||
| 159 | + | ||||
| 138 | func clearHeadingNavigationHighlightScript(namespace string) string { | 160 | func clearHeadingNavigationHighlightScript(namespace string) string { | ||
| 139 | return `(() => { | 161 | return `(() => { | ||
| 140 | const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `; | 162 | const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `; | ||
M internal/infrastructure/cef/semantic_navigation_test.go +15 -0
| @@ -43,6 +43,21 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin | |||
| 43 | 43 | require.NoError(t, err) | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | + | func TestWebViewActivateSemanticNavigationTargetClicksSelectedHeadingLink(t *testing.T) { | |
| 47 | + | browser := cefmocks.NewMockBrowser(t) | |
| 48 | + | frame := cefmocks.NewMockFrame(t) | |
| 49 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 50 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |
| 51 | + | "const target = window[stateKey]", | |
| 52 | + | "target.closest(\"a[href]\")", | |
| 53 | + | "target.querySelector(\"a[href]\")", | |
| 54 | + | "link.click()", | |
| 55 | + | )), "", int32(0)).Once() | |
| 56 | + | ||
| 57 | + | wv := &WebView{browser: browser} | |
| 58 | + | require.NoError(t, wv.ActivateSemanticNavigationTarget(context.Background())) | |
| 59 | + | } | |
| 60 | + | ||
| 46 | 61 | func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) { | |
| 47 | 62 | browser := cefmocks.NewMockBrowser(t) | |
| 48 | 63 | frame := cefmocks.NewMockFrame(t) | |
| @@ -43,6 +43,21 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin | |||||
| 43 | require.NoError(t, err) | 43 | require.NoError(t, err) | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | + | func TestWebViewActivateSemanticNavigationTargetClicksSelectedHeadingLink(t *testing.T) { | |||
| 47 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 48 | + | frame := cefmocks.NewMockFrame(t) | |||
| 49 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 50 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |||
| 51 | + | "const target = window[stateKey]", | |||
| 52 | + | "target.closest(\"a[href]\")", | |||
| 53 | + | "target.querySelector(\"a[href]\")", | |||
| 54 | + | "link.click()", | |||
| 55 | + | )), "", int32(0)).Once() | |||
| 56 | + | ||||
| 57 | + | wv := &WebView{browser: browser} | |||
| 58 | + | require.NoError(t, wv.ActivateSemanticNavigationTarget(context.Background())) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 46 | func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) { | 61 | func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) { | ||
| 47 | browser := cefmocks.NewMockBrowser(t) | 62 | browser := cefmocks.NewMockBrowser(t) | ||
| 48 | frame := cefmocks.NewMockFrame(t) | 63 | frame := cefmocks.NewMockFrame(t) | ||
M internal/infrastructure/config/defaults.go +1 -1
| @@ -316,7 +316,7 @@ func DefaultConfig() *Config { | |||
| 316 | 316 | "vim-scroll-right": {Keys: []string{"l"}, Desc: "Scroll page right"}, | |
| 317 | 317 | "vim-scroll-down-fast": {Keys: []string{"shift+j"}, Desc: "Scroll page down fast"}, | |
| 318 | 318 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"}, | |
| 319 | - | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | |
| 319 | + | "confirm": {Keys: []string{"enter"}, Desc: "Open selected heading link and exit mode"}, | |
| 320 | 320 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | |
| 321 | 321 | "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"}, | |
| 322 | 322 | "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"}, | |
| @@ -316,7 +316,7 @@ func DefaultConfig() *Config { | |||||
| 316 | "vim-scroll-right": {Keys: []string{"l"}, Desc: "Scroll page right"}, | 316 | "vim-scroll-right": {Keys: []string{"l"}, Desc: "Scroll page right"}, | ||
| 317 | "vim-scroll-down-fast": {Keys: []string{"shift+j"}, Desc: "Scroll page down fast"}, | 317 | "vim-scroll-down-fast": {Keys: []string{"shift+j"}, Desc: "Scroll page down fast"}, | ||
| 318 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"}, | 318 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"}, | ||
| 319 | - | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | 319 | + | "confirm": {Keys: []string{"enter"}, Desc: "Open selected heading link and exit mode"}, |
| 320 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | 320 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | ||
| 321 | "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"}, | 321 | "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"}, | ||
| 322 | "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"}, | 322 | "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"}, | ||
M internal/ui/app_vim_mode_test.go +16 -0
| @@ -177,6 +177,7 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() { | |||
| 177 | 177 | type semanticNavigatingWebView struct { | |
| 178 | 178 | *portmocks.MockWebView | |
| 179 | 179 | requests []dto.SemanticNavigationRequest | |
| 180 | + | activateNavigationTargetCalls int | |
| 180 | 181 | clearNavigationHighlightCalls int | |
| 181 | 182 | } | |
| 182 | 183 | ||
| @@ -192,6 +193,11 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request | |||
| 192 | 193 | return nil | |
| 193 | 194 | } | |
| 194 | 195 | ||
| 196 | + | func (wv *semanticNavigatingWebView) ActivateSemanticNavigationTarget(context.Context) error { | |
| 197 | + | wv.activateNavigationTargetCalls++ | |
| 198 | + | return nil | |
| 199 | + | } | |
| 200 | + | ||
| 195 | 201 | func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error { | |
| 196 | 202 | wv.clearNavigationHighlightCalls++ | |
| 197 | 203 | return nil | |
| @@ -513,6 +519,16 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |||
| 513 | 519 | }, wv.requests) | |
| 514 | 520 | } | |
| 515 | 521 | ||
| 522 | + | func TestVimMode_ConfirmActivatesSelectedHeadingLink(t *testing.T) { | |
| 523 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 524 | + | wv := newSemanticNavigatingWebView(t, 12) | |
| 525 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 526 | + | ||
| 527 | + | app.navigateVimSequenceAction(context.Background(), bw, "confirm", 0) | |
| 528 | + | ||
| 529 | + | assert.Equal(t, 1, wv.activateNavigationTargetCalls) | |
| 530 | + | } | |
| 531 | + | ||
| 516 | 532 | func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) { | |
| 517 | 533 | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 518 | 534 | wv := newInputFocusingWebView(t, 6) | |
| @@ -177,6 +177,7 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() { | |||||
| 177 | type semanticNavigatingWebView struct { | 177 | type semanticNavigatingWebView struct { | ||
| 178 | *portmocks.MockWebView | 178 | *portmocks.MockWebView | ||
| 179 | requests []dto.SemanticNavigationRequest | 179 | requests []dto.SemanticNavigationRequest | ||
| 180 | + | activateNavigationTargetCalls int | |||
| 180 | clearNavigationHighlightCalls int | 181 | clearNavigationHighlightCalls int | ||
| 181 | } | 182 | } | ||
| 182 | 183 | ||||
| @@ -192,6 +193,11 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request | |||||
| 192 | return nil | 193 | return nil | ||
| 193 | } | 194 | } | ||
| 194 | 195 | ||||
| 196 | + | func (wv *semanticNavigatingWebView) ActivateSemanticNavigationTarget(context.Context) error { | |||
| 197 | + | wv.activateNavigationTargetCalls++ | |||
| 198 | + | return nil | |||
| 199 | + | } | |||
| 200 | + | ||||
| 195 | func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error { | 201 | func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error { | ||
| 196 | wv.clearNavigationHighlightCalls++ | 202 | wv.clearNavigationHighlightCalls++ | ||
| 197 | return nil | 203 | return nil | ||
| @@ -513,6 +519,16 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |||||
| 513 | }, wv.requests) | 519 | }, wv.requests) | ||
| 514 | } | 520 | } | ||
| 515 | 521 | ||||
| 522 | + | func TestVimMode_ConfirmActivatesSelectedHeadingLink(t *testing.T) { | |||
| 523 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 524 | + | wv := newSemanticNavigatingWebView(t, 12) | |||
| 525 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 526 | + | ||||
| 527 | + | app.navigateVimSequenceAction(context.Background(), bw, "confirm", 0) | |||
| 528 | + | ||||
| 529 | + | assert.Equal(t, 1, wv.activateNavigationTargetCalls) | |||
| 530 | + | } | |||
| 531 | + | ||||
| 516 | func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) { | 532 | func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) { | ||
| 517 | app, bw, paneID := newVimModeAccessibilityFixture(t) | 533 | app, bw, paneID := newVimModeAccessibilityFixture(t) | ||
| 518 | wv := newInputFocusingWebView(t, 6) | 534 | wv := newInputFocusingWebView(t, 6) | ||
M internal/ui/input/keyboard.go +67 -23
| @@ -407,17 +407,8 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 407 | 407 | } | |
| 408 | 408 | } | |
| 409 | 409 | ||
| 410 | - | // Escape / Enter should always leave modal modes even if the current config | |
| 411 | - | // omits explicit confirm/cancel bindings. | |
| 412 | - | if mode != ModeNormal && modifiers == 0 { | |
| 413 | - | switch keyval { | |
| 414 | - | case uint(gdk.KEY_Escape), uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 415 | - | if mode == ModeVim { | |
| 416 | - | h.stopVimScrollRepeat() | |
| 417 | - | } | |
| 418 | - | h.modal.ExitMode(h.ctx) | |
| 419 | - | return true | |
| 420 | - | } | |
| 410 | + | if h.handleGuaranteedModalExit(mode, keyval, modifiers) { | |
| 411 | + | return true | |
| 421 | 412 | } | |
| 422 | 413 | ||
| 423 | 414 | // Page focus traversal must stay out of shortcut lookup. The callback keeps | |
| @@ -472,6 +463,32 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 472 | 463 | return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers) | |
| 473 | 464 | } | |
| 474 | 465 | ||
| 466 | + | // handleGuaranteedModalExit preserves Escape and Enter as modal fallbacks even | |
| 467 | + | // when the current configuration omits explicit cancel or confirm bindings. | |
| 468 | + | func (h *KeyboardHandler) handleGuaranteedModalExit(mode Mode, keyval uint, modifiers Modifier) bool { | |
| 469 | + | if mode == ModeNormal || modifiers != 0 { | |
| 470 | + | return false | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | switch keyval { | |
| 474 | + | case uint(gdk.KEY_Escape): | |
| 475 | + | if mode == ModeVim { | |
| 476 | + | h.stopVimScrollRepeat() | |
| 477 | + | } | |
| 478 | + | h.modal.ExitMode(h.ctx) | |
| 479 | + | return true | |
| 480 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 481 | + | if mode == ModeVim { | |
| 482 | + | h.confirmVimMode() | |
| 483 | + | } else { | |
| 484 | + | h.modal.ExitMode(h.ctx) | |
| 485 | + | } | |
| 486 | + | return true | |
| 487 | + | default: | |
| 488 | + | return false | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | ||
| 475 | 492 | func (h *KeyboardHandler) handleShortcutLookupResult( | |
| 476 | 493 | log *zerolog.Logger, | |
| 477 | 494 | action Action, | |
| @@ -669,6 +686,17 @@ func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers | |||
| 669 | 686 | } | |
| 670 | 687 | } | |
| 671 | 688 | ||
| 689 | + | func (h *KeyboardHandler) confirmVimMode() { | |
| 690 | + | h.stopVimScrollRepeat() | |
| 691 | + | h.seq.mu.Lock() | |
| 692 | + | onAction := h.seq.onAction | |
| 693 | + | h.seq.mu.Unlock() | |
| 694 | + | if onAction != nil { | |
| 695 | + | onAction("confirm", 0) | |
| 696 | + | } | |
| 697 | + | h.modal.ExitMode(h.ctx) | |
| 698 | + | } | |
| 699 | + | ||
| 672 | 700 | // dispatchAction dispatches the action and handles mode-related logic. | |
| 673 | 701 | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |
| 674 | 702 | if h.handleModeAction(action) { | |
| @@ -817,12 +845,40 @@ func isVimScrollAction(action Action) bool { | |||
| 817 | 845 | } | |
| 818 | 846 | } | |
| 819 | 847 | ||
| 848 | + | func (h *KeyboardHandler) handleVimModeAction(action Action, workspace *entity.WorkspaceConfig) bool { | |
| 849 | + | switch action { | |
| 850 | + | case ActionEnterVimMode: | |
| 851 | + | if h.modal.Mode() == ModeVim { | |
| 852 | + | h.stopVimScrollRepeat() | |
| 853 | + | h.modal.ExitMode(h.ctx) | |
| 854 | + | return true | |
| 855 | + | } | |
| 856 | + | var timeoutMS int | |
| 857 | + | if workspace != nil { | |
| 858 | + | timeoutMS = workspace.VimMode.TimeoutMilliseconds | |
| 859 | + | } | |
| 860 | + | h.modal.EnterVimMode(h.ctx, time.Duration(timeoutMS)*time.Millisecond) | |
| 861 | + | return true | |
| 862 | + | case ActionVimConfirm: | |
| 863 | + | if h.modal.Mode() == ModeVim { | |
| 864 | + | h.confirmVimMode() | |
| 865 | + | } | |
| 866 | + | return true | |
| 867 | + | default: | |
| 868 | + | return false | |
| 869 | + | } | |
| 870 | + | } | |
| 871 | + | ||
| 820 | 872 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | |
| 821 | 873 | h.mu.RLock() | |
| 822 | 874 | workspace := h.workspace | |
| 823 | 875 | session := h.session | |
| 824 | 876 | h.mu.RUnlock() | |
| 825 | 877 | ||
| 878 | + | if h.handleVimModeAction(action, workspace) { | |
| 879 | + | return true | |
| 880 | + | } | |
| 881 | + | ||
| 826 | 882 | switch action { | |
| 827 | 883 | case ActionEnterTabMode: | |
| 828 | 884 | var ms int | |
| @@ -856,18 +912,6 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||
| 856 | 912 | } | |
| 857 | 913 | h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond) | |
| 858 | 914 | return true | |
| 859 | - | case ActionEnterVimMode: | |
| 860 | - | if h.modal.Mode() == ModeVim { | |
| 861 | - | h.stopVimScrollRepeat() | |
| 862 | - | h.modal.ExitMode(h.ctx) | |
| 863 | - | return true | |
| 864 | - | } | |
| 865 | - | var pgms int | |
| 866 | - | if workspace != nil { | |
| 867 | - | pgms = workspace.VimMode.TimeoutMilliseconds | |
| 868 | - | } | |
| 869 | - | h.modal.EnterVimMode(h.ctx, time.Duration(pgms)*time.Millisecond) | |
| 870 | - | return true | |
| 871 | 915 | case ActionExitMode: | |
| 872 | 916 | if h.modal.Mode() == ModeVim { | |
| 873 | 917 | h.stopVimScrollRepeat() | |
| @@ -407,17 +407,8 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 407 | } | 407 | } | ||
| 408 | } | 408 | } | ||
| 409 | 409 | ||||
| 410 | - | // Escape / Enter should always leave modal modes even if the current config | 410 | + | if h.handleGuaranteedModalExit(mode, keyval, modifiers) { |
| 411 | - | // omits explicit confirm/cancel bindings. | 411 | + | return true |
| 412 | - | if mode != ModeNormal && modifiers == 0 { | |||
| 413 | - | switch keyval { | |||
| 414 | - | case uint(gdk.KEY_Escape), uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |||
| 415 | - | if mode == ModeVim { | |||
| 416 | - | h.stopVimScrollRepeat() | |||
| 417 | - | } | |||
| 418 | - | h.modal.ExitMode(h.ctx) | |||
| 419 | - | return true | |||
| 420 | - | } | |||
| 421 | } | 412 | } | ||
| 422 | 413 | ||||
| 423 | // Page focus traversal must stay out of shortcut lookup. The callback keeps | 414 | // Page focus traversal must stay out of shortcut lookup. The callback keeps | ||
| @@ -472,6 +463,32 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 472 | return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers) | 463 | return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers) | ||
| 473 | } | 464 | } | ||
| 474 | 465 | ||||
| 466 | + | // handleGuaranteedModalExit preserves Escape and Enter as modal fallbacks even | |||
| 467 | + | // when the current configuration omits explicit cancel or confirm bindings. | |||
| 468 | + | func (h *KeyboardHandler) handleGuaranteedModalExit(mode Mode, keyval uint, modifiers Modifier) bool { | |||
| 469 | + | if mode == ModeNormal || modifiers != 0 { | |||
| 470 | + | return false | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | switch keyval { | |||
| 474 | + | case uint(gdk.KEY_Escape): | |||
| 475 | + | if mode == ModeVim { | |||
| 476 | + | h.stopVimScrollRepeat() | |||
| 477 | + | } | |||
| 478 | + | h.modal.ExitMode(h.ctx) | |||
| 479 | + | return true | |||
| 480 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |||
| 481 | + | if mode == ModeVim { | |||
| 482 | + | h.confirmVimMode() | |||
| 483 | + | } else { | |||
| 484 | + | h.modal.ExitMode(h.ctx) | |||
| 485 | + | } | |||
| 486 | + | return true | |||
| 487 | + | default: | |||
| 488 | + | return false | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | ||||
| 475 | func (h *KeyboardHandler) handleShortcutLookupResult( | 492 | func (h *KeyboardHandler) handleShortcutLookupResult( | ||
| 476 | log *zerolog.Logger, | 493 | log *zerolog.Logger, | ||
| 477 | action Action, | 494 | action Action, | ||
| @@ -669,6 +686,17 @@ func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers | |||||
| 669 | } | 686 | } | ||
| 670 | } | 687 | } | ||
| 671 | 688 | ||||
| 689 | + | func (h *KeyboardHandler) confirmVimMode() { | |||
| 690 | + | h.stopVimScrollRepeat() | |||
| 691 | + | h.seq.mu.Lock() | |||
| 692 | + | onAction := h.seq.onAction | |||
| 693 | + | h.seq.mu.Unlock() | |||
| 694 | + | if onAction != nil { | |||
| 695 | + | onAction("confirm", 0) | |||
| 696 | + | } | |||
| 697 | + | h.modal.ExitMode(h.ctx) | |||
| 698 | + | } | |||
| 699 | + | ||||
| 672 | // dispatchAction dispatches the action and handles mode-related logic. | 700 | // dispatchAction dispatches the action and handles mode-related logic. | ||
| 673 | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | 701 | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | ||
| 674 | if h.handleModeAction(action) { | 702 | if h.handleModeAction(action) { | ||
| @@ -817,12 +845,40 @@ func isVimScrollAction(action Action) bool { | |||||
| 817 | } | 845 | } | ||
| 818 | } | 846 | } | ||
| 819 | 847 | ||||
| 848 | + | func (h *KeyboardHandler) handleVimModeAction(action Action, workspace *entity.WorkspaceConfig) bool { | |||
| 849 | + | switch action { | |||
| 850 | + | case ActionEnterVimMode: | |||
| 851 | + | if h.modal.Mode() == ModeVim { | |||
| 852 | + | h.stopVimScrollRepeat() | |||
| 853 | + | h.modal.ExitMode(h.ctx) | |||
| 854 | + | return true | |||
| 855 | + | } | |||
| 856 | + | var timeoutMS int | |||
| 857 | + | if workspace != nil { | |||
| 858 | + | timeoutMS = workspace.VimMode.TimeoutMilliseconds | |||
| 859 | + | } | |||
| 860 | + | h.modal.EnterVimMode(h.ctx, time.Duration(timeoutMS)*time.Millisecond) | |||
| 861 | + | return true | |||
| 862 | + | case ActionVimConfirm: | |||
| 863 | + | if h.modal.Mode() == ModeVim { | |||
| 864 | + | h.confirmVimMode() | |||
| 865 | + | } | |||
| 866 | + | return true | |||
| 867 | + | default: | |||
| 868 | + | return false | |||
| 869 | + | } | |||
| 870 | + | } | |||
| 871 | + | ||||
| 820 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | 872 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | ||
| 821 | h.mu.RLock() | 873 | h.mu.RLock() | ||
| 822 | workspace := h.workspace | 874 | workspace := h.workspace | ||
| 823 | session := h.session | 875 | session := h.session | ||
| 824 | h.mu.RUnlock() | 876 | h.mu.RUnlock() | ||
| 825 | 877 | ||||
| 878 | + | if h.handleVimModeAction(action, workspace) { | |||
| 879 | + | return true | |||
| 880 | + | } | |||
| 881 | + | ||||
| 826 | switch action { | 882 | switch action { | ||
| 827 | case ActionEnterTabMode: | 883 | case ActionEnterTabMode: | ||
| 828 | var ms int | 884 | var ms int | ||
| @@ -856,18 +912,6 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||||
| 856 | } | 912 | } | ||
| 857 | h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond) | 913 | h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond) | ||
| 858 | return true | 914 | return true | ||
| 859 | - | case ActionEnterVimMode: | |||
| 860 | - | if h.modal.Mode() == ModeVim { | |||
| 861 | - | h.stopVimScrollRepeat() | |||
| 862 | - | h.modal.ExitMode(h.ctx) | |||
| 863 | - | return true | |||
| 864 | - | } | |||
| 865 | - | var pgms int | |||
| 866 | - | if workspace != nil { | |||
| 867 | - | pgms = workspace.VimMode.TimeoutMilliseconds | |||
| 868 | - | } | |||
| 869 | - | h.modal.EnterVimMode(h.ctx, time.Duration(pgms)*time.Millisecond) | |||
| 870 | - | return true | |||
| 871 | case ActionExitMode: | 915 | case ActionExitMode: | ||
| 872 | if h.modal.Mode() == ModeVim { | 916 | if h.modal.Mode() == ModeVim { | ||
| 873 | h.stopVimScrollRepeat() | 917 | h.stopVimScrollRepeat() | ||
M internal/ui/input/keyboard_test.go +57 -16
| @@ -580,8 +580,11 @@ func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) { | |||
| 580 | 580 | } | |
| 581 | 581 | ||
| 582 | 582 | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |
| 583 | - | h.SetOnAction(func(ctx context.Context, action Action) error { | |
| 584 | - | return nil | |
| 583 | + | var confirmCalls int | |
| 584 | + | h.SetOnSequenceAction(func(action string, _ int) { | |
| 585 | + | if action == "confirm" { | |
| 586 | + | confirmCalls++ | |
| 587 | + | } | |
| 585 | 588 | }) | |
| 586 | 589 | ||
| 587 | 590 | // Enter vim mode | |
| @@ -598,36 +601,74 @@ func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) { | |||
| 598 | 601 | if h.Mode() != ModeNormal { | |
| 599 | 602 | t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode()) | |
| 600 | 603 | } | |
| 604 | + | if confirmCalls != 0 { | |
| 605 | + | t.Fatalf("confirm calls after Escape = %d, want 0", confirmCalls) | |
| 606 | + | } | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | func TestHandleKeyPress_VimModeEnterConfirmsAndExits(t *testing.T) { | |
| 610 | + | for _, tt := range []struct { | |
| 611 | + | name string | |
| 612 | + | keyval uint | |
| 613 | + | }{ | |
| 614 | + | {name: "return", keyval: uint(gdk.KEY_Return)}, | |
| 615 | + | {name: "keypad enter", keyval: uint(gdk.KEY_KP_Enter)}, | |
| 616 | + | } { | |
| 617 | + | t.Run(tt.name, func(t *testing.T) { | |
| 618 | + | ctx := context.Background() | |
| 619 | + | workspace := newTestWorkspace() | |
| 620 | + | workspace.VimMode = entity.VimModeConfig{ | |
| 621 | + | ActivationShortcut: "ctrl+y", | |
| 622 | + | Actions: map[string]entity.ActionBinding{ | |
| 623 | + | "confirm": {Keys: []string{"enter"}}, | |
| 624 | + | }, | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |
| 628 | + | var confirmed bool | |
| 629 | + | h.SetOnSequenceAction(func(action string, count int) { | |
| 630 | + | confirmed = action == "confirm" && count == 0 | |
| 631 | + | }) | |
| 632 | + | enterVimMode(t, h) | |
| 633 | + | ||
| 634 | + | if !h.handleKeyPress(tt.keyval, 0, 0) { | |
| 635 | + | t.Fatal("Enter should be consumed") | |
| 636 | + | } | |
| 637 | + | if h.Mode() != ModeNormal { | |
| 638 | + | t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode()) | |
| 639 | + | } | |
| 640 | + | if !confirmed { | |
| 641 | + | t.Fatal("Enter should dispatch Vim confirm before exiting") | |
| 642 | + | } | |
| 643 | + | }) | |
| 644 | + | } | |
| 601 | 645 | } | |
| 602 | 646 | ||
| 603 | - | func TestHandleKeyPress_VimModeEnterExits(t *testing.T) { | |
| 647 | + | func TestHandleKeyPress_VimModeConfiguredConfirmActivatesAndExits(t *testing.T) { | |
| 604 | 648 | ctx := context.Background() | |
| 605 | 649 | workspace := newTestWorkspace() | |
| 606 | 650 | workspace.VimMode = entity.VimModeConfig{ | |
| 607 | 651 | ActivationShortcut: "ctrl+y", | |
| 608 | 652 | Actions: map[string]entity.ActionBinding{ | |
| 609 | - | "confirm": {Keys: []string{"enter"}}, | |
| 653 | + | "confirm": {Keys: []string{"x"}}, | |
| 610 | 654 | }, | |
| 611 | 655 | } | |
| 612 | 656 | ||
| 613 | 657 | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |
| 614 | - | h.SetOnAction(func(ctx context.Context, action Action) error { | |
| 615 | - | return nil | |
| 658 | + | var confirmed bool | |
| 659 | + | h.SetOnSequenceAction(func(action string, count int) { | |
| 660 | + | confirmed = action == "confirm" && count == 0 | |
| 616 | 661 | }) | |
| 662 | + | enterVimMode(t, h) | |
| 617 | 663 | ||
| 618 | - | // Enter vim mode | |
| 619 | - | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |
| 620 | - | if h.Mode() != ModeVim { | |
| 621 | - | t.Fatal("failed to enter vim mode") | |
| 664 | + | if !h.handleKeyPress(uint('x'), 0, 0) { | |
| 665 | + | t.Fatal("configured confirm should be consumed") | |
| 622 | 666 | } | |
| 623 | - | ||
| 624 | - | // Enter should exit vim mode | |
| 625 | - | result := h.handleKeyPress(uint(gdk.KEY_Return), 0, 0) | |
| 626 | - | if !result { | |
| 627 | - | t.Fatal("Enter should be consumed") | |
| 667 | + | if !confirmed { | |
| 668 | + | t.Fatal("configured confirm should dispatch Vim confirm") | |
| 628 | 669 | } | |
| 629 | 670 | if h.Mode() != ModeNormal { | |
| 630 | - | t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode()) | |
| 671 | + | t.Fatalf("mode after configured confirm = %v, want ModeNormal", h.Mode()) | |
| 631 | 672 | } | |
| 632 | 673 | } | |
| 633 | 674 | ||
| @@ -580,8 +580,11 @@ func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) { | |||||
| 580 | } | 580 | } | ||
| 581 | 581 | ||||
| 582 | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | 582 | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | ||
| 583 | - | h.SetOnAction(func(ctx context.Context, action Action) error { | 583 | + | var confirmCalls int |
| 584 | - | return nil | 584 | + | h.SetOnSequenceAction(func(action string, _ int) { |
| 585 | + | if action == "confirm" { | |||
| 586 | + | confirmCalls++ | |||
| 587 | + | } | |||
| 585 | }) | 588 | }) | ||
| 586 | 589 | ||||
| 587 | // Enter vim mode | 590 | // Enter vim mode | ||
| @@ -598,36 +601,74 @@ func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) { | |||||
| 598 | if h.Mode() != ModeNormal { | 601 | if h.Mode() != ModeNormal { | ||
| 599 | t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode()) | 602 | t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode()) | ||
| 600 | } | 603 | } | ||
| 604 | + | if confirmCalls != 0 { | |||
| 605 | + | t.Fatalf("confirm calls after Escape = %d, want 0", confirmCalls) | |||
| 606 | + | } | |||
| 607 | + | } | |||
| 608 | + | ||||
| 609 | + | func TestHandleKeyPress_VimModeEnterConfirmsAndExits(t *testing.T) { | |||
| 610 | + | for _, tt := range []struct { | |||
| 611 | + | name string | |||
| 612 | + | keyval uint | |||
| 613 | + | }{ | |||
| 614 | + | {name: "return", keyval: uint(gdk.KEY_Return)}, | |||
| 615 | + | {name: "keypad enter", keyval: uint(gdk.KEY_KP_Enter)}, | |||
| 616 | + | } { | |||
| 617 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 618 | + | ctx := context.Background() | |||
| 619 | + | workspace := newTestWorkspace() | |||
| 620 | + | workspace.VimMode = entity.VimModeConfig{ | |||
| 621 | + | ActivationShortcut: "ctrl+y", | |||
| 622 | + | Actions: map[string]entity.ActionBinding{ | |||
| 623 | + | "confirm": {Keys: []string{"enter"}}, | |||
| 624 | + | }, | |||
| 625 | + | } | |||
| 626 | + | ||||
| 627 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 628 | + | var confirmed bool | |||
| 629 | + | h.SetOnSequenceAction(func(action string, count int) { | |||
| 630 | + | confirmed = action == "confirm" && count == 0 | |||
| 631 | + | }) | |||
| 632 | + | enterVimMode(t, h) | |||
| 633 | + | ||||
| 634 | + | if !h.handleKeyPress(tt.keyval, 0, 0) { | |||
| 635 | + | t.Fatal("Enter should be consumed") | |||
| 636 | + | } | |||
| 637 | + | if h.Mode() != ModeNormal { | |||
| 638 | + | t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode()) | |||
| 639 | + | } | |||
| 640 | + | if !confirmed { | |||
| 641 | + | t.Fatal("Enter should dispatch Vim confirm before exiting") | |||
| 642 | + | } | |||
| 643 | + | }) | |||
| 644 | + | } | |||
| 601 | } | 645 | } | ||
| 602 | 646 | ||||
| 603 | - | func TestHandleKeyPress_VimModeEnterExits(t *testing.T) { | 647 | + | func TestHandleKeyPress_VimModeConfiguredConfirmActivatesAndExits(t *testing.T) { |
| 604 | ctx := context.Background() | 648 | ctx := context.Background() | ||
| 605 | workspace := newTestWorkspace() | 649 | workspace := newTestWorkspace() | ||
| 606 | workspace.VimMode = entity.VimModeConfig{ | 650 | workspace.VimMode = entity.VimModeConfig{ | ||
| 607 | ActivationShortcut: "ctrl+y", | 651 | ActivationShortcut: "ctrl+y", | ||
| 608 | Actions: map[string]entity.ActionBinding{ | 652 | Actions: map[string]entity.ActionBinding{ | ||
| 609 | - | "confirm": {Keys: []string{"enter"}}, | 653 | + | "confirm": {Keys: []string{"x"}}, |
| 610 | }, | 654 | }, | ||
| 611 | } | 655 | } | ||
| 612 | 656 | ||||
| 613 | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | 657 | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | ||
| 614 | - | h.SetOnAction(func(ctx context.Context, action Action) error { | 658 | + | var confirmed bool |
| 615 | - | return nil | 659 | + | h.SetOnSequenceAction(func(action string, count int) { |
| 660 | + | confirmed = action == "confirm" && count == 0 | |||
| 616 | }) | 661 | }) | ||
| 662 | + | enterVimMode(t, h) | |||
| 617 | 663 | ||||
| 618 | - | // Enter vim mode | 664 | + | if !h.handleKeyPress(uint('x'), 0, 0) { |
| 619 | - | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | 665 | + | t.Fatal("configured confirm should be consumed") |
| 620 | - | if h.Mode() != ModeVim { | |||
| 621 | - | t.Fatal("failed to enter vim mode") | |||
| 622 | } | 666 | } | ||
| 623 | - | 667 | + | if !confirmed { | |
| 624 | - | // Enter should exit vim mode | 668 | + | t.Fatal("configured confirm should dispatch Vim confirm") |
| 625 | - | result := h.handleKeyPress(uint(gdk.KEY_Return), 0, 0) | |||
| 626 | - | if !result { | |||
| 627 | - | t.Fatal("Enter should be consumed") | |||
| 628 | } | 669 | } | ||
| 629 | if h.Mode() != ModeNormal { | 670 | if h.Mode() != ModeNormal { | ||
| 630 | - | t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode()) | 671 | + | t.Fatalf("mode after configured confirm = %v, want ModeNormal", h.Mode()) |
| 631 | } | 672 | } | ||
| 632 | } | 673 | } | ||
| 633 | 674 | ||||
M internal/ui/input/shortcuts.go +9 -1
| @@ -206,6 +206,7 @@ const ( | |||
| 206 | 206 | ||
| 207 | 207 | // Vim mode actions | |
| 208 | 208 | ActionEnterVimMode Action = "enter_vim_mode" | |
| 209 | + | ActionVimConfirm Action = "vim_confirm" | |
| 209 | 210 | ActionVimScrollLeft Action = "vim_scroll_left" | |
| 210 | 211 | ActionVimScrollDown Action = "vim_scroll_down" | |
| 211 | 212 | ActionVimScrollUp Action = "vim_scroll_up" | |
| @@ -589,7 +590,7 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin | |||
| 589 | 590 | var registered, parseErrors, unknownActions int | |
| 590 | 591 | for key, configAction := range bindings { | |
| 591 | 592 | if binding, ok := ParseKeyString(key); ok { | |
| 592 | - | if action := mapConfigAction(configAction); action != "" { | |
| 593 | + | if action := mapModeConfigAction(configAction, mode); action != "" { | |
| 593 | 594 | dest[binding] = action | |
| 594 | 595 | registered++ | |
| 595 | 596 | log.Trace(). | |
| @@ -760,6 +761,13 @@ func ParseFloatingProfileAction(action Action) (string, bool) { | |||
| 760 | 761 | return target.URL, true | |
| 761 | 762 | } | |
| 762 | 763 | ||
| 764 | + | func mapModeConfigAction(configAction, mode string) Action { | |
| 765 | + | if mode == "vim" && configAction == "confirm" { | |
| 766 | + | return ActionVimConfirm | |
| 767 | + | } | |
| 768 | + | return mapConfigAction(configAction) | |
| 769 | + | } | |
| 770 | + | ||
| 763 | 771 | // mapConfigAction maps config action names to Action constants. | |
| 764 | 772 | func mapConfigAction(configAction string) Action { | |
| 765 | 773 | if configAction == "cancel" || configAction == "confirm" { | |
| @@ -206,6 +206,7 @@ const ( | |||||
| 206 | 206 | ||||
| 207 | // Vim mode actions | 207 | // Vim mode actions | ||
| 208 | ActionEnterVimMode Action = "enter_vim_mode" | 208 | ActionEnterVimMode Action = "enter_vim_mode" | ||
| 209 | + | ActionVimConfirm Action = "vim_confirm" | |||
| 209 | ActionVimScrollLeft Action = "vim_scroll_left" | 210 | ActionVimScrollLeft Action = "vim_scroll_left" | ||
| 210 | ActionVimScrollDown Action = "vim_scroll_down" | 211 | ActionVimScrollDown Action = "vim_scroll_down" | ||
| 211 | ActionVimScrollUp Action = "vim_scroll_up" | 212 | ActionVimScrollUp Action = "vim_scroll_up" | ||
| @@ -589,7 +590,7 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin | |||||
| 589 | var registered, parseErrors, unknownActions int | 590 | var registered, parseErrors, unknownActions int | ||
| 590 | for key, configAction := range bindings { | 591 | for key, configAction := range bindings { | ||
| 591 | if binding, ok := ParseKeyString(key); ok { | 592 | if binding, ok := ParseKeyString(key); ok { | ||
| 592 | - | if action := mapConfigAction(configAction); action != "" { | 593 | + | if action := mapModeConfigAction(configAction, mode); action != "" { |
| 593 | dest[binding] = action | 594 | dest[binding] = action | ||
| 594 | registered++ | 595 | registered++ | ||
| 595 | log.Trace(). | 596 | log.Trace(). | ||
| @@ -760,6 +761,13 @@ func ParseFloatingProfileAction(action Action) (string, bool) { | |||||
| 760 | return target.URL, true | 761 | return target.URL, true | ||
| 761 | } | 762 | } | ||
| 762 | 763 | ||||
| 764 | + | func mapModeConfigAction(configAction, mode string) Action { | |||
| 765 | + | if mode == "vim" && configAction == "confirm" { | |||
| 766 | + | return ActionVimConfirm | |||
| 767 | + | } | |||
| 768 | + | return mapConfigAction(configAction) | |||
| 769 | + | } | |||
| 770 | + | ||||
| 763 | // mapConfigAction maps config action names to Action constants. | 771 | // mapConfigAction maps config action names to Action constants. | ||
| 764 | func mapConfigAction(configAction string) Action { | 772 | func mapConfigAction(configAction string) Action { | ||
| 765 | if configAction == "cancel" || configAction == "confirm" { | 773 | if configAction == "cancel" || configAction == "confirm" { | ||
M internal/ui/input/vimkeys_dispatch.go +6 -2
| @@ -272,7 +272,9 @@ func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierTyp | |||
| 272 | 272 | if onPending != nil { | |
| 273 | 273 | onPending("") | |
| 274 | 274 | } | |
| 275 | - | if onAction != nil { | |
| 275 | + | if action == "confirm" { | |
| 276 | + | h.confirmVimMode() | |
| 277 | + | } else if onAction != nil { | |
| 276 | 278 | onAction(action, count) | |
| 277 | 279 | } | |
| 278 | 280 | log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete") | |
| @@ -343,7 +345,9 @@ func (h *KeyboardHandler) resolveAmbiguity(gen uint64) { | |||
| 343 | 345 | if onPending != nil { | |
| 344 | 346 | onPending("") | |
| 345 | 347 | } | |
| 346 | - | if onAction != nil { | |
| 348 | + | if action == "confirm" { | |
| 349 | + | h.confirmVimMode() | |
| 350 | + | } else if onAction != nil { | |
| 347 | 351 | onAction(action, count) | |
| 348 | 352 | } | |
| 349 | 353 | log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved") | |
| @@ -272,7 +272,9 @@ func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierTyp | |||||
| 272 | if onPending != nil { | 272 | if onPending != nil { | ||
| 273 | onPending("") | 273 | onPending("") | ||
| 274 | } | 274 | } | ||
| 275 | - | if onAction != nil { | 275 | + | if action == "confirm" { |
| 276 | + | h.confirmVimMode() | |||
| 277 | + | } else if onAction != nil { | |||
| 276 | onAction(action, count) | 278 | onAction(action, count) | ||
| 277 | } | 279 | } | ||
| 278 | log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete") | 280 | log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete") | ||
| @@ -343,7 +345,9 @@ func (h *KeyboardHandler) resolveAmbiguity(gen uint64) { | |||||
| 343 | if onPending != nil { | 345 | if onPending != nil { | ||
| 344 | onPending("") | 346 | onPending("") | ||
| 345 | } | 347 | } | ||
| 346 | - | if onAction != nil { | 348 | + | if action == "confirm" { |
| 349 | + | h.confirmVimMode() | |||
| 350 | + | } else if onAction != nil { | |||
| 347 | onAction(action, count) | 351 | onAction(action, count) | ||
| 348 | } | 352 | } | ||
| 349 | log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved") | 353 | log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved") | ||
M internal/ui/input/vimkeys_dispatch_test.go +25 -2
| @@ -275,8 +275,8 @@ func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) { | |||
| 275 | 275 | } | |
| 276 | 276 | ||
| 277 | 277 | enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone} | |
| 278 | - | if action, found := set.VimMode[enterBinding]; !found || action != ActionExitMode { | |
| 279 | - | t.Fatalf("legacy enter missing from VimMode: found=%v action=%q", found, action) | |
| 278 | + | if action, found := set.VimMode[enterBinding]; !found || action != ActionVimConfirm { | |
| 279 | + | t.Fatalf("legacy Vim confirm missing from VimMode: found=%v action=%q", found, action) | |
| 280 | 280 | } | |
| 281 | 281 | ||
| 282 | 282 | escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone} | |
| @@ -360,6 +360,29 @@ func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) { | |||
| 360 | 360 | } | |
| 361 | 361 | } | |
| 362 | 362 | ||
| 363 | + | func TestFeedVimModeSequence_ConfiguredConfirmActivatesAndExits(t *testing.T) { | |
| 364 | + | ctx := context.Background() | |
| 365 | + | ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{ | |
| 366 | + | "confirm": {Keys: []string{"zz"}}, | |
| 367 | + | }) | |
| 368 | + | h := NewKeyboardHandler(ctx, ws, newTestSession()) | |
| 369 | + | actions := captureSequenceActions(h) | |
| 370 | + | enterVimMode(t, h) | |
| 371 | + | ||
| 372 | + | if !h.handleKeyPress(uint('z'), 0, 0) { | |
| 373 | + | t.Fatal("configured confirm prefix should be consumed") | |
| 374 | + | } | |
| 375 | + | if !h.handleKeyPress(uint('z'), 0, 0) { | |
| 376 | + | t.Fatal("configured confirm completion should be consumed") | |
| 377 | + | } | |
| 378 | + | if len(*actions) != 1 || (*actions)[0].action != "confirm" || (*actions)[0].count != 0 { | |
| 379 | + | t.Fatalf("actions = %#v, want confirm count 0", *actions) | |
| 380 | + | } | |
| 381 | + | if h.Mode() != ModeNormal { | |
| 382 | + | t.Fatalf("mode after configured confirm sequence = %v, want ModeNormal", h.Mode()) | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 363 | 386 | func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) { | |
| 364 | 387 | ctx := context.Background() | |
| 365 | 388 | ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{ | |
| @@ -275,8 +275,8 @@ func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) { | |||||
| 275 | } | 275 | } | ||
| 276 | 276 | ||||
| 277 | enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone} | 277 | enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone} | ||
| 278 | - | if action, found := set.VimMode[enterBinding]; !found || action != ActionExitMode { | 278 | + | if action, found := set.VimMode[enterBinding]; !found || action != ActionVimConfirm { |
| 279 | - | t.Fatalf("legacy enter missing from VimMode: found=%v action=%q", found, action) | 279 | + | t.Fatalf("legacy Vim confirm missing from VimMode: found=%v action=%q", found, action) |
| 280 | } | 280 | } | ||
| 281 | 281 | ||||
| 282 | escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone} | 282 | escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone} | ||
| @@ -360,6 +360,29 @@ func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) { | |||||
| 360 | } | 360 | } | ||
| 361 | } | 361 | } | ||
| 362 | 362 | ||||
| 363 | + | func TestFeedVimModeSequence_ConfiguredConfirmActivatesAndExits(t *testing.T) { | |||
| 364 | + | ctx := context.Background() | |||
| 365 | + | ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{ | |||
| 366 | + | "confirm": {Keys: []string{"zz"}}, | |||
| 367 | + | }) | |||
| 368 | + | h := NewKeyboardHandler(ctx, ws, newTestSession()) | |||
| 369 | + | actions := captureSequenceActions(h) | |||
| 370 | + | enterVimMode(t, h) | |||
| 371 | + | ||||
| 372 | + | if !h.handleKeyPress(uint('z'), 0, 0) { | |||
| 373 | + | t.Fatal("configured confirm prefix should be consumed") | |||
| 374 | + | } | |||
| 375 | + | if !h.handleKeyPress(uint('z'), 0, 0) { | |||
| 376 | + | t.Fatal("configured confirm completion should be consumed") | |||
| 377 | + | } | |||
| 378 | + | if len(*actions) != 1 || (*actions)[0].action != "confirm" || (*actions)[0].count != 0 { | |||
| 379 | + | t.Fatalf("actions = %#v, want confirm count 0", *actions) | |||
| 380 | + | } | |||
| 381 | + | if h.Mode() != ModeNormal { | |||
| 382 | + | t.Fatalf("mode after configured confirm sequence = %v, want ModeNormal", h.Mode()) | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | ||||
| 363 | func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) { | 386 | func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) { | ||
| 364 | ctx := context.Background() | 387 | ctx := context.Background() | ||
| 365 | ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{ | 388 | ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{ | ||