Merge pull request #382 from bnema/feat/vim-live-a11y-navigation
feat(vim): add live page navigation and input focus
29 files · +1288 -35
M README.md +1 -1
| @@ -200,7 +200,7 @@ sudo apt install libwebkitgtk-6.0-4 libgtk-4-1 gstreamer1.0-plugins-base gstream | |||
| 200 | 200 | ||
| 201 | 201 | Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend. | |
| 202 | 202 | ||
| 203 | - | Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths. | |
| 203 | + | Set `ENV=dev` to isolate the development process under `.dev/dumber/`. Dumber sets `HOME` plus `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME` there before GTK or the browser engine starts, so development runs do not use production profiles, cookies, caches, or logs. | |
| 204 | 204 | ||
| 205 | 205 | ### Build from source | |
| 206 | 206 | ||
| @@ -200,7 +200,7 @@ sudo apt install libwebkitgtk-6.0-4 libgtk-4-1 gstreamer1.0-plugins-base gstream | |||||
| 200 | 200 | ||||
| 201 | Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend. | 201 | Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend. | ||
| 202 | 202 | ||||
| 203 | - | Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths. | 203 | + | Set `ENV=dev` to isolate the development process under `.dev/dumber/`. Dumber sets `HOME` plus `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME` there before GTK or the browser engine starts, so development runs do not use production profiles, cookies, caches, or logs. |
| 204 | 204 | ||||
| 205 | ### Build from source | 205 | ### Build from source | ||
| 206 | 206 | ||||
M cmd/dumber/main.go +5 -0
| @@ -164,6 +164,11 @@ type startupTiming struct { | |||
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | 166 | func main() { | |
| 167 | + | if err := bootstrap.ApplyDevelopmentEnvironment(); err != nil { | |
| 168 | + | _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |
| 169 | + | os.Exit(1) | |
| 170 | + | } | |
| 171 | + | ||
| 167 | 172 | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | |
| 168 | 173 | // invalidate frames retained across those boundaries and make the GC abort | |
| 169 | 174 | // with "traceback did not unwind completely". Re-exec once so the runtime | |
| @@ -164,6 +164,11 @@ type startupTiming struct { | |||||
| 164 | } | 164 | } | ||
| 165 | 165 | ||||
| 166 | func main() { | 166 | func main() { | ||
| 167 | + | if err := bootstrap.ApplyDevelopmentEnvironment(); err != nil { | |||
| 168 | + | _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |||
| 169 | + | os.Exit(1) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 167 | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | 172 | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | ||
| 168 | // invalidate frames retained across those boundaries and make the GC abort | 173 | // invalidate frames retained across those boundaries and make the GC abort | ||
| 169 | // with "traceback did not unwind completely". Re-exec once so the runtime | 174 | // with "traceback did not unwind completely". Re-exec once so the runtime | ||
M docs/config/index.md +20 -2
| @@ -404,6 +404,8 @@ cancel = ["escape"] | |||
| 404 | 404 | |-----|------|---------|-------------| | |
| 405 | 405 | | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key | | |
| 406 | 406 | | `workspace.vim_mode.timeout_ms` | int | `0` | Vim mode timeout in milliseconds (`0` disables auto-timeout) | | |
| 407 | + | | `workspace.vim_mode.sequence_timeout_ms` | int | `500` | Timeout for ambiguous multi-key Vim sequences | | |
| 408 | + | | `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown | | |
| 407 | 409 | | `workspace.vim_mode.actions` | map | See below | Action→binding mappings | | |
| 408 | 410 | ||
| 409 | 411 | **Default vim mode actions:** | |
| @@ -433,6 +435,18 @@ desc = "Scroll page down fast" | |||
| 433 | 435 | keys = ["shift+k"] | |
| 434 | 436 | desc = "Scroll page up fast" | |
| 435 | 437 | ||
| 438 | + | [workspace.vim_mode.actions.focus-input] | |
| 439 | + | keys = ["gi"] | |
| 440 | + | desc = "Focus next page input" | |
| 441 | + | ||
| 442 | + | [workspace.vim_mode.actions.heading-next] | |
| 443 | + | keys = ["]]"] | |
| 444 | + | desc = "Jump to next heading" | |
| 445 | + | ||
| 446 | + | [workspace.vim_mode.actions.heading-prev] | |
| 447 | + | keys = ["[["] | |
| 448 | + | desc = "Jump to previous heading" | |
| 449 | + | ||
| 436 | 450 | [workspace.vim_mode.actions.confirm] | |
| 437 | 451 | keys = ["enter"] | |
| 438 | 452 | desc = "Confirm action" | |
| @@ -443,9 +457,13 @@ desc = "Cancel/exit mode" | |||
| 443 | 457 | ``` | |
| 444 | 458 | ||
| 445 | 459 | Notes: | |
| 446 | - | - Vim Mode scrolls the active webpage inside the active pane only. | |
| 447 | - | - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. | |
| 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. | |
| 462 | + | - `]]` and `[[` move through visible `h1`–`h6` headings and use the current Dumber theme accent for the selected-heading outline. | |
| 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. | |
| 448 | 464 | - 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 | + | - 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. | |
| 449 | 467 | - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself. | |
| 450 | 468 | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals. | |
| 451 | 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. | |
| @@ -404,6 +404,8 @@ cancel = ["escape"] | |||||
| 404 | |-----|------|---------|-------------| | 404 | |-----|------|---------|-------------| | ||
| 405 | | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key | | 405 | | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key | | ||
| 406 | | `workspace.vim_mode.timeout_ms` | int | `0` | Vim mode timeout in milliseconds (`0` disables auto-timeout) | | 406 | | `workspace.vim_mode.timeout_ms` | int | `0` | Vim mode timeout in milliseconds (`0` disables auto-timeout) | | ||
| 407 | + | | `workspace.vim_mode.sequence_timeout_ms` | int | `500` | Timeout for ambiguous multi-key Vim sequences | | |||
| 408 | + | | `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown | | |||
| 407 | | `workspace.vim_mode.actions` | map | See below | Action→binding mappings | | 409 | | `workspace.vim_mode.actions` | map | See below | Action→binding mappings | | ||
| 408 | 410 | ||||
| 409 | **Default vim mode actions:** | 411 | **Default vim mode actions:** | ||
| @@ -433,6 +435,18 @@ desc = "Scroll page down fast" | |||||
| 433 | keys = ["shift+k"] | 435 | keys = ["shift+k"] | ||
| 434 | desc = "Scroll page up fast" | 436 | desc = "Scroll page up fast" | ||
| 435 | 437 | ||||
| 438 | + | [workspace.vim_mode.actions.focus-input] | |||
| 439 | + | keys = ["gi"] | |||
| 440 | + | desc = "Focus next page input" | |||
| 441 | + | ||||
| 442 | + | [workspace.vim_mode.actions.heading-next] | |||
| 443 | + | keys = ["]]"] | |||
| 444 | + | desc = "Jump to next heading" | |||
| 445 | + | ||||
| 446 | + | [workspace.vim_mode.actions.heading-prev] | |||
| 447 | + | keys = ["[["] | |||
| 448 | + | desc = "Jump to previous heading" | |||
| 449 | + | ||||
| 436 | [workspace.vim_mode.actions.confirm] | 450 | [workspace.vim_mode.actions.confirm] | ||
| 437 | keys = ["enter"] | 451 | keys = ["enter"] | ||
| 438 | desc = "Confirm action" | 452 | desc = "Confirm action" | ||
| @@ -443,9 +457,13 @@ desc = "Cancel/exit mode" | |||||
| 443 | ``` | 457 | ``` | ||
| 444 | 458 | ||||
| 445 | Notes: | 459 | Notes: | ||
| 446 | - | - Vim Mode scrolls the active webpage inside the active pane only. | 460 | + | - Vim Mode scrolls and navigates the active webpage inside the active pane only. |
| 447 | - | - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. | 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. | |||
| 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. | |||
| 448 | - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active. | 464 | - 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 | + | - 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. | |||
| 449 | - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself. | 467 | - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself. | ||
| 450 | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals. | 468 | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals. | ||
| 451 | - 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. | 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. | ||
M docs/getting-started.md +6 -4
| @@ -16,7 +16,7 @@ Dumber uses modal keybindings inspired by Zellij: | |||
| 16 | 16 | |------|------------|---------| | |
| 17 | 17 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | |
| 18 | 18 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | |
| 19 | - | | Vim Mode | `Ctrl+Y` | Scroll the active webpage with `h/j/k/l` and `Shift+J/K`; arrow keys stay native and other app shortcuts wait until exit | | |
| 19 | + | | Vim Mode | `Ctrl+Y` | Scroll, navigate headings, and focus page inputs with Vim-style commands; arrow keys stay native and page focus stays inside the page while other app shortcuts wait until exit | | |
| 20 | 20 | | Resize Mode | `Ctrl+N` | Resize pane splits | | |
| 21 | 21 | | Session Mode | `Ctrl+O` | Session management | | |
| 22 | 22 | ||
| @@ -36,9 +36,11 @@ Vim Mode stays local to the active pane and automatically leaves the mode when f | |||
| 36 | 36 | 2. Use `h`, `j`, `k`, `l` to scroll left, down, up, or right | |
| 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 | - | 5. Other app-level shortcuts stay suspended until you leave Vim Mode | |
| 40 | - | 6. Press `Escape` or `Enter` to leave the mode | |
| 41 | - | 7. If a page input or editor is already focused, `Ctrl+Y` is passed through instead of stealing the keypress | |
| 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 | |
| 42 | 44 | ||
| 43 | 45 | > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the | |
| 44 | 46 | > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller | |
| @@ -16,7 +16,7 @@ Dumber uses modal keybindings inspired by Zellij: | |||||
| 16 | |------|------------|---------| | 16 | |------|------------|---------| | ||
| 17 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | 17 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | ||
| 18 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | 18 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | ||
| 19 | - | | Vim Mode | `Ctrl+Y` | Scroll the active webpage with `h/j/k/l` and `Shift+J/K`; arrow keys stay native and other app shortcuts wait until exit | | 19 | + | | Vim Mode | `Ctrl+Y` | Scroll, navigate headings, and focus page inputs with Vim-style commands; arrow keys stay native and page focus stays inside the page while other app shortcuts wait until exit | |
| 20 | | Resize Mode | `Ctrl+N` | Resize pane splits | | 20 | | Resize Mode | `Ctrl+N` | Resize pane splits | | ||
| 21 | | Session Mode | `Ctrl+O` | Session management | | 21 | | Session Mode | `Ctrl+O` | Session management | | ||
| 22 | 22 | ||||
| @@ -36,9 +36,11 @@ Vim Mode stays local to the active pane and automatically leaves the mode when f | |||||
| 36 | 2. Use `h`, `j`, `k`, `l` to scroll left, down, up, or right | 36 | 2. Use `h`, `j`, `k`, `l` to scroll left, down, up, or right | ||
| 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. Other app-level shortcuts stay suspended until you leave Vim Mode | 39 | + | 5. Use `gi` to cycle through visible page inputs; use `]]` and `[[` to move through headings |
| 40 | - | 6. Press `Escape` or `Enter` to leave the mode | 40 | + | 6. `Tab` and `Shift+Tab` traverse focusable page controls while a page input is focused |
| 41 | - | 7. If a page input or editor is already focused, `Ctrl+Y` is passed through instead of stealing the keypress | 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 | |||
| 42 | 44 | ||||
| 43 | > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the | 45 | > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the | ||
| 44 | > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller | 46 | > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller | ||
M docs/reference/configuration.md +2 -0
| @@ -102,6 +102,8 @@ | |||
| 102 | 102 | | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings | | |
| 103 | 103 | | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | | | |
| 104 | 104 | | `workspace.vim_mode.timeout_ms` | int | `0` | >= 0 | | |
| 105 | + | | `workspace.vim_mode.sequence_timeout_ms` | int | `500` | >= 0; timeout for ambiguous multi-key Vim sequences | | |
| 106 | + | | `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown | | |
| 105 | 107 | | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) | | |
| 106 | 108 | | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | | | |
| 107 | 109 | | `workspace.resize_mode.timeout_ms` | int | `3000` | | | |
| @@ -102,6 +102,8 @@ | |||||
| 102 | | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings | | 102 | | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings | | ||
| 103 | | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | | | 103 | | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | | | ||
| 104 | | `workspace.vim_mode.timeout_ms` | int | `0` | >= 0 | | 104 | | `workspace.vim_mode.timeout_ms` | int | `0` | >= 0 | | ||
| 105 | + | | `workspace.vim_mode.sequence_timeout_ms` | int | `500` | >= 0; timeout for ambiguous multi-key Vim sequences | | |||
| 106 | + | | `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown | | |||
| 105 | | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) | | 107 | | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) | | ||
| 106 | | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | | | 108 | | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | | | ||
| 107 | | `workspace.resize_mode.timeout_ms` | int | `3000` | | | 109 | | `workspace.resize_mode.timeout_ms` | int | `3000` | | | ||
M docs/reference/keybindings.md +18 -3
| @@ -8,12 +8,12 @@ Dumber uses modal keybindings inspired by Zellij. Press a mode activation key, t | |||
| 8 | 8 | |------|-----|---------| | |
| 9 | 9 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | |
| 10 | 10 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | |
| 11 | - | | Vim Mode | `Ctrl+Y` | Scroll the active webpage with `h/j/k/l` and `Shift+J/K`; arrow keys stay native and other app shortcuts wait until exit | | |
| 11 | + | | Vim Mode | `Ctrl+Y` | Scroll, navigate headings, and focus page inputs with Vim-style commands; arrow keys stay native and page focus stays inside the page while other app shortcuts wait until exit | | |
| 12 | 12 | | Resize Mode | `Ctrl+N` | Resize pane splits | | |
| 13 | 13 | | Session Mode | `Ctrl+O` | Session management | | |
| 14 | 14 | ||
| 15 | 15 | Press `Escape` or `Enter` to exit any mode. | |
| 16 | - | Vim Mode activation is passed through when the focused webpage element is already editable, so browser-page editors keep their own `Ctrl+Y` behavior. | |
| 16 | + | `Ctrl+Y` can activate Vim Mode even when a page input is already focused, so you can use Vim commands without first moving focus away from the editor. | |
| 17 | 17 | ||
| 18 | 18 | Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix. | |
| 19 | 19 | ||
| @@ -55,10 +55,12 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke | |||
| 55 | 55 | ||
| 56 | 56 | ## Vim Mode (`Ctrl+Y`) | |
| 57 | 57 | ||
| 58 | - | Vim Mode is an explicit Vim-style scrolling mode for the active pane only. It applies a pane-local accent from `workspace.styling.pane_mode_color`, and when `workspace.styling.mode_indicator_toaster_enabled` is true, shows a persistent bottom-left `VIM MODE` toaster while the mode is active; the toaster hides on exit, when the mode is toggled off, or when the toaster is disabled in config. The mode exits automatically when focus moves into the omnibox, find bar, overlays, or an editable element inside the page. The default `timeout_ms` is `0`, so Vim Mode does not auto-time out unless you configure one. Arrow keys continue to flow through the browser engine's native page-navigation path while Vim Mode is active, while other app-level shortcuts stay suspended until you leave the mode. | |
| 58 | + | Vim Mode is an explicit Vim-style navigation mode for the active pane only. It applies a pane-local accent from `workspace.styling.pane_mode_color`, and when `workspace.styling.mode_indicator_toaster_enabled` is true, shows a persistent bottom-left `VIM MODE` toaster while the mode is active; the toaster hides on exit, when the mode is toggled off, or when the toaster is disabled in config. The mode exits automatically when focus moves into the omnibox, find bar, overlays, or an editable element inside the page. The default `timeout_ms` is `0`, so Vim Mode does not auto-time out unless you configure one. Arrow keys continue to flow through the browser engine's native page-navigation path while Vim Mode is active, while other app-level shortcuts stay suspended until you leave the mode. `Ctrl+Y` remains available when an editable page control is already focused. | |
| 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. | |
| 63 | + | ||
| 62 | 64 | | Action | Keys | | |
| 63 | 65 | |--------|------| | |
| 64 | 66 | | Scroll left | `H` | | |
| @@ -67,6 +69,10 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||
| 67 | 69 | | Scroll right | `L` | | |
| 68 | 70 | | Scroll down fast | `Shift+J` | | |
| 69 | 71 | | Scroll up fast | `Shift+K` | | |
| 72 | + | | Focus next page input | `gi` | | |
| 73 | + | | Next heading | `]]` | | |
| 74 | + | | Previous heading | `[[` | | |
| 75 | + | | Page focus traversal | `Tab`, `Shift+Tab` | | |
| 70 | 76 | | Confirm | `Enter` | | |
| 71 | 77 | | Cancel | `Escape` | | |
| 72 | 78 | ||
| @@ -159,6 +165,15 @@ keys = ["shift+j"] | |||
| 159 | 165 | [workspace.vim_mode.actions.vim-scroll-up-fast] | |
| 160 | 166 | keys = ["shift+k"] | |
| 161 | 167 | ||
| 168 | + | [workspace.vim_mode.actions.focus-input] | |
| 169 | + | keys = ["gi"] | |
| 170 | + | ||
| 171 | + | [workspace.vim_mode.actions.heading-next] | |
| 172 | + | keys = ["]]"] | |
| 173 | + | ||
| 174 | + | [workspace.vim_mode.actions.heading-prev] | |
| 175 | + | keys = ["[["] | |
| 176 | + | ||
| 162 | 177 | [workspace.shortcuts.actions.close-pane] | |
| 163 | 178 | keys = ["ctrl+w"] | |
| 164 | 179 | ||
| @@ -8,12 +8,12 @@ Dumber uses modal keybindings inspired by Zellij. Press a mode activation key, t | |||||
| 8 | |------|-----|---------| | 8 | |------|-----|---------| | ||
| 9 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | 9 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | ||
| 10 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | 10 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | ||
| 11 | - | | Vim Mode | `Ctrl+Y` | Scroll the active webpage with `h/j/k/l` and `Shift+J/K`; arrow keys stay native and other app shortcuts wait until exit | | 11 | + | | Vim Mode | `Ctrl+Y` | Scroll, navigate headings, and focus page inputs with Vim-style commands; arrow keys stay native and page focus stays inside the page while other app shortcuts wait until exit | |
| 12 | | Resize Mode | `Ctrl+N` | Resize pane splits | | 12 | | Resize Mode | `Ctrl+N` | Resize pane splits | | ||
| 13 | | Session Mode | `Ctrl+O` | Session management | | 13 | | Session Mode | `Ctrl+O` | Session management | | ||
| 14 | 14 | ||||
| 15 | Press `Escape` or `Enter` to exit any mode. | 15 | Press `Escape` or `Enter` to exit any mode. | ||
| 16 | - | Vim Mode activation is passed through when the focused webpage element is already editable, so browser-page editors keep their own `Ctrl+Y` behavior. | 16 | + | `Ctrl+Y` can activate Vim Mode even when a page input is already focused, so you can use Vim commands without first moving focus away from the editor. |
| 17 | 17 | ||||
| 18 | Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix. | 18 | Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix. | ||
| 19 | 19 | ||||
| @@ -55,10 +55,12 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke | |||||
| 55 | 55 | ||||
| 56 | ## Vim Mode (`Ctrl+Y`) | 56 | ## Vim Mode (`Ctrl+Y`) | ||
| 57 | 57 | ||||
| 58 | - | Vim Mode is an explicit Vim-style scrolling mode for the active pane only. It applies a pane-local accent from `workspace.styling.pane_mode_color`, and when `workspace.styling.mode_indicator_toaster_enabled` is true, shows a persistent bottom-left `VIM MODE` toaster while the mode is active; the toaster hides on exit, when the mode is toggled off, or when the toaster is disabled in config. The mode exits automatically when focus moves into the omnibox, find bar, overlays, or an editable element inside the page. The default `timeout_ms` is `0`, so Vim Mode does not auto-time out unless you configure one. Arrow keys continue to flow through the browser engine's native page-navigation path while Vim Mode is active, while other app-level shortcuts stay suspended until you leave the mode. | 58 | + | Vim Mode is an explicit Vim-style navigation mode for the active pane only. It applies a pane-local accent from `workspace.styling.pane_mode_color`, and when `workspace.styling.mode_indicator_toaster_enabled` is true, shows a persistent bottom-left `VIM MODE` toaster while the mode is active; the toaster hides on exit, when the mode is toggled off, or when the toaster is disabled in config. The mode exits automatically when focus moves into the omnibox, find bar, overlays, or an editable element inside the page. The default `timeout_ms` is `0`, so Vim Mode does not auto-time out unless you configure one. Arrow keys continue to flow through the browser engine's native page-navigation path while Vim Mode is active, while other app-level shortcuts stay suspended until you leave the mode. `Ctrl+Y` remains available when an editable page control is already focused. |
| 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. | |||
| 63 | + | ||||
| 62 | | Action | Keys | | 64 | | Action | Keys | | ||
| 63 | |--------|------| | 65 | |--------|------| | ||
| 64 | | Scroll left | `H` | | 66 | | Scroll left | `H` | | ||
| @@ -67,6 +69,10 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||||
| 67 | | Scroll right | `L` | | 69 | | Scroll right | `L` | | ||
| 68 | | Scroll down fast | `Shift+J` | | 70 | | Scroll down fast | `Shift+J` | | ||
| 69 | | Scroll up fast | `Shift+K` | | 71 | | Scroll up fast | `Shift+K` | | ||
| 72 | + | | Focus next page input | `gi` | | |||
| 73 | + | | Next heading | `]]` | | |||
| 74 | + | | Previous heading | `[[` | | |||
| 75 | + | | Page focus traversal | `Tab`, `Shift+Tab` | | |||
| 70 | | Confirm | `Enter` | | 76 | | Confirm | `Enter` | | ||
| 71 | | Cancel | `Escape` | | 77 | | Cancel | `Escape` | | ||
| 72 | 78 | ||||
| @@ -159,6 +165,15 @@ keys = ["shift+j"] | |||||
| 159 | [workspace.vim_mode.actions.vim-scroll-up-fast] | 165 | [workspace.vim_mode.actions.vim-scroll-up-fast] | ||
| 160 | keys = ["shift+k"] | 166 | keys = ["shift+k"] | ||
| 161 | 167 | ||||
| 168 | + | [workspace.vim_mode.actions.focus-input] | |||
| 169 | + | keys = ["gi"] | |||
| 170 | + | ||||
| 171 | + | [workspace.vim_mode.actions.heading-next] | |||
| 172 | + | keys = ["]]"] | |||
| 173 | + | ||||
| 174 | + | [workspace.vim_mode.actions.heading-prev] | |||
| 175 | + | keys = ["[["] | |||
| 176 | + | ||||
| 162 | [workspace.shortcuts.actions.close-pane] | 177 | [workspace.shortcuts.actions.close-pane] | ||
| 163 | keys = ["ctrl+w"] | 178 | keys = ["ctrl+w"] | ||
| 164 | 179 | ||||
A internal/application/dto/semantic_navigation.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | // SemanticNavigationTarget identifies a visible document target that Vim Mode | |
| 4 | + | // can navigate without coupling the application layer to a browser engine. | |
| 5 | + | type SemanticNavigationTarget int | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | SemanticNavigationTargetHeading SemanticNavigationTarget = iota | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // SemanticNavigationDirection identifies the strict document-order direction | |
| 12 | + | // for a semantic navigation request. | |
| 13 | + | type SemanticNavigationDirection int | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | SemanticNavigationBackward SemanticNavigationDirection = -1 | |
| 17 | + | SemanticNavigationForward SemanticNavigationDirection = 1 | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // SemanticNavigationRequest describes one Vim structural movement. Count uses | |
| 21 | + | // Vim semantics: adapters treat values less than one as one. | |
| 22 | + | type SemanticNavigationRequest struct { | |
| 23 | + | Target SemanticNavigationTarget | |
| 24 | + | Direction SemanticNavigationDirection | |
| 25 | + | Count int | |
| 26 | + | HighlightColor string | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | // SemanticNavigationTarget identifies a visible document target that Vim Mode | |||
| 4 | + | // can navigate without coupling the application layer to a browser engine. | |||
| 5 | + | type SemanticNavigationTarget int | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | SemanticNavigationTargetHeading SemanticNavigationTarget = iota | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // SemanticNavigationDirection identifies the strict document-order direction | |||
| 12 | + | // for a semantic navigation request. | |||
| 13 | + | type SemanticNavigationDirection int | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | SemanticNavigationBackward SemanticNavigationDirection = -1 | |||
| 17 | + | SemanticNavigationForward SemanticNavigationDirection = 1 | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // SemanticNavigationRequest describes one Vim structural movement. Count uses | |||
| 21 | + | // Vim semantics: adapters treat values less than one as one. | |||
| 22 | + | type SemanticNavigationRequest struct { | |||
| 23 | + | Target SemanticNavigationTarget | |||
| 24 | + | Direction SemanticNavigationDirection | |||
| 25 | + | Count int | |||
| 26 | + | HighlightColor string | |||
| 27 | + | } | |||
M internal/application/port/webview.go +21 -0
| @@ -444,3 +444,24 @@ type PageScrollCanceler interface { | |||
| 444 | 444 | type AccessibilityEnabler interface { | |
| 445 | 445 | EnableAccessibility() | |
| 446 | 446 | } | |
| 447 | + | ||
| 448 | + | // SemanticNavigable is an optional WebView capability for visible structural | |
| 449 | + | // navigation. Unsupported engines safely leave it unimplemented. | |
| 450 | + | type SemanticNavigable interface { | |
| 451 | + | NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // PageInputFocuser is an optional WebView capability for moving Vim Mode | |
| 455 | + | // input focus to the next eligible page input. With no active eligible input, | |
| 456 | + | // implementations focus the first one. Unsupported engines safely leave it | |
| 457 | + | // unimplemented. | |
| 458 | + | type PageInputFocuser interface { | |
| 459 | + | FocusNextInput() | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // PageFocusNavigator is an optional WebView capability for keeping native-like | |
| 463 | + | // focus traversal inside the active page when the host window would otherwise | |
| 464 | + | // move focus outside the WebView. | |
| 465 | + | type PageFocusNavigator interface { | |
| 466 | + | NavigatePageFocus(backward bool) | |
| 467 | + | } | |
| @@ -444,3 +444,24 @@ type PageScrollCanceler interface { | |||||
| 444 | type AccessibilityEnabler interface { | 444 | type AccessibilityEnabler interface { | ||
| 445 | EnableAccessibility() | 445 | EnableAccessibility() | ||
| 446 | } | 446 | } | ||
| 447 | + | ||||
| 448 | + | // SemanticNavigable is an optional WebView capability for visible structural | |||
| 449 | + | // navigation. Unsupported engines safely leave it unimplemented. | |||
| 450 | + | type SemanticNavigable interface { | |||
| 451 | + | NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | // PageInputFocuser is an optional WebView capability for moving Vim Mode | |||
| 455 | + | // input focus to the next eligible page input. With no active eligible input, | |||
| 456 | + | // implementations focus the first one. Unsupported engines safely leave it | |||
| 457 | + | // unimplemented. | |||
| 458 | + | type PageInputFocuser interface { | |||
| 459 | + | FocusNextInput() | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | // PageFocusNavigator is an optional WebView capability for keeping native-like | |||
| 463 | + | // focus traversal inside the active page when the host window would otherwise | |||
| 464 | + | // move focus outside the WebView. | |||
| 465 | + | type PageFocusNavigator interface { | |||
| 466 | + | NavigatePageFocus(backward bool) | |||
| 467 | + | } | |||
M internal/application/usecase/vim_mode_policy.go +3 -3
| @@ -46,9 +46,9 @@ func NewVimModePolicyUseCase() *VimModePolicyUseCase { | |||
| 46 | 46 | func (*VimModePolicyUseCase) Evaluate(input VimModePolicyInput) VimModePolicyTransition { | |
| 47 | 47 | switch input.Trigger { | |
| 48 | 48 | case VimModePolicyTriggerActivationAttempt: | |
| 49 | - | if input.PageEditableFocused { | |
| 50 | - | return VimModePolicyTransitionBlockActivation | |
| 51 | - | } | |
| 49 | + | // Ctrl+Y is the explicit Vim Mode activation command. It must remain | |
| 50 | + | // available while a page input is focused so users can leave typing mode | |
| 51 | + | // without first moving focus elsewhere. | |
| 52 | 52 | return VimModePolicyTransitionStay | |
| 53 | 53 | case VimModePolicyTriggerOmniboxFocus, | |
| 54 | 54 | VimModePolicyTriggerFindBarFocus, | |
| @@ -46,9 +46,9 @@ func NewVimModePolicyUseCase() *VimModePolicyUseCase { | |||||
| 46 | func (*VimModePolicyUseCase) Evaluate(input VimModePolicyInput) VimModePolicyTransition { | 46 | func (*VimModePolicyUseCase) Evaluate(input VimModePolicyInput) VimModePolicyTransition { | ||
| 47 | switch input.Trigger { | 47 | switch input.Trigger { | ||
| 48 | case VimModePolicyTriggerActivationAttempt: | 48 | case VimModePolicyTriggerActivationAttempt: | ||
| 49 | - | if input.PageEditableFocused { | 49 | + | // Ctrl+Y is the explicit Vim Mode activation command. It must remain |
| 50 | - | return VimModePolicyTransitionBlockActivation | 50 | + | // available while a page input is focused so users can leave typing mode |
| 51 | - | } | 51 | + | // without first moving focus elsewhere. |
| 52 | return VimModePolicyTransitionStay | 52 | return VimModePolicyTransitionStay | ||
| 53 | case VimModePolicyTriggerOmniboxFocus, | 53 | case VimModePolicyTriggerOmniboxFocus, | ||
| 54 | VimModePolicyTriggerFindBarFocus, | 54 | VimModePolicyTriggerFindBarFocus, | ||
M internal/application/usecase/vim_mode_policy_test.go +2 -2
| @@ -13,12 +13,12 @@ func TestVimModePolicyEvaluate(t *testing.T) { | |||
| 13 | 13 | want VimModePolicyTransition | |
| 14 | 14 | }{ | |
| 15 | 15 | { | |
| 16 | - | name: "activation blocked while page editable focused", | |
| 16 | + | name: "activation stays allowed while page editable focused", | |
| 17 | 17 | input: VimModePolicyInput{ | |
| 18 | 18 | Trigger: VimModePolicyTriggerActivationAttempt, | |
| 19 | 19 | PageEditableFocused: true, | |
| 20 | 20 | }, | |
| 21 | - | want: VimModePolicyTransitionBlockActivation, | |
| 21 | + | want: VimModePolicyTransitionStay, | |
| 22 | 22 | }, | |
| 23 | 23 | { | |
| 24 | 24 | name: "activation stays allowed when page is not editable", | |
| @@ -13,12 +13,12 @@ func TestVimModePolicyEvaluate(t *testing.T) { | |||||
| 13 | want VimModePolicyTransition | 13 | want VimModePolicyTransition | ||
| 14 | }{ | 14 | }{ | ||
| 15 | { | 15 | { | ||
| 16 | - | name: "activation blocked while page editable focused", | 16 | + | name: "activation stays allowed while page editable focused", |
| 17 | input: VimModePolicyInput{ | 17 | input: VimModePolicyInput{ | ||
| 18 | Trigger: VimModePolicyTriggerActivationAttempt, | 18 | Trigger: VimModePolicyTriggerActivationAttempt, | ||
| 19 | PageEditableFocused: true, | 19 | PageEditableFocused: true, | ||
| 20 | }, | 20 | }, | ||
| 21 | - | want: VimModePolicyTransitionBlockActivation, | 21 | + | want: VimModePolicyTransitionStay, |
| 22 | }, | 22 | }, | ||
| 23 | { | 23 | { | ||
| 24 | name: "activation stays allowed when page is not editable", | 24 | name: "activation stays allowed when page is not editable", | ||
A internal/application/usecase/vim_navigation.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | var errUnsupportedVimNavigationAction = errors.New("vim navigation: unsupported action") | |
| 12 | + | ||
| 13 | + | // VimNavigationUseCase maps configured Vim Mode navigation actions to optional | |
| 14 | + | // WebView capabilities. The UI layer supplies the active WebView and remains | |
| 15 | + | // responsible only for event and theme context. | |
| 16 | + | type VimNavigationUseCase struct{} | |
| 17 | + | ||
| 18 | + | // NewVimNavigationUseCase creates a stateless Vim navigation use case. | |
| 19 | + | func NewVimNavigationUseCase() *VimNavigationUseCase { | |
| 20 | + | return &VimNavigationUseCase{} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Execute dispatches a configured semantic Vim action to the active WebView. | |
| 24 | + | func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error { | |
| 25 | + | if wv == nil { | |
| 26 | + | return errors.New("vim navigation: nil webview") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | if action == "focus-input" { | |
| 30 | + | focuser, ok := wv.(port.PageInputFocuser) | |
| 31 | + | if !ok { | |
| 32 | + | return nil | |
| 33 | + | } | |
| 34 | + | focuser.FocusNextInput() | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | var request dto.SemanticNavigationRequest | |
| 39 | + | switch action { | |
| 40 | + | case "heading-next": | |
| 41 | + | request = dto.SemanticNavigationRequest{ | |
| 42 | + | Target: dto.SemanticNavigationTargetHeading, | |
| 43 | + | Direction: dto.SemanticNavigationForward, | |
| 44 | + | Count: count, | |
| 45 | + | HighlightColor: highlightColor, | |
| 46 | + | } | |
| 47 | + | case "heading-prev": | |
| 48 | + | request = dto.SemanticNavigationRequest{ | |
| 49 | + | Target: dto.SemanticNavigationTargetHeading, | |
| 50 | + | Direction: dto.SemanticNavigationBackward, | |
| 51 | + | Count: count, | |
| 52 | + | HighlightColor: highlightColor, | |
| 53 | + | } | |
| 54 | + | default: | |
| 55 | + | return errUnsupportedVimNavigationAction | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | navigator, ok := wv.(port.SemanticNavigable) | |
| 59 | + | if !ok { | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | return navigator.NavigateSemantic(ctx, request) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // NavigatePageFocus keeps focus traversal inside a page when the active page | |
| 66 | + | // control would otherwise hand the host window a Tab event. | |
| 67 | + | func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool { | |
| 68 | + | if wv == nil { | |
| 69 | + | return false | |
| 70 | + | } | |
| 71 | + | navigator, ok := wv.(port.PageFocusNavigator) | |
| 72 | + | if !ok { | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | navigator.NavigatePageFocus(backward) | |
| 76 | + | return true | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | var errUnsupportedVimNavigationAction = errors.New("vim navigation: unsupported action") | |||
| 12 | + | ||||
| 13 | + | // VimNavigationUseCase maps configured Vim Mode navigation actions to optional | |||
| 14 | + | // WebView capabilities. The UI layer supplies the active WebView and remains | |||
| 15 | + | // responsible only for event and theme context. | |||
| 16 | + | type VimNavigationUseCase struct{} | |||
| 17 | + | ||||
| 18 | + | // NewVimNavigationUseCase creates a stateless Vim navigation use case. | |||
| 19 | + | func NewVimNavigationUseCase() *VimNavigationUseCase { | |||
| 20 | + | return &VimNavigationUseCase{} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Execute dispatches a configured semantic Vim action to the active WebView. | |||
| 24 | + | func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error { | |||
| 25 | + | if wv == nil { | |||
| 26 | + | return errors.New("vim navigation: nil webview") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | if action == "focus-input" { | |||
| 30 | + | focuser, ok := wv.(port.PageInputFocuser) | |||
| 31 | + | if !ok { | |||
| 32 | + | return nil | |||
| 33 | + | } | |||
| 34 | + | focuser.FocusNextInput() | |||
| 35 | + | return nil | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | var request dto.SemanticNavigationRequest | |||
| 39 | + | switch action { | |||
| 40 | + | case "heading-next": | |||
| 41 | + | request = dto.SemanticNavigationRequest{ | |||
| 42 | + | Target: dto.SemanticNavigationTargetHeading, | |||
| 43 | + | Direction: dto.SemanticNavigationForward, | |||
| 44 | + | Count: count, | |||
| 45 | + | HighlightColor: highlightColor, | |||
| 46 | + | } | |||
| 47 | + | case "heading-prev": | |||
| 48 | + | request = dto.SemanticNavigationRequest{ | |||
| 49 | + | Target: dto.SemanticNavigationTargetHeading, | |||
| 50 | + | Direction: dto.SemanticNavigationBackward, | |||
| 51 | + | Count: count, | |||
| 52 | + | HighlightColor: highlightColor, | |||
| 53 | + | } | |||
| 54 | + | default: | |||
| 55 | + | return errUnsupportedVimNavigationAction | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | navigator, ok := wv.(port.SemanticNavigable) | |||
| 59 | + | if !ok { | |||
| 60 | + | return nil | |||
| 61 | + | } | |||
| 62 | + | return navigator.NavigateSemantic(ctx, request) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // NavigatePageFocus keeps focus traversal inside a page when the active page | |||
| 66 | + | // control would otherwise hand the host window a Tab event. | |||
| 67 | + | func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool { | |||
| 68 | + | if wv == nil { | |||
| 69 | + | return false | |||
| 70 | + | } | |||
| 71 | + | navigator, ok := wv.(port.PageFocusNavigator) | |||
| 72 | + | if !ok { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | navigator.NavigatePageFocus(backward) | |||
| 76 | + | return true | |||
| 77 | + | } | |||
A internal/application/usecase/vim_navigation_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type vimNavigationWebView struct { | |
| 14 | + | *portmocks.MockWebView | |
| 15 | + | focusNextInputCalls int | |
| 16 | + | pageFocusBackward []bool | |
| 17 | + | semanticRequests []dto.SemanticNavigationRequest | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (wv *vimNavigationWebView) FocusNextInput() { | |
| 21 | + | wv.focusNextInputCalls++ | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (wv *vimNavigationWebView) NavigatePageFocus(backward bool) { | |
| 25 | + | wv.pageFocusBackward = append(wv.pageFocusBackward, backward) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto.SemanticNavigationRequest) error { | |
| 29 | + | wv.semanticRequests = append(wv.semanticRequests, request) | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |
| 34 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 35 | + | uc := NewVimNavigationUseCase() | |
| 36 | + | ||
| 37 | + | require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, "")) | |
| 38 | + | require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")) | |
| 39 | + | ||
| 40 | + | assert.Equal(t, 1, wv.focusNextInputCalls) | |
| 41 | + | require.Len(t, wv.semanticRequests, 1) | |
| 42 | + | assert.Equal(t, dto.SemanticNavigationRequest{ | |
| 43 | + | Target: dto.SemanticNavigationTargetHeading, | |
| 44 | + | Direction: dto.SemanticNavigationBackward, | |
| 45 | + | Count: 3, | |
| 46 | + | HighlightColor: "#aabbcc", | |
| 47 | + | }, wv.semanticRequests[0]) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) { | |
| 51 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 52 | + | ||
| 53 | + | handled := NewVimNavigationUseCase().NavigatePageFocus(wv, true) | |
| 54 | + | ||
| 55 | + | assert.True(t, handled) | |
| 56 | + | assert.Equal(t, []bool{true}, wv.pageFocusBackward) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) { | |
| 60 | + | wv := portmocks.NewMockWebView(t) | |
| 61 | + | ||
| 62 | + | require.NoError(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, "")) | |
| 63 | + | assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true)) | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type vimNavigationWebView struct { | |||
| 14 | + | *portmocks.MockWebView | |||
| 15 | + | focusNextInputCalls int | |||
| 16 | + | pageFocusBackward []bool | |||
| 17 | + | semanticRequests []dto.SemanticNavigationRequest | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (wv *vimNavigationWebView) FocusNextInput() { | |||
| 21 | + | wv.focusNextInputCalls++ | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (wv *vimNavigationWebView) NavigatePageFocus(backward bool) { | |||
| 25 | + | wv.pageFocusBackward = append(wv.pageFocusBackward, backward) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto.SemanticNavigationRequest) error { | |||
| 29 | + | wv.semanticRequests = append(wv.semanticRequests, request) | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |||
| 34 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |||
| 35 | + | uc := NewVimNavigationUseCase() | |||
| 36 | + | ||||
| 37 | + | require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, "")) | |||
| 38 | + | require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")) | |||
| 39 | + | ||||
| 40 | + | assert.Equal(t, 1, wv.focusNextInputCalls) | |||
| 41 | + | require.Len(t, wv.semanticRequests, 1) | |||
| 42 | + | assert.Equal(t, dto.SemanticNavigationRequest{ | |||
| 43 | + | Target: dto.SemanticNavigationTargetHeading, | |||
| 44 | + | Direction: dto.SemanticNavigationBackward, | |||
| 45 | + | Count: 3, | |||
| 46 | + | HighlightColor: "#aabbcc", | |||
| 47 | + | }, wv.semanticRequests[0]) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) { | |||
| 51 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |||
| 52 | + | ||||
| 53 | + | handled := NewVimNavigationUseCase().NavigatePageFocus(wv, true) | |||
| 54 | + | ||||
| 55 | + | assert.True(t, handled) | |||
| 56 | + | assert.Equal(t, []bool{true}, wv.pageFocusBackward) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) { | |||
| 60 | + | wv := portmocks.NewMockWebView(t) | |||
| 61 | + | ||||
| 62 | + | require.NoError(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, "")) | |||
| 63 | + | assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true)) | |||
| 64 | + | } | |||
A internal/bootstrap/development_environment.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | developmentEnvironmentName = "dev" | |
| 11 | + | developmentPrivateMode os.FileMode = 0o700 | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ApplyDevelopmentEnvironment isolates the process from the user's XDG and | |
| 15 | + | // home directories when ENV=dev. It must run before GTK, CEF, or any adapter | |
| 16 | + | // can initialize and read process environment settings. | |
| 17 | + | func ApplyDevelopmentEnvironment() error { | |
| 18 | + | if os.Getenv("ENV") != developmentEnvironmentName { | |
| 19 | + | return nil | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | cwd, err := os.Getwd() | |
| 23 | + | if err != nil { | |
| 24 | + | return fmt.Errorf("resolve development environment root: %w", err) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | devDir := filepath.Join(cwd, ".dev") | |
| 28 | + | if err := ensureDevelopmentDirectory(devDir, true); err != nil { | |
| 29 | + | return err | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | root := filepath.Join(devDir, "dumber") | |
| 33 | + | paths := map[string]string{ | |
| 34 | + | "HOME": filepath.Join(root, "home"), | |
| 35 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |
| 36 | + | "XDG_DATA_HOME": filepath.Join(root, "data"), | |
| 37 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |
| 38 | + | "XDG_CACHE_HOME": filepath.Join(root, "cache"), | |
| 39 | + | } | |
| 40 | + | for _, path := range developmentPrivateDirectories(root, paths) { | |
| 41 | + | if err := ensureDevelopmentDirectory(path, true); err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | for name, path := range paths { | |
| 46 | + | if err := os.Setenv(name, path); err != nil { | |
| 47 | + | return fmt.Errorf("set %s for development environment: %w", name, err) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // developmentPrivateDirectories returns every currently known path that Dumber | |
| 55 | + | // creates below the development sandbox. Creating and validating them before | |
| 56 | + | // GTK, CEF, logging, or the browser profile starts prevents those subsystems | |
| 57 | + | // from following a pre-existing sandbox symlink. | |
| 58 | + | func developmentPrivateDirectories(root string, paths map[string]string) []string { | |
| 59 | + | enginesDir := filepath.Join(root, "engines") | |
| 60 | + | cefDir := filepath.Join(enginesDir, "cef") | |
| 61 | + | webkitDir := filepath.Join(enginesDir, "webkit") | |
| 62 | + | runtimeDir := filepath.Join(root, "runtime") | |
| 63 | + | return []string{ | |
| 64 | + | root, | |
| 65 | + | paths["HOME"], | |
| 66 | + | paths["XDG_CONFIG_HOME"], | |
| 67 | + | paths["XDG_DATA_HOME"], | |
| 68 | + | paths["XDG_STATE_HOME"], | |
| 69 | + | paths["XDG_CACHE_HOME"], | |
| 70 | + | filepath.Join(root, "logs"), | |
| 71 | + | enginesDir, | |
| 72 | + | cefDir, | |
| 73 | + | filepath.Join(cefDir, "data"), | |
| 74 | + | filepath.Join(cefDir, "runtime"), | |
| 75 | + | filepath.Join(cefDir, "logs"), | |
| 76 | + | webkitDir, | |
| 77 | + | filepath.Join(webkitDir, "data"), | |
| 78 | + | filepath.Join(webkitDir, "cache"), | |
| 79 | + | filepath.Join(webkitDir, "runtime"), | |
| 80 | + | filepath.Join(webkitDir, "logs"), | |
| 81 | + | runtimeDir, | |
| 82 | + | filepath.Join(runtimeDir, "cef"), | |
| 83 | + | filepath.Join(runtimeDir, "webkit"), | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func ensureDevelopmentDirectory(path string, private bool) error { | |
| 88 | + | if err := os.MkdirAll(path, developmentPrivateMode); err != nil { | |
| 89 | + | return fmt.Errorf("create development environment directory %q: %w", path, err) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | info, err := os.Lstat(path) | |
| 93 | + | if err != nil { | |
| 94 | + | return fmt.Errorf("inspect development environment directory %q: %w", path, err) | |
| 95 | + | } | |
| 96 | + | if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { | |
| 97 | + | return fmt.Errorf("development environment path %q must be a directory, not %s", path, info.Mode().Type()) | |
| 98 | + | } | |
| 99 | + | if !private { | |
| 100 | + | return nil | |
| 101 | + | } | |
| 102 | + | if err = os.Chmod(path, developmentPrivateMode); err != nil { | |
| 103 | + | return fmt.Errorf("restrict development environment directory %q: %w", path, err) | |
| 104 | + | } | |
| 105 | + | info, err = os.Stat(path) | |
| 106 | + | if err != nil { | |
| 107 | + | return fmt.Errorf("verify development environment directory %q: %w", path, err) | |
| 108 | + | } | |
| 109 | + | if info.Mode().Perm() != developmentPrivateMode.Perm() { | |
| 110 | + | return fmt.Errorf("development environment directory %q permissions are %04o, want 0700", path, info.Mode().Perm()) | |
| 111 | + | } | |
| 112 | + | return nil | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | developmentEnvironmentName = "dev" | |||
| 11 | + | developmentPrivateMode os.FileMode = 0o700 | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ApplyDevelopmentEnvironment isolates the process from the user's XDG and | |||
| 15 | + | // home directories when ENV=dev. It must run before GTK, CEF, or any adapter | |||
| 16 | + | // can initialize and read process environment settings. | |||
| 17 | + | func ApplyDevelopmentEnvironment() error { | |||
| 18 | + | if os.Getenv("ENV") != developmentEnvironmentName { | |||
| 19 | + | return nil | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | cwd, err := os.Getwd() | |||
| 23 | + | if err != nil { | |||
| 24 | + | return fmt.Errorf("resolve development environment root: %w", err) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | devDir := filepath.Join(cwd, ".dev") | |||
| 28 | + | if err := ensureDevelopmentDirectory(devDir, true); err != nil { | |||
| 29 | + | return err | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | root := filepath.Join(devDir, "dumber") | |||
| 33 | + | paths := map[string]string{ | |||
| 34 | + | "HOME": filepath.Join(root, "home"), | |||
| 35 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |||
| 36 | + | "XDG_DATA_HOME": filepath.Join(root, "data"), | |||
| 37 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |||
| 38 | + | "XDG_CACHE_HOME": filepath.Join(root, "cache"), | |||
| 39 | + | } | |||
| 40 | + | for _, path := range developmentPrivateDirectories(root, paths) { | |||
| 41 | + | if err := ensureDevelopmentDirectory(path, true); err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | for name, path := range paths { | |||
| 46 | + | if err := os.Setenv(name, path); err != nil { | |||
| 47 | + | return fmt.Errorf("set %s for development environment: %w", name, err) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // developmentPrivateDirectories returns every currently known path that Dumber | |||
| 55 | + | // creates below the development sandbox. Creating and validating them before | |||
| 56 | + | // GTK, CEF, logging, or the browser profile starts prevents those subsystems | |||
| 57 | + | // from following a pre-existing sandbox symlink. | |||
| 58 | + | func developmentPrivateDirectories(root string, paths map[string]string) []string { | |||
| 59 | + | enginesDir := filepath.Join(root, "engines") | |||
| 60 | + | cefDir := filepath.Join(enginesDir, "cef") | |||
| 61 | + | webkitDir := filepath.Join(enginesDir, "webkit") | |||
| 62 | + | runtimeDir := filepath.Join(root, "runtime") | |||
| 63 | + | return []string{ | |||
| 64 | + | root, | |||
| 65 | + | paths["HOME"], | |||
| 66 | + | paths["XDG_CONFIG_HOME"], | |||
| 67 | + | paths["XDG_DATA_HOME"], | |||
| 68 | + | paths["XDG_STATE_HOME"], | |||
| 69 | + | paths["XDG_CACHE_HOME"], | |||
| 70 | + | filepath.Join(root, "logs"), | |||
| 71 | + | enginesDir, | |||
| 72 | + | cefDir, | |||
| 73 | + | filepath.Join(cefDir, "data"), | |||
| 74 | + | filepath.Join(cefDir, "runtime"), | |||
| 75 | + | filepath.Join(cefDir, "logs"), | |||
| 76 | + | webkitDir, | |||
| 77 | + | filepath.Join(webkitDir, "data"), | |||
| 78 | + | filepath.Join(webkitDir, "cache"), | |||
| 79 | + | filepath.Join(webkitDir, "runtime"), | |||
| 80 | + | filepath.Join(webkitDir, "logs"), | |||
| 81 | + | runtimeDir, | |||
| 82 | + | filepath.Join(runtimeDir, "cef"), | |||
| 83 | + | filepath.Join(runtimeDir, "webkit"), | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func ensureDevelopmentDirectory(path string, private bool) error { | |||
| 88 | + | if err := os.MkdirAll(path, developmentPrivateMode); err != nil { | |||
| 89 | + | return fmt.Errorf("create development environment directory %q: %w", path, err) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | info, err := os.Lstat(path) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return fmt.Errorf("inspect development environment directory %q: %w", path, err) | |||
| 95 | + | } | |||
| 96 | + | if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { | |||
| 97 | + | return fmt.Errorf("development environment path %q must be a directory, not %s", path, info.Mode().Type()) | |||
| 98 | + | } | |||
| 99 | + | if !private { | |||
| 100 | + | return nil | |||
| 101 | + | } | |||
| 102 | + | if err = os.Chmod(path, developmentPrivateMode); err != nil { | |||
| 103 | + | return fmt.Errorf("restrict development environment directory %q: %w", path, err) | |||
| 104 | + | } | |||
| 105 | + | info, err = os.Stat(path) | |||
| 106 | + | if err != nil { | |||
| 107 | + | return fmt.Errorf("verify development environment directory %q: %w", path, err) | |||
| 108 | + | } | |||
| 109 | + | if info.Mode().Perm() != developmentPrivateMode.Perm() { | |||
| 110 | + | return fmt.Errorf("development environment directory %q permissions are %04o, want 0700", path, info.Mode().Perm()) | |||
| 111 | + | } | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
A internal/bootstrap/development_environment_test.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestApplyDevelopmentEnvironment_DevIsolatesHomeAndXDGDirectories(t *testing.T) { | |
| 12 | + | worktree := t.TempDir() | |
| 13 | + | t.Chdir(worktree) | |
| 14 | + | t.Setenv("ENV", developmentEnvironmentName) | |
| 15 | + | t.Setenv("HOME", "/home/production") | |
| 16 | + | t.Setenv("XDG_CONFIG_HOME", "/production/config") | |
| 17 | + | t.Setenv("XDG_DATA_HOME", "/production/data") | |
| 18 | + | t.Setenv("XDG_STATE_HOME", "/production/state") | |
| 19 | + | t.Setenv("XDG_CACHE_HOME", "/production/cache") | |
| 20 | + | t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") | |
| 21 | + | ||
| 22 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |
| 23 | + | ||
| 24 | + | root := filepath.Join(worktree, ".dev", "dumber") | |
| 25 | + | want := map[string]string{ | |
| 26 | + | "HOME": filepath.Join(root, "home"), | |
| 27 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |
| 28 | + | "XDG_DATA_HOME": filepath.Join(root, "data"), | |
| 29 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |
| 30 | + | "XDG_CACHE_HOME": filepath.Join(root, "cache"), | |
| 31 | + | } | |
| 32 | + | for name, path := range want { | |
| 33 | + | require.Equal(t, path, os.Getenv(name), name) | |
| 34 | + | info, err := os.Stat(path) | |
| 35 | + | require.NoError(t, err, name) | |
| 36 | + | require.True(t, info.IsDir(), name) | |
| 37 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm(), name) | |
| 38 | + | } | |
| 39 | + | require.Equal(t, "/run/user/1000", os.Getenv("XDG_RUNTIME_DIR")) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestApplyDevelopmentEnvironment_DevTightensExistingDevDirectoryPermissions(t *testing.T) { | |
| 43 | + | worktree := t.TempDir() | |
| 44 | + | t.Chdir(worktree) | |
| 45 | + | t.Setenv("ENV", developmentEnvironmentName) | |
| 46 | + | ||
| 47 | + | devDir := filepath.Join(worktree, ".dev") | |
| 48 | + | require.NoError(t, os.Mkdir(devDir, 0o777)) | |
| 49 | + | require.NoError(t, os.Chmod(devDir, 0o777)) | |
| 50 | + | ||
| 51 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |
| 52 | + | ||
| 53 | + | info, err := os.Stat(devDir) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestApplyDevelopmentEnvironment_DevRejectsSandboxSymlink(t *testing.T) { | |
| 59 | + | worktree := t.TempDir() | |
| 60 | + | t.Chdir(worktree) | |
| 61 | + | t.Setenv("ENV", developmentEnvironmentName) | |
| 62 | + | t.Setenv("HOME", "/home/production") | |
| 63 | + | ||
| 64 | + | devDir := filepath.Join(worktree, ".dev") | |
| 65 | + | require.NoError(t, os.Mkdir(devDir, 0o755)) | |
| 66 | + | target := t.TempDir() | |
| 67 | + | require.NoError(t, os.Symlink(target, filepath.Join(devDir, "dumber"))) | |
| 68 | + | ||
| 69 | + | err := ApplyDevelopmentEnvironment() | |
| 70 | + | require.Error(t, err) | |
| 71 | + | require.Contains(t, err.Error(), "must be a directory") | |
| 72 | + | require.Equal(t, "/home/production", os.Getenv("HOME")) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestApplyDevelopmentEnvironment_DevRejectsSandboxChildSymlinks(t *testing.T) { | |
| 76 | + | for _, child := range []string{"logs", "engines"} { | |
| 77 | + | t.Run(child, func(t *testing.T) { | |
| 78 | + | worktree := t.TempDir() | |
| 79 | + | t.Chdir(worktree) | |
| 80 | + | t.Setenv("ENV", developmentEnvironmentName) | |
| 81 | + | ||
| 82 | + | root := filepath.Join(worktree, ".dev", "dumber") | |
| 83 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 84 | + | require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(root, child))) | |
| 85 | + | ||
| 86 | + | err := ApplyDevelopmentEnvironment() | |
| 87 | + | require.Error(t, err) | |
| 88 | + | require.Contains(t, err.Error(), "must be a directory") | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestApplyDevelopmentEnvironment_DevTightensExistingSandboxPermissions(t *testing.T) { | |
| 94 | + | worktree := t.TempDir() | |
| 95 | + | t.Chdir(worktree) | |
| 96 | + | t.Setenv("ENV", developmentEnvironmentName) | |
| 97 | + | ||
| 98 | + | root := filepath.Join(worktree, ".dev", "dumber") | |
| 99 | + | require.NoError(t, os.MkdirAll(root, 0o755)) | |
| 100 | + | require.NoError(t, os.Chmod(root, 0o755)) | |
| 101 | + | ||
| 102 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |
| 103 | + | ||
| 104 | + | info, err := os.Stat(root) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestApplyDevelopmentEnvironment_ProductionPreservesEnvironment(t *testing.T) { | |
| 110 | + | t.Setenv("ENV", "") | |
| 111 | + | t.Setenv("HOME", "/home/production") | |
| 112 | + | t.Setenv("XDG_CONFIG_HOME", "/production/config") | |
| 113 | + | t.Setenv("XDG_DATA_HOME", "/production/data") | |
| 114 | + | t.Setenv("XDG_STATE_HOME", "/production/state") | |
| 115 | + | t.Setenv("XDG_CACHE_HOME", "/production/cache") | |
| 116 | + | ||
| 117 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |
| 118 | + | ||
| 119 | + | require.Equal(t, "/home/production", os.Getenv("HOME")) | |
| 120 | + | require.Equal(t, "/production/config", os.Getenv("XDG_CONFIG_HOME")) | |
| 121 | + | require.Equal(t, "/production/data", os.Getenv("XDG_DATA_HOME")) | |
| 122 | + | require.Equal(t, "/production/state", os.Getenv("XDG_STATE_HOME")) | |
| 123 | + | require.Equal(t, "/production/cache", os.Getenv("XDG_CACHE_HOME")) | |
| 124 | + | } | |
| @@ -0,0 +1,124 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestApplyDevelopmentEnvironment_DevIsolatesHomeAndXDGDirectories(t *testing.T) { | |||
| 12 | + | worktree := t.TempDir() | |||
| 13 | + | t.Chdir(worktree) | |||
| 14 | + | t.Setenv("ENV", developmentEnvironmentName) | |||
| 15 | + | t.Setenv("HOME", "/home/production") | |||
| 16 | + | t.Setenv("XDG_CONFIG_HOME", "/production/config") | |||
| 17 | + | t.Setenv("XDG_DATA_HOME", "/production/data") | |||
| 18 | + | t.Setenv("XDG_STATE_HOME", "/production/state") | |||
| 19 | + | t.Setenv("XDG_CACHE_HOME", "/production/cache") | |||
| 20 | + | t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") | |||
| 21 | + | ||||
| 22 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |||
| 23 | + | ||||
| 24 | + | root := filepath.Join(worktree, ".dev", "dumber") | |||
| 25 | + | want := map[string]string{ | |||
| 26 | + | "HOME": filepath.Join(root, "home"), | |||
| 27 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |||
| 28 | + | "XDG_DATA_HOME": filepath.Join(root, "data"), | |||
| 29 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |||
| 30 | + | "XDG_CACHE_HOME": filepath.Join(root, "cache"), | |||
| 31 | + | } | |||
| 32 | + | for name, path := range want { | |||
| 33 | + | require.Equal(t, path, os.Getenv(name), name) | |||
| 34 | + | info, err := os.Stat(path) | |||
| 35 | + | require.NoError(t, err, name) | |||
| 36 | + | require.True(t, info.IsDir(), name) | |||
| 37 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm(), name) | |||
| 38 | + | } | |||
| 39 | + | require.Equal(t, "/run/user/1000", os.Getenv("XDG_RUNTIME_DIR")) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestApplyDevelopmentEnvironment_DevTightensExistingDevDirectoryPermissions(t *testing.T) { | |||
| 43 | + | worktree := t.TempDir() | |||
| 44 | + | t.Chdir(worktree) | |||
| 45 | + | t.Setenv("ENV", developmentEnvironmentName) | |||
| 46 | + | ||||
| 47 | + | devDir := filepath.Join(worktree, ".dev") | |||
| 48 | + | require.NoError(t, os.Mkdir(devDir, 0o777)) | |||
| 49 | + | require.NoError(t, os.Chmod(devDir, 0o777)) | |||
| 50 | + | ||||
| 51 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |||
| 52 | + | ||||
| 53 | + | info, err := os.Stat(devDir) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestApplyDevelopmentEnvironment_DevRejectsSandboxSymlink(t *testing.T) { | |||
| 59 | + | worktree := t.TempDir() | |||
| 60 | + | t.Chdir(worktree) | |||
| 61 | + | t.Setenv("ENV", developmentEnvironmentName) | |||
| 62 | + | t.Setenv("HOME", "/home/production") | |||
| 63 | + | ||||
| 64 | + | devDir := filepath.Join(worktree, ".dev") | |||
| 65 | + | require.NoError(t, os.Mkdir(devDir, 0o755)) | |||
| 66 | + | target := t.TempDir() | |||
| 67 | + | require.NoError(t, os.Symlink(target, filepath.Join(devDir, "dumber"))) | |||
| 68 | + | ||||
| 69 | + | err := ApplyDevelopmentEnvironment() | |||
| 70 | + | require.Error(t, err) | |||
| 71 | + | require.Contains(t, err.Error(), "must be a directory") | |||
| 72 | + | require.Equal(t, "/home/production", os.Getenv("HOME")) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestApplyDevelopmentEnvironment_DevRejectsSandboxChildSymlinks(t *testing.T) { | |||
| 76 | + | for _, child := range []string{"logs", "engines"} { | |||
| 77 | + | t.Run(child, func(t *testing.T) { | |||
| 78 | + | worktree := t.TempDir() | |||
| 79 | + | t.Chdir(worktree) | |||
| 80 | + | t.Setenv("ENV", developmentEnvironmentName) | |||
| 81 | + | ||||
| 82 | + | root := filepath.Join(worktree, ".dev", "dumber") | |||
| 83 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |||
| 84 | + | require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(root, child))) | |||
| 85 | + | ||||
| 86 | + | err := ApplyDevelopmentEnvironment() | |||
| 87 | + | require.Error(t, err) | |||
| 88 | + | require.Contains(t, err.Error(), "must be a directory") | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestApplyDevelopmentEnvironment_DevTightensExistingSandboxPermissions(t *testing.T) { | |||
| 94 | + | worktree := t.TempDir() | |||
| 95 | + | t.Chdir(worktree) | |||
| 96 | + | t.Setenv("ENV", developmentEnvironmentName) | |||
| 97 | + | ||||
| 98 | + | root := filepath.Join(worktree, ".dev", "dumber") | |||
| 99 | + | require.NoError(t, os.MkdirAll(root, 0o755)) | |||
| 100 | + | require.NoError(t, os.Chmod(root, 0o755)) | |||
| 101 | + | ||||
| 102 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |||
| 103 | + | ||||
| 104 | + | info, err := os.Stat(root) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestApplyDevelopmentEnvironment_ProductionPreservesEnvironment(t *testing.T) { | |||
| 110 | + | t.Setenv("ENV", "") | |||
| 111 | + | t.Setenv("HOME", "/home/production") | |||
| 112 | + | t.Setenv("XDG_CONFIG_HOME", "/production/config") | |||
| 113 | + | t.Setenv("XDG_DATA_HOME", "/production/data") | |||
| 114 | + | t.Setenv("XDG_STATE_HOME", "/production/state") | |||
| 115 | + | t.Setenv("XDG_CACHE_HOME", "/production/cache") | |||
| 116 | + | ||||
| 117 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |||
| 118 | + | ||||
| 119 | + | require.Equal(t, "/home/production", os.Getenv("HOME")) | |||
| 120 | + | require.Equal(t, "/production/config", os.Getenv("XDG_CONFIG_HOME")) | |||
| 121 | + | require.Equal(t, "/production/data", os.Getenv("XDG_DATA_HOME")) | |||
| 122 | + | require.Equal(t, "/production/state", os.Getenv("XDG_STATE_HOME")) | |||
| 123 | + | require.Equal(t, "/production/cache", os.Getenv("XDG_CACHE_HOME")) | |||
| 124 | + | } | |||
A internal/infrastructure/cef/input_focus.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | var ( | |
| 10 | + | _ port.PageInputFocuser = (*WebView)(nil) | |
| 11 | + | _ port.PageFocusNavigator = (*WebView)(nil) | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // FocusNextInput moves focus to the next visible, editable page input. When | |
| 15 | + | // no eligible input is active it focuses the first one; movement wraps at the | |
| 16 | + | // end so repeated gi commands continue to cycle through the page inputs. | |
| 17 | + | func (wv *WebView) FocusNextInput() { | |
| 18 | + | if wv == nil || wv.destroyed.Load() { | |
| 19 | + | return | |
| 20 | + | } | |
| 21 | + | wv.RunJavaScript(context.Background(), focusNextInputScript()) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NavigatePageFocus keeps reverse and forward focus traversal inside the live | |
| 25 | + | // page when GTK would otherwise move focus out of the WebView. | |
| 26 | + | func (wv *WebView) NavigatePageFocus(backward bool) { | |
| 27 | + | if wv == nil || wv.destroyed.Load() { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | wv.RunJavaScript(context.Background(), pageFocusNavigationScript(backward)) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func focusNextInputScript() string { | |
| 34 | + | return `(() => { | |
| 35 | + | const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]")); | |
| 36 | + | const hiddenInputTypes = new Set([ | |
| 37 | + | "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit" | |
| 38 | + | ]); | |
| 39 | + | const isVisible = (element) => { | |
| 40 | + | if (!(element instanceof HTMLElement) || !element.isConnected) return false; | |
| 41 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |
| 42 | + | if (element.getClientRects().length === 0) return false; | |
| 43 | + | const style = window.getComputedStyle(element); | |
| 44 | + | return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"; | |
| 45 | + | }; | |
| 46 | + | const isEligible = (element) => { | |
| 47 | + | if (!isVisible(element)) return false; | |
| 48 | + | if (element instanceof HTMLInputElement) { | |
| 49 | + | return !element.disabled && !element.readOnly && !hiddenInputTypes.has(element.type.toLowerCase()); | |
| 50 | + | } | |
| 51 | + | if (element instanceof HTMLTextAreaElement) { | |
| 52 | + | return !element.disabled && !element.readOnly; | |
| 53 | + | } | |
| 54 | + | return element.isContentEditable && element.getAttribute("contenteditable") !== "false"; | |
| 55 | + | }; | |
| 56 | + | const eligible = candidates.filter(isEligible); | |
| 57 | + | if (eligible.length === 0) return; | |
| 58 | + | const active = document.activeElement; | |
| 59 | + | const activeIndex = eligible.findIndex((element) => element === active || element.contains(active)); | |
| 60 | + | const target = eligible[(activeIndex + 1 + eligible.length) % eligible.length]; | |
| 61 | + | target.focus(); | |
| 62 | + | })();` | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func pageFocusNavigationScript(backward bool) string { | |
| 66 | + | direction := "1" | |
| 67 | + | if backward { | |
| 68 | + | direction = "-1" | |
| 69 | + | } | |
| 70 | + | return `(() => { | |
| 71 | + | const direction = ` + direction + `; | |
| 72 | + | const selector = [ | |
| 73 | + | "a[href]", "area[href]", "button:not([disabled])", | |
| 74 | + | "input:not([disabled]):not([type=\"hidden\"])", "select:not([disabled])", | |
| 75 | + | "textarea:not([disabled])", "[contenteditable]:not([contenteditable=\"false\"])", | |
| 76 | + | "[tabindex]:not([tabindex=\"-1\"])" | |
| 77 | + | ].join(","); | |
| 78 | + | const isVisible = (element) => { | |
| 79 | + | if (!(element instanceof HTMLElement) || !element.isConnected) return false; | |
| 80 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |
| 81 | + | if (element.getClientRects().length === 0) return false; | |
| 82 | + | const style = window.getComputedStyle(element); | |
| 83 | + | return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"; | |
| 84 | + | }; | |
| 85 | + | const candidates = Array.from(document.querySelectorAll(selector)).filter((element) => { | |
| 86 | + | return isVisible(element) && !element.disabled && element.tabIndex >= 0; | |
| 87 | + | }); | |
| 88 | + | if (candidates.length === 0) return; | |
| 89 | + | const active = document.activeElement; | |
| 90 | + | const activeIndex = candidates.findIndex((element) => element === active || element.contains(active)); | |
| 91 | + | const targetIndex = activeIndex < 0 | |
| 92 | + | ? (direction < 0 ? candidates.length - 1 : 0) | |
| 93 | + | : activeIndex + direction; | |
| 94 | + | if (targetIndex < 0 || targetIndex >= candidates.length) return; | |
| 95 | + | candidates[targetIndex].focus(); | |
| 96 | + | })();` | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | var ( | |||
| 10 | + | _ port.PageInputFocuser = (*WebView)(nil) | |||
| 11 | + | _ port.PageFocusNavigator = (*WebView)(nil) | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // FocusNextInput moves focus to the next visible, editable page input. When | |||
| 15 | + | // no eligible input is active it focuses the first one; movement wraps at the | |||
| 16 | + | // end so repeated gi commands continue to cycle through the page inputs. | |||
| 17 | + | func (wv *WebView) FocusNextInput() { | |||
| 18 | + | if wv == nil || wv.destroyed.Load() { | |||
| 19 | + | return | |||
| 20 | + | } | |||
| 21 | + | wv.RunJavaScript(context.Background(), focusNextInputScript()) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // NavigatePageFocus keeps reverse and forward focus traversal inside the live | |||
| 25 | + | // page when GTK would otherwise move focus out of the WebView. | |||
| 26 | + | func (wv *WebView) NavigatePageFocus(backward bool) { | |||
| 27 | + | if wv == nil || wv.destroyed.Load() { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | wv.RunJavaScript(context.Background(), pageFocusNavigationScript(backward)) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func focusNextInputScript() string { | |||
| 34 | + | return `(() => { | |||
| 35 | + | const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]")); | |||
| 36 | + | const hiddenInputTypes = new Set([ | |||
| 37 | + | "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit" | |||
| 38 | + | ]); | |||
| 39 | + | const isVisible = (element) => { | |||
| 40 | + | if (!(element instanceof HTMLElement) || !element.isConnected) return false; | |||
| 41 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |||
| 42 | + | if (element.getClientRects().length === 0) return false; | |||
| 43 | + | const style = window.getComputedStyle(element); | |||
| 44 | + | return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"; | |||
| 45 | + | }; | |||
| 46 | + | const isEligible = (element) => { | |||
| 47 | + | if (!isVisible(element)) return false; | |||
| 48 | + | if (element instanceof HTMLInputElement) { | |||
| 49 | + | return !element.disabled && !element.readOnly && !hiddenInputTypes.has(element.type.toLowerCase()); | |||
| 50 | + | } | |||
| 51 | + | if (element instanceof HTMLTextAreaElement) { | |||
| 52 | + | return !element.disabled && !element.readOnly; | |||
| 53 | + | } | |||
| 54 | + | return element.isContentEditable && element.getAttribute("contenteditable") !== "false"; | |||
| 55 | + | }; | |||
| 56 | + | const eligible = candidates.filter(isEligible); | |||
| 57 | + | if (eligible.length === 0) return; | |||
| 58 | + | const active = document.activeElement; | |||
| 59 | + | const activeIndex = eligible.findIndex((element) => element === active || element.contains(active)); | |||
| 60 | + | const target = eligible[(activeIndex + 1 + eligible.length) % eligible.length]; | |||
| 61 | + | target.focus(); | |||
| 62 | + | })();` | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func pageFocusNavigationScript(backward bool) string { | |||
| 66 | + | direction := "1" | |||
| 67 | + | if backward { | |||
| 68 | + | direction = "-1" | |||
| 69 | + | } | |||
| 70 | + | return `(() => { | |||
| 71 | + | const direction = ` + direction + `; | |||
| 72 | + | const selector = [ | |||
| 73 | + | "a[href]", "area[href]", "button:not([disabled])", | |||
| 74 | + | "input:not([disabled]):not([type=\"hidden\"])", "select:not([disabled])", | |||
| 75 | + | "textarea:not([disabled])", "[contenteditable]:not([contenteditable=\"false\"])", | |||
| 76 | + | "[tabindex]:not([tabindex=\"-1\"])" | |||
| 77 | + | ].join(","); | |||
| 78 | + | const isVisible = (element) => { | |||
| 79 | + | if (!(element instanceof HTMLElement) || !element.isConnected) return false; | |||
| 80 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |||
| 81 | + | if (element.getClientRects().length === 0) return false; | |||
| 82 | + | const style = window.getComputedStyle(element); | |||
| 83 | + | return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"; | |||
| 84 | + | }; | |||
| 85 | + | const candidates = Array.from(document.querySelectorAll(selector)).filter((element) => { | |||
| 86 | + | return isVisible(element) && !element.disabled && element.tabIndex >= 0; | |||
| 87 | + | }); | |||
| 88 | + | if (candidates.length === 0) return; | |||
| 89 | + | const active = document.activeElement; | |||
| 90 | + | const activeIndex = candidates.findIndex((element) => element === active || element.contains(active)); | |||
| 91 | + | const targetIndex = activeIndex < 0 | |||
| 92 | + | ? (direction < 0 ? candidates.length - 1 : 0) | |||
| 93 | + | : activeIndex + direction; | |||
| 94 | + | if (targetIndex < 0 || targetIndex >= candidates.length) return; | |||
| 95 | + | candidates[targetIndex].focus(); | |||
| 96 | + | })();` | |||
| 97 | + | } | |||
A internal/infrastructure/cef/input_focus_test.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 7 | + | "github.com/stretchr/testify/mock" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestWebViewFocusNextInputExecutesSafeFocusScript(t *testing.T) { | |
| 12 | + | browser := cefmocks.NewMockBrowser(t) | |
| 13 | + | frame := cefmocks.NewMockFrame(t) | |
| 14 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 15 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |
| 16 | + | `document.querySelectorAll("input,textarea,[contenteditable]")`, | |
| 17 | + | `element.closest("[hidden],[inert],[aria-hidden=\"true\"]")`, | |
| 18 | + | "getClientRects", | |
| 19 | + | "HTMLInputElement", | |
| 20 | + | "HTMLTextAreaElement", | |
| 21 | + | "activeIndex", | |
| 22 | + | "target.focus()", | |
| 23 | + | )), "", int32(0)).Once() | |
| 24 | + | ||
| 25 | + | (&WebView{browser: browser}).FocusNextInput() | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestWebViewNavigatePageFocusExecutesDirectionalScript(t *testing.T) { | |
| 29 | + | browser := cefmocks.NewMockBrowser(t) | |
| 30 | + | frame := cefmocks.NewMockFrame(t) | |
| 31 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 32 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |
| 33 | + | "const direction = -1;", | |
| 34 | + | "a[href]", | |
| 35 | + | "textarea:not([disabled])", | |
| 36 | + | "document.activeElement", | |
| 37 | + | "targetIndex", | |
| 38 | + | "candidates[targetIndex].focus()", | |
| 39 | + | )), "", int32(0)).Once() | |
| 40 | + | ||
| 41 | + | (&WebView{browser: browser}).NavigatePageFocus(true) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestFocusNextInputScriptWrapsAndNoOpsWithoutEligibleTarget(t *testing.T) { | |
| 45 | + | script := focusNextInputScript() | |
| 46 | + | ||
| 47 | + | require.Contains(t, script, "const eligible = candidates.filter(isEligible);") | |
| 48 | + | require.Contains(t, script, "if (eligible.length === 0) return;") | |
| 49 | + | require.Contains(t, script, "const target = eligible[(activeIndex + 1 + eligible.length) % eligible.length];") | |
| 50 | + | require.Contains(t, script, "!element.disabled && !element.readOnly") | |
| 51 | + | require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestPageFocusNavigationScriptStopsAtPageBoundaries(t *testing.T) { | |
| 55 | + | script := pageFocusNavigationScript(true) | |
| 56 | + | ||
| 57 | + | require.Contains(t, script, "const direction = -1;") | |
| 58 | + | require.Contains(t, script, "if (targetIndex < 0 || targetIndex >= candidates.length) return;") | |
| 59 | + | require.Contains(t, script, "candidates[targetIndex].focus();") | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 7 | + | "github.com/stretchr/testify/mock" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestWebViewFocusNextInputExecutesSafeFocusScript(t *testing.T) { | |||
| 12 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 13 | + | frame := cefmocks.NewMockFrame(t) | |||
| 14 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 15 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |||
| 16 | + | `document.querySelectorAll("input,textarea,[contenteditable]")`, | |||
| 17 | + | `element.closest("[hidden],[inert],[aria-hidden=\"true\"]")`, | |||
| 18 | + | "getClientRects", | |||
| 19 | + | "HTMLInputElement", | |||
| 20 | + | "HTMLTextAreaElement", | |||
| 21 | + | "activeIndex", | |||
| 22 | + | "target.focus()", | |||
| 23 | + | )), "", int32(0)).Once() | |||
| 24 | + | ||||
| 25 | + | (&WebView{browser: browser}).FocusNextInput() | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestWebViewNavigatePageFocusExecutesDirectionalScript(t *testing.T) { | |||
| 29 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 30 | + | frame := cefmocks.NewMockFrame(t) | |||
| 31 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 32 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |||
| 33 | + | "const direction = -1;", | |||
| 34 | + | "a[href]", | |||
| 35 | + | "textarea:not([disabled])", | |||
| 36 | + | "document.activeElement", | |||
| 37 | + | "targetIndex", | |||
| 38 | + | "candidates[targetIndex].focus()", | |||
| 39 | + | )), "", int32(0)).Once() | |||
| 40 | + | ||||
| 41 | + | (&WebView{browser: browser}).NavigatePageFocus(true) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestFocusNextInputScriptWrapsAndNoOpsWithoutEligibleTarget(t *testing.T) { | |||
| 45 | + | script := focusNextInputScript() | |||
| 46 | + | ||||
| 47 | + | require.Contains(t, script, "const eligible = candidates.filter(isEligible);") | |||
| 48 | + | require.Contains(t, script, "if (eligible.length === 0) return;") | |||
| 49 | + | require.Contains(t, script, "const target = eligible[(activeIndex + 1 + eligible.length) % eligible.length];") | |||
| 50 | + | require.Contains(t, script, "!element.disabled && !element.readOnly") | |||
| 51 | + | require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestPageFocusNavigationScriptStopsAtPageBoundaries(t *testing.T) { | |||
| 55 | + | script := pageFocusNavigationScript(true) | |||
| 56 | + | ||||
| 57 | + | require.Contains(t, script, "const direction = -1;") | |||
| 58 | + | require.Contains(t, script, "if (targetIndex < 0 || targetIndex >= candidates.length) return;") | |||
| 59 | + | require.Contains(t, script, "candidates[targetIndex].focus();") | |||
| 60 | + | } | |||
A internal/infrastructure/cef/semantic_navigation.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strconv" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // NavigateSemantic performs the initial live Vim structural navigation path. | |
| 12 | + | // It deliberately uses only stable HTML semantics and a static script: CEF's | |
| 13 | + | // raw accessibility payload is not yet a documented application contract. | |
| 14 | + | func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error { | |
| 15 | + | if wv == nil || wv.destroyed.Load() { | |
| 16 | + | return errDestroyed | |
| 17 | + | } | |
| 18 | + | if request.Target != dto.SemanticNavigationTargetHeading { | |
| 19 | + | return fmt.Errorf("unsupported semantic navigation target: %d", request.Target) | |
| 20 | + | } | |
| 21 | + | if request.Direction != dto.SemanticNavigationForward && request.Direction != dto.SemanticNavigationBackward { | |
| 22 | + | return fmt.Errorf("unsupported semantic navigation direction: %d", request.Direction) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | count := request.Count | |
| 26 | + | if count < 1 { | |
| 27 | + | count = 1 | |
| 28 | + | } | |
| 29 | + | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor)) | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func headingNavigationScript(direction, count int, highlightColor string) string { | |
| 34 | + | if highlightColor == "" { | |
| 35 | + | highlightColor = "#fbbf24" | |
| 36 | + | } | |
| 37 | + | return `(() => { | |
| 38 | + | const stateKey = "__dumberVimHeadingTarget"; | |
| 39 | + | const className = "dumber-vim-heading-target"; | |
| 40 | + | const highlightColor = ` + strconv.Quote(highlightColor) + `; | |
| 41 | + | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | |
| 42 | + | .filter((heading) => { | |
| 43 | + | if (heading.getClientRects().length === 0) return false; | |
| 44 | + | if (heading.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |
| 45 | + | const style = window.getComputedStyle(heading); | |
| 46 | + | return style.visibility !== "hidden" && style.visibility !== "collapse"; | |
| 47 | + | }); | |
| 48 | + | if (headings.length === 0) return; | |
| 49 | + | ||
| 50 | + | const prior = window[stateKey]; | |
| 51 | + | let index = headings.indexOf(prior); | |
| 52 | + | if (index < 0) { | |
| 53 | + | const firstAtOrAfterViewportTop = headings.findIndex( | |
| 54 | + | (heading) => heading.getBoundingClientRect().top >= 0, | |
| 55 | + | ); | |
| 56 | + | if (` + strconv.Itoa(direction) + ` > 0) { | |
| 57 | + | index = firstAtOrAfterViewportTop - 1; | |
| 58 | + | } else { | |
| 59 | + | index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length; | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `); | |
| 64 | + | if (next < 0 || next >= headings.length) return; | |
| 65 | + | const target = headings[next]; | |
| 66 | + | ||
| 67 | + | const previous = document.querySelector("." + className); | |
| 68 | + | if (previous && previous !== target) previous.classList.remove(className); | |
| 69 | + | let style = document.getElementById("dumber-vim-heading-target-style"); | |
| 70 | + | if (!style) { | |
| 71 | + | // This visual cue is best-effort: strict page CSP can reject a DOM style | |
| 72 | + | // element, but target selection and scrolling must stay independent of it. | |
| 73 | + | style = document.createElement("style"); | |
| 74 | + | style.id = "dumber-vim-heading-target-style"; | |
| 75 | + | document.documentElement.appendChild(style); | |
| 76 | + | } | |
| 77 | + | // Refresh the outline on every navigation so an in-session accent change is | |
| 78 | + | // reflected even when the target style element already exists. | |
| 79 | + | style.textContent = "." + className + | |
| 80 | + | " { outline: 3px solid " + highlightColor + | |
| 81 | + | " !important; outline-offset: 5px !important; border-radius: 3px !important; }"; | |
| 82 | + | window[stateKey] = target; | |
| 83 | + | target.classList.add(className); | |
| 84 | + | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | |
| 85 | + | })();` | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strconv" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // NavigateSemantic performs the initial live Vim structural navigation path. | |||
| 12 | + | // It deliberately uses only stable HTML semantics and a static script: CEF's | |||
| 13 | + | // raw accessibility payload is not yet a documented application contract. | |||
| 14 | + | func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error { | |||
| 15 | + | if wv == nil || wv.destroyed.Load() { | |||
| 16 | + | return errDestroyed | |||
| 17 | + | } | |||
| 18 | + | if request.Target != dto.SemanticNavigationTargetHeading { | |||
| 19 | + | return fmt.Errorf("unsupported semantic navigation target: %d", request.Target) | |||
| 20 | + | } | |||
| 21 | + | if request.Direction != dto.SemanticNavigationForward && request.Direction != dto.SemanticNavigationBackward { | |||
| 22 | + | return fmt.Errorf("unsupported semantic navigation direction: %d", request.Direction) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | count := request.Count | |||
| 26 | + | if count < 1 { | |||
| 27 | + | count = 1 | |||
| 28 | + | } | |||
| 29 | + | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor)) | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func headingNavigationScript(direction, count int, highlightColor string) string { | |||
| 34 | + | if highlightColor == "" { | |||
| 35 | + | highlightColor = "#fbbf24" | |||
| 36 | + | } | |||
| 37 | + | return `(() => { | |||
| 38 | + | const stateKey = "__dumberVimHeadingTarget"; | |||
| 39 | + | const className = "dumber-vim-heading-target"; | |||
| 40 | + | const highlightColor = ` + strconv.Quote(highlightColor) + `; | |||
| 41 | + | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | |||
| 42 | + | .filter((heading) => { | |||
| 43 | + | if (heading.getClientRects().length === 0) return false; | |||
| 44 | + | if (heading.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |||
| 45 | + | const style = window.getComputedStyle(heading); | |||
| 46 | + | return style.visibility !== "hidden" && style.visibility !== "collapse"; | |||
| 47 | + | }); | |||
| 48 | + | if (headings.length === 0) return; | |||
| 49 | + | ||||
| 50 | + | const prior = window[stateKey]; | |||
| 51 | + | let index = headings.indexOf(prior); | |||
| 52 | + | if (index < 0) { | |||
| 53 | + | const firstAtOrAfterViewportTop = headings.findIndex( | |||
| 54 | + | (heading) => heading.getBoundingClientRect().top >= 0, | |||
| 55 | + | ); | |||
| 56 | + | if (` + strconv.Itoa(direction) + ` > 0) { | |||
| 57 | + | index = firstAtOrAfterViewportTop - 1; | |||
| 58 | + | } else { | |||
| 59 | + | index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length; | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `); | |||
| 64 | + | if (next < 0 || next >= headings.length) return; | |||
| 65 | + | const target = headings[next]; | |||
| 66 | + | ||||
| 67 | + | const previous = document.querySelector("." + className); | |||
| 68 | + | if (previous && previous !== target) previous.classList.remove(className); | |||
| 69 | + | let style = document.getElementById("dumber-vim-heading-target-style"); | |||
| 70 | + | if (!style) { | |||
| 71 | + | // This visual cue is best-effort: strict page CSP can reject a DOM style | |||
| 72 | + | // element, but target selection and scrolling must stay independent of it. | |||
| 73 | + | style = document.createElement("style"); | |||
| 74 | + | style.id = "dumber-vim-heading-target-style"; | |||
| 75 | + | document.documentElement.appendChild(style); | |||
| 76 | + | } | |||
| 77 | + | // Refresh the outline on every navigation so an in-session accent change is | |||
| 78 | + | // reflected even when the target style element already exists. | |||
| 79 | + | style.textContent = "." + className + | |||
| 80 | + | " { outline: 3px solid " + highlightColor + | |||
| 81 | + | " !important; outline-offset: 5px !important; border-radius: 3px !important; }"; | |||
| 82 | + | window[stateKey] = target; | |||
| 83 | + | target.classList.add(className); | |||
| 84 | + | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | |||
| 85 | + | })();` | |||
| 86 | + | } | |||
A internal/infrastructure/cef/semantic_navigation_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testing.T) { | |
| 16 | + | browser := cefmocks.NewMockBrowser(t) | |
| 17 | + | frame := cefmocks.NewMockFrame(t) | |
| 18 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 19 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |
| 20 | + | "document.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")", | |
| 21 | + | "heading.closest(\"[hidden],[inert],[aria-hidden=\\\"true\\\"]\")", | |
| 22 | + | "window.getComputedStyle(heading)", | |
| 23 | + | "firstAtOrAfterViewportTop", | |
| 24 | + | "if (next < 0 || next >= headings.length) return;", | |
| 25 | + | "previous && previous !== target", | |
| 26 | + | "scrollIntoView", | |
| 27 | + | "window[stateKey] = target", | |
| 28 | + | "const highlightColor = \"#22c55e\"", | |
| 29 | + | "let style = document.getElementById(\"dumber-vim-heading-target-style\")", | |
| 30 | + | "style.textContent = \".\" + className", | |
| 31 | + | "1 * 3", | |
| 32 | + | )), "", int32(0)).Once() | |
| 33 | + | ||
| 34 | + | wv := &WebView{browser: browser} | |
| 35 | + | err := wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{ | |
| 36 | + | Target: dto.SemanticNavigationTargetHeading, | |
| 37 | + | Direction: dto.SemanticNavigationForward, | |
| 38 | + | Count: 3, | |
| 39 | + | HighlightColor: "#22c55e", | |
| 40 | + | }) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | |
| 45 | + | forward := headingNavigationScript(1, 1, "") | |
| 46 | + | backward := headingNavigationScript(-1, 1, "#4ade80") | |
| 47 | + | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | |
| 48 | + | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | |
| 49 | + | assert.Contains(t, backward, "-1 * 1") | |
| 50 | + | assert.NotContains(t, backward, "window.innerHeight") | |
| 51 | + | assert.Contains(t, forward, "const highlightColor = \"#fbbf24\"") | |
| 52 | + | assert.Contains(t, backward, "const highlightColor = \"#4ade80\"") | |
| 53 | + | ||
| 54 | + | wv := &WebView{} | |
| 55 | + | err := wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{ | |
| 56 | + | Target: dto.SemanticNavigationTargetHeading, | |
| 57 | + | Direction: dto.SemanticNavigationForward, | |
| 58 | + | Count: 0, | |
| 59 | + | }) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | ||
| 62 | + | err = wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{ | |
| 63 | + | Target: dto.SemanticNavigationTargetHeading + 1, | |
| 64 | + | Direction: dto.SemanticNavigationForward, | |
| 65 | + | }) | |
| 66 | + | require.Error(t, err) | |
| 67 | + | ||
| 68 | + | err = wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{ | |
| 69 | + | Target: dto.SemanticNavigationTargetHeading, | |
| 70 | + | Direction: 0, | |
| 71 | + | }) | |
| 72 | + | require.Error(t, err) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func mockStringContaining(t *testing.T, fragments ...string) func(string) bool { | |
| 76 | + | t.Helper() | |
| 77 | + | return func(script string) bool { | |
| 78 | + | for _, fragment := range fragments { | |
| 79 | + | if !assert.Contains(t, script, fragment) { | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testing.T) { | |||
| 16 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 17 | + | frame := cefmocks.NewMockFrame(t) | |||
| 18 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 19 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |||
| 20 | + | "document.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")", | |||
| 21 | + | "heading.closest(\"[hidden],[inert],[aria-hidden=\\\"true\\\"]\")", | |||
| 22 | + | "window.getComputedStyle(heading)", | |||
| 23 | + | "firstAtOrAfterViewportTop", | |||
| 24 | + | "if (next < 0 || next >= headings.length) return;", | |||
| 25 | + | "previous && previous !== target", | |||
| 26 | + | "scrollIntoView", | |||
| 27 | + | "window[stateKey] = target", | |||
| 28 | + | "const highlightColor = \"#22c55e\"", | |||
| 29 | + | "let style = document.getElementById(\"dumber-vim-heading-target-style\")", | |||
| 30 | + | "style.textContent = \".\" + className", | |||
| 31 | + | "1 * 3", | |||
| 32 | + | )), "", int32(0)).Once() | |||
| 33 | + | ||||
| 34 | + | wv := &WebView{browser: browser} | |||
| 35 | + | err := wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{ | |||
| 36 | + | Target: dto.SemanticNavigationTargetHeading, | |||
| 37 | + | Direction: dto.SemanticNavigationForward, | |||
| 38 | + | Count: 3, | |||
| 39 | + | HighlightColor: "#22c55e", | |||
| 40 | + | }) | |||
| 41 | + | require.NoError(t, err) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | |||
| 45 | + | forward := headingNavigationScript(1, 1, "") | |||
| 46 | + | backward := headingNavigationScript(-1, 1, "#4ade80") | |||
| 47 | + | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | |||
| 48 | + | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | |||
| 49 | + | assert.Contains(t, backward, "-1 * 1") | |||
| 50 | + | assert.NotContains(t, backward, "window.innerHeight") | |||
| 51 | + | assert.Contains(t, forward, "const highlightColor = \"#fbbf24\"") | |||
| 52 | + | assert.Contains(t, backward, "const highlightColor = \"#4ade80\"") | |||
| 53 | + | ||||
| 54 | + | wv := &WebView{} | |||
| 55 | + | err := wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{ | |||
| 56 | + | Target: dto.SemanticNavigationTargetHeading, | |||
| 57 | + | Direction: dto.SemanticNavigationForward, | |||
| 58 | + | Count: 0, | |||
| 59 | + | }) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | ||||
| 62 | + | err = wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{ | |||
| 63 | + | Target: dto.SemanticNavigationTargetHeading + 1, | |||
| 64 | + | Direction: dto.SemanticNavigationForward, | |||
| 65 | + | }) | |||
| 66 | + | require.Error(t, err) | |||
| 67 | + | ||||
| 68 | + | err = wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{ | |||
| 69 | + | Target: dto.SemanticNavigationTargetHeading, | |||
| 70 | + | Direction: 0, | |||
| 71 | + | }) | |||
| 72 | + | require.Error(t, err) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func mockStringContaining(t *testing.T, fragments ...string) func(string) bool { | |||
| 76 | + | t.Helper() | |||
| 77 | + | return func(script string) bool { | |||
| 78 | + | for _, fragment := range fragments { | |||
| 79 | + | if !assert.Contains(t, script, fragment) { | |||
| 80 | + | return false | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | return true | |||
| 84 | + | } | |||
| 85 | + | } | |||
M internal/infrastructure/config/defaults.go +1 -0
| @@ -320,6 +320,7 @@ func DefaultConfig() *Config { | |||
| 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"}, | |
| 323 | + | "focus-input": {Keys: []string{"gi"}, Desc: "Focus next page input"}, | |
| 323 | 324 | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | |
| 324 | 325 | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | |
| 325 | 326 | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | |
| @@ -320,6 +320,7 @@ func DefaultConfig() *Config { | |||||
| 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"}, | ||
| 323 | + | "focus-input": {Keys: []string{"gi"}, Desc: "Focus next page input"}, | |||
| 323 | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | 324 | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | ||
| 324 | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | 325 | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | ||
| 325 | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | 326 | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | ||
M internal/infrastructure/config/migrate_test.go +35 -0
| @@ -816,6 +816,41 @@ func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) { | |||
| 816 | 816 | assert.Equal(t, "Custom move pane", move["desc"]) | |
| 817 | 817 | } | |
| 818 | 818 | ||
| 819 | + | func TestMigrator_MergeMissingDefaultActions_VimMode(t *testing.T) { | |
| 820 | + | m := NewMigrator() | |
| 821 | + | ||
| 822 | + | rawConfig := map[string]any{ | |
| 823 | + | "workspace": map[string]any{ | |
| 824 | + | "vim_mode": map[string]any{ | |
| 825 | + | "actions": map[string]any{ | |
| 826 | + | "heading-next": map[string]any{ | |
| 827 | + | "keys": []string{"]]"}, | |
| 828 | + | "desc": "Custom next heading", | |
| 829 | + | }, | |
| 830 | + | }, | |
| 831 | + | }, | |
| 832 | + | }, | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | m.mergeMissingDefaultActions(rawConfig) | |
| 836 | + | ||
| 837 | + | actionsAny := m.getNestedValue(rawConfig, "workspace.vim_mode.actions") | |
| 838 | + | actions, ok := actionsAny.(map[string]any) | |
| 839 | + | require.True(t, ok) | |
| 840 | + | ||
| 841 | + | focusAny, hasFocus := actions["focus-input"] | |
| 842 | + | require.True(t, hasFocus) | |
| 843 | + | focus, ok := focusAny.(ActionBinding) | |
| 844 | + | require.True(t, ok) | |
| 845 | + | assert.Equal(t, []string{"gi"}, focus.Keys) | |
| 846 | + | ||
| 847 | + | headingAny, hasHeading := actions["heading-next"] | |
| 848 | + | require.True(t, hasHeading) | |
| 849 | + | heading, ok := headingAny.(map[string]any) | |
| 850 | + | require.True(t, ok) | |
| 851 | + | assert.Equal(t, "Custom next heading", heading["desc"]) | |
| 852 | + | } | |
| 853 | + | ||
| 819 | 854 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | |
| 820 | 855 | m := NewMigrator() | |
| 821 | 856 | ||
| @@ -816,6 +816,41 @@ func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) { | |||||
| 816 | assert.Equal(t, "Custom move pane", move["desc"]) | 816 | assert.Equal(t, "Custom move pane", move["desc"]) | ||
| 817 | } | 817 | } | ||
| 818 | 818 | ||||
| 819 | + | func TestMigrator_MergeMissingDefaultActions_VimMode(t *testing.T) { | |||
| 820 | + | m := NewMigrator() | |||
| 821 | + | ||||
| 822 | + | rawConfig := map[string]any{ | |||
| 823 | + | "workspace": map[string]any{ | |||
| 824 | + | "vim_mode": map[string]any{ | |||
| 825 | + | "actions": map[string]any{ | |||
| 826 | + | "heading-next": map[string]any{ | |||
| 827 | + | "keys": []string{"]]"}, | |||
| 828 | + | "desc": "Custom next heading", | |||
| 829 | + | }, | |||
| 830 | + | }, | |||
| 831 | + | }, | |||
| 832 | + | }, | |||
| 833 | + | } | |||
| 834 | + | ||||
| 835 | + | m.mergeMissingDefaultActions(rawConfig) | |||
| 836 | + | ||||
| 837 | + | actionsAny := m.getNestedValue(rawConfig, "workspace.vim_mode.actions") | |||
| 838 | + | actions, ok := actionsAny.(map[string]any) | |||
| 839 | + | require.True(t, ok) | |||
| 840 | + | ||||
| 841 | + | focusAny, hasFocus := actions["focus-input"] | |||
| 842 | + | require.True(t, hasFocus) | |||
| 843 | + | focus, ok := focusAny.(ActionBinding) | |||
| 844 | + | require.True(t, ok) | |||
| 845 | + | assert.Equal(t, []string{"gi"}, focus.Keys) | |||
| 846 | + | ||||
| 847 | + | headingAny, hasHeading := actions["heading-next"] | |||
| 848 | + | require.True(t, hasHeading) | |||
| 849 | + | heading, ok := headingAny.(map[string]any) | |||
| 850 | + | require.True(t, ok) | |||
| 851 | + | assert.Equal(t, "Custom next heading", heading["desc"]) | |||
| 852 | + | } | |||
| 853 | + | ||||
| 819 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | 854 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | ||
| 820 | m := NewMigrator() | 855 | m := NewMigrator() | ||
| 821 | 856 | ||||
M internal/infrastructure/config/schema_provider_test.go +0 -10
| @@ -18,9 +18,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||
| 18 | 18 | ||
| 19 | 19 | var missing []string | |
| 20 | 20 | for _, key := range NewSchemaProvider().GetSchema() { | |
| 21 | - | if _, pending := schemaKeysPendingProductDocs[key.Key]; pending { | |
| 22 | - | continue | |
| 23 | - | } | |
| 24 | 21 | if configurationReferenceCoversKey(docKeys, key.Key) { | |
| 25 | 22 | continue | |
| 26 | 23 | } | |
| @@ -31,13 +28,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||
| 31 | 28 | require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key") | |
| 32 | 29 | } | |
| 33 | 30 | ||
| 34 | - | // schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from | |
| 35 | - | // product docs until a dedicated docs task lands them. | |
| 36 | - | var schemaKeysPendingProductDocs = map[string]struct{}{ | |
| 37 | - | "workspace.vim_mode.sequence_timeout_ms": {}, | |
| 38 | - | "workspace.vim_mode.preload_accessibility": {}, | |
| 39 | - | } | |
| 40 | - | ||
| 41 | 31 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | |
| 42 | 32 | t.Helper() | |
| 43 | 33 | ||
| @@ -18,9 +18,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||||
| 18 | 18 | ||||
| 19 | var missing []string | 19 | var missing []string | ||
| 20 | for _, key := range NewSchemaProvider().GetSchema() { | 20 | for _, key := range NewSchemaProvider().GetSchema() { | ||
| 21 | - | if _, pending := schemaKeysPendingProductDocs[key.Key]; pending { | |||
| 22 | - | continue | |||
| 23 | - | } | |||
| 24 | if configurationReferenceCoversKey(docKeys, key.Key) { | 21 | if configurationReferenceCoversKey(docKeys, key.Key) { | ||
| 25 | continue | 22 | continue | ||
| 26 | } | 23 | } | ||
| @@ -31,13 +28,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||||
| 31 | require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key") | 28 | require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key") | ||
| 32 | } | 29 | } | ||
| 33 | 30 | ||||
| 34 | - | // schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from | |||
| 35 | - | // product docs until a dedicated docs task lands them. | |||
| 36 | - | var schemaKeysPendingProductDocs = map[string]struct{}{ | |||
| 37 | - | "workspace.vim_mode.sequence_timeout_ms": {}, | |||
| 38 | - | "workspace.vim_mode.preload_accessibility": {}, | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | 31 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | ||
| 42 | t.Helper() | 32 | t.Helper() | ||
| 43 | 33 | ||||
M internal/infrastructure/config/vim_mode_config_test.go +4 -2
| @@ -22,6 +22,7 @@ var legacyVimModeActions = []string{ | |||
| 22 | 22 | var sequenceVimModeActions = map[string]string{ | |
| 23 | 23 | "heading-next": "]]", | |
| 24 | 24 | "heading-prev": "[[", | |
| 25 | + | "focus-input": "gi", | |
| 25 | 26 | "code-next": "]c", | |
| 26 | 27 | "code-prev": "[c", | |
| 27 | 28 | "table-next": "]t", | |
| @@ -65,7 +66,7 @@ func TestVimModeSequenceDefaults(t *testing.T) { | |||
| 65 | 66 | vimMode := cfg.Workspace.VimMode | |
| 66 | 67 | ||
| 67 | 68 | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |
| 68 | - | require.Len(t, sequenceVimModeActions, 11) | |
| 69 | + | require.Len(t, sequenceVimModeActions, 12) | |
| 69 | 70 | ||
| 70 | 71 | for action, key := range sequenceVimModeActions { | |
| 71 | 72 | requireActionBinding(t, vimMode.Actions, action, []string{key}) | |
| @@ -118,6 +119,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||
| 118 | 119 | assert.Equal(t, "cancel", bindings["escape"]) | |
| 119 | 120 | assert.Equal(t, "heading-next", bindings["]]"]) | |
| 120 | 121 | assert.Equal(t, "heading-prev", bindings["[["]) | |
| 122 | + | assert.Equal(t, "focus-input", bindings["gi"]) | |
| 121 | 123 | assert.Equal(t, "code-next", bindings["]c"]) | |
| 122 | 124 | assert.Equal(t, "code-prev", bindings["[c"]) | |
| 123 | 125 | assert.Equal(t, "table-next", bindings["]t"]) | |
| @@ -127,7 +129,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||
| 127 | 129 | assert.Equal(t, "yank-section", bindings["yah"]) | |
| 128 | 130 | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | |
| 129 | 131 | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | |
| 130 | - | assert.Len(t, bindings, 19) | |
| 132 | + | assert.Len(t, bindings, 20) | |
| 131 | 133 | } | |
| 132 | 134 | ||
| 133 | 135 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | |
| @@ -22,6 +22,7 @@ var legacyVimModeActions = []string{ | |||||
| 22 | var sequenceVimModeActions = map[string]string{ | 22 | var sequenceVimModeActions = map[string]string{ | ||
| 23 | "heading-next": "]]", | 23 | "heading-next": "]]", | ||
| 24 | "heading-prev": "[[", | 24 | "heading-prev": "[[", | ||
| 25 | + | "focus-input": "gi", | |||
| 25 | "code-next": "]c", | 26 | "code-next": "]c", | ||
| 26 | "code-prev": "[c", | 27 | "code-prev": "[c", | ||
| 27 | "table-next": "]t", | 28 | "table-next": "]t", | ||
| @@ -65,7 +66,7 @@ func TestVimModeSequenceDefaults(t *testing.T) { | |||||
| 65 | vimMode := cfg.Workspace.VimMode | 66 | vimMode := cfg.Workspace.VimMode | ||
| 66 | 67 | ||||
| 67 | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | 68 | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | ||
| 68 | - | require.Len(t, sequenceVimModeActions, 11) | 69 | + | require.Len(t, sequenceVimModeActions, 12) |
| 69 | 70 | ||||
| 70 | for action, key := range sequenceVimModeActions { | 71 | for action, key := range sequenceVimModeActions { | ||
| 71 | requireActionBinding(t, vimMode.Actions, action, []string{key}) | 72 | requireActionBinding(t, vimMode.Actions, action, []string{key}) | ||
| @@ -118,6 +119,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||||
| 118 | assert.Equal(t, "cancel", bindings["escape"]) | 119 | assert.Equal(t, "cancel", bindings["escape"]) | ||
| 119 | assert.Equal(t, "heading-next", bindings["]]"]) | 120 | assert.Equal(t, "heading-next", bindings["]]"]) | ||
| 120 | assert.Equal(t, "heading-prev", bindings["[["]) | 121 | assert.Equal(t, "heading-prev", bindings["[["]) | ||
| 122 | + | assert.Equal(t, "focus-input", bindings["gi"]) | |||
| 121 | assert.Equal(t, "code-next", bindings["]c"]) | 123 | assert.Equal(t, "code-next", bindings["]c"]) | ||
| 122 | assert.Equal(t, "code-prev", bindings["[c"]) | 124 | assert.Equal(t, "code-prev", bindings["[c"]) | ||
| 123 | assert.Equal(t, "table-next", bindings["]t"]) | 125 | assert.Equal(t, "table-next", bindings["]t"]) | ||
| @@ -127,7 +129,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||||
| 127 | assert.Equal(t, "yank-section", bindings["yah"]) | 129 | assert.Equal(t, "yank-section", bindings["yah"]) | ||
| 128 | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | 130 | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | ||
| 129 | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | 131 | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | ||
| 130 | - | assert.Len(t, bindings, 19) | 132 | + | assert.Len(t, bindings, 20) |
| 131 | } | 133 | } | ||
| 132 | 134 | ||||
| 133 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | 135 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | ||
M internal/infrastructure/webkit/injector.go +3 -0
| @@ -194,8 +194,11 @@ func buildAccentDetectionScript(token string) string { | |||
| 194 | 194 | const accentKeys = new Set([%s]); | |
| 195 | 195 | const editableFocusToken = %q; | |
| 196 | 196 | let pressedKey = null; | |
| 197 | + | let lastEditableFocusState = null; | |
| 197 | 198 | ||
| 198 | 199 | function postEditableFocus(editable) { | |
| 200 | + | if (lastEditableFocusState === editable) return; | |
| 201 | + | lastEditableFocusState = editable; | |
| 199 | 202 | if (!editableFocusToken) return; | |
| 200 | 203 | if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return; | |
| 201 | 204 | window.webkit.messageHandlers.dumber.postMessage({ | |
| @@ -194,8 +194,11 @@ func buildAccentDetectionScript(token string) string { | |||||
| 194 | const accentKeys = new Set([%s]); | 194 | const accentKeys = new Set([%s]); | ||
| 195 | const editableFocusToken = %q; | 195 | const editableFocusToken = %q; | ||
| 196 | let pressedKey = null; | 196 | let pressedKey = null; | ||
| 197 | + | let lastEditableFocusState = null; | |||
| 197 | 198 | ||||
| 198 | function postEditableFocus(editable) { | 199 | function postEditableFocus(editable) { | ||
| 200 | + | if (lastEditableFocusState === editable) return; | |||
| 201 | + | lastEditableFocusState = editable; | |||
| 199 | if (!editableFocusToken) return; | 202 | if (!editableFocusToken) return; | ||
| 200 | if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return; | 203 | if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return; | ||
| 201 | window.webkit.messageHandlers.dumber.postMessage({ | 204 | window.webkit.messageHandlers.dumber.postMessage({ | ||
M internal/infrastructure/webkit/injector_accent_test.go +2 -0
| @@ -38,6 +38,8 @@ func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) { | |||
| 38 | 38 | assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'") | |
| 39 | 39 | assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`) | |
| 40 | 40 | assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`) | |
| 41 | + | assert.Contains(t, accentDetectionScript, "let lastEditableFocusState = null;") | |
| 42 | + | assert.Contains(t, accentDetectionScript, "if (lastEditableFocusState === editable) return;") | |
| 41 | 43 | assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`) | |
| 42 | 44 | assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`) | |
| 43 | 45 | assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`) | |
| @@ -38,6 +38,8 @@ func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) { | |||||
| 38 | assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'") | 38 | assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'") | ||
| 39 | assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`) | 39 | assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`) | ||
| 40 | assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`) | 40 | assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`) | ||
| 41 | + | assert.Contains(t, accentDetectionScript, "let lastEditableFocusState = null;") | |||
| 42 | + | assert.Contains(t, accentDetectionScript, "if (lastEditableFocusState === editable) return;") | |||
| 41 | assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`) | 43 | assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`) | ||
| 42 | assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`) | 44 | assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`) | ||
| 43 | assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`) | 45 | assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`) | ||
M internal/ui/app.go +88 -0
| @@ -124,6 +124,7 @@ type App struct { | |||
| 124 | 124 | focusMgr *focus.Manager | |
| 125 | 125 | ||
| 126 | 126 | vimModePolicyUC *usecase.VimModePolicyUseCase | |
| 127 | + | vimNavigationUC *usecase.VimNavigationUseCase | |
| 127 | 128 | pageEditableFocusByPane map[entity.PaneID]bool | |
| 128 | 129 | ||
| 129 | 130 | resizeModeBorderTarget layout.Widget | |
| @@ -217,6 +218,7 @@ func New(deps *Dependencies) (*App, error) { | |||
| 217 | 218 | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 218 | 219 | browserWindows: make(map[string]*browserWindow), | |
| 219 | 220 | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | |
| 221 | + | vimNavigationUC: usecase.NewVimNavigationUseCase(), | |
| 220 | 222 | pageEditableFocusByPane: make(map[entity.PaneID]bool), | |
| 221 | 223 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | |
| 222 | 224 | if fn != nil { | |
| @@ -1101,6 +1103,12 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1101 | 1103 | a.handleModeChange(ctx, bw, from, to) | |
| 1102 | 1104 | }) | |
| 1103 | 1105 | a.bindVimModeSequenceToaster(ctx, bw) | |
| 1106 | + | bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) { | |
| 1107 | + | a.navigateVimSequenceAction(ctx, bw, action, count) | |
| 1108 | + | }) | |
| 1109 | + | bw.keyboardHandler.SetOnPageFocusNavigation(func(navigationCtx context.Context, backward bool) bool { | |
| 1110 | + | return a.navigatePageFocus(navigationCtx, bw, backward) | |
| 1111 | + | }) | |
| 1104 | 1112 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 1105 | 1113 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | |
| 1106 | 1114 | return input.RoutePassToWidget | |
| @@ -2160,6 +2168,81 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p | |||
| 2160 | 2168 | return paneID, a.contentCoord.GetWebView(paneID) | |
| 2161 | 2169 | } | |
| 2162 | 2170 | ||
| 2171 | + | func (a *App) navigatePageFocus(_ context.Context, bw *browserWindow, backward bool) bool { | |
| 2172 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2173 | + | if paneID == "" || !a.pageEditableFocused(paneID) { | |
| 2174 | + | return false | |
| 2175 | + | } | |
| 2176 | + | navigationUC := a.vimNavigationUseCase() | |
| 2177 | + | if navigationUC == nil { | |
| 2178 | + | return false | |
| 2179 | + | } | |
| 2180 | + | return navigationUC.NavigatePageFocus(wv, backward) | |
| 2181 | + | } | |
| 2182 | + | ||
| 2183 | + | func (a *App) vimNavigationUseCase() *usecase.VimNavigationUseCase { | |
| 2184 | + | if a == nil { | |
| 2185 | + | return nil | |
| 2186 | + | } | |
| 2187 | + | if a.vimNavigationUC == nil { | |
| 2188 | + | a.vimNavigationUC = usecase.NewVimNavigationUseCase() | |
| 2189 | + | } | |
| 2190 | + | return a.vimNavigationUC | |
| 2191 | + | } | |
| 2192 | + | ||
| 2193 | + | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | |
| 2194 | + | _, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2195 | + | navigationUC := a.vimNavigationUseCase() | |
| 2196 | + | if navigationUC == nil { | |
| 2197 | + | return | |
| 2198 | + | } | |
| 2199 | + | if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil { | |
| 2200 | + | logging.FromContext(ctx).Debug(). | |
| 2201 | + | Err(err). | |
| 2202 | + | Str("action", action). | |
| 2203 | + | Msg("vim navigation unavailable") | |
| 2204 | + | } | |
| 2205 | + | } | |
| 2206 | + | ||
| 2207 | + | func (a *App) vimNavigationHighlightColor() string { | |
| 2208 | + | if a == nil || a.deps == nil || a.deps.Theme == nil { | |
| 2209 | + | return "" | |
| 2210 | + | } | |
| 2211 | + | return a.deps.Theme.GetCurrentPalette().Accent | |
| 2212 | + | } | |
| 2213 | + | ||
| 2214 | + | func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) { | |
| 2215 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2216 | + | if wv == nil { | |
| 2217 | + | return | |
| 2218 | + | } | |
| 2219 | + | enabler, ok := wv.(port.AccessibilityEnabler) | |
| 2220 | + | if !ok { | |
| 2221 | + | return | |
| 2222 | + | } | |
| 2223 | + | enabler.EnableAccessibility() | |
| 2224 | + | windowID := "" | |
| 2225 | + | if bw != nil { | |
| 2226 | + | windowID = bw.id | |
| 2227 | + | } | |
| 2228 | + | logging.FromContext(ctx).Debug(). | |
| 2229 | + | Str("window_id", windowID). | |
| 2230 | + | Str("pane_id", string(paneID)). | |
| 2231 | + | Msg("webview accessibility enabled for vim mode") | |
| 2232 | + | } | |
| 2233 | + | ||
| 2234 | + | func (a *App) preloadAccessibilityForShownPane(paneID entity.PaneID) { | |
| 2235 | + | if a == nil || paneID == "" || !a.runtimeConfigSnapshot().UI.Workspace.VimMode.PreloadAccessibility || a.contentCoord == nil { | |
| 2236 | + | return | |
| 2237 | + | } | |
| 2238 | + | wv := a.contentCoord.GetWebView(paneID) | |
| 2239 | + | enabler, ok := wv.(port.AccessibilityEnabler) | |
| 2240 | + | if !ok { | |
| 2241 | + | return | |
| 2242 | + | } | |
| 2243 | + | enabler.EnableAccessibility() | |
| 2244 | + | } | |
| 2245 | + | ||
| 2163 | 2246 | // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes | |
| 2164 | 2247 | // navigation through navigate for the supplied browser window. The closure captures | |
| 2165 | 2248 | // bw at creation time, so navigation targets the owning window even when another | |
| @@ -3300,6 +3383,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3300 | 3383 | a.deps.OnFirstWebViewShown(ctx) | |
| 3301 | 3384 | }) | |
| 3302 | 3385 | } | |
| 3386 | + | a.preloadAccessibilityForShownPane(paneID) | |
| 3303 | 3387 | ||
| 3304 | 3388 | // This only updates focus when the shown pane belongs to the last-focused | |
| 3305 | 3389 | // window's active workspace. activeWorkspace() delegates to | |
| @@ -3861,6 +3945,10 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to | |||
| 3861 | 3945 | a.applyResizeModeBorder(ctx, a.activeWorkspace()) | |
| 3862 | 3946 | } | |
| 3863 | 3947 | ||
| 3948 | + | if to == input.ModeVim && from != input.ModeVim { | |
| 3949 | + | a.enableAccessibilityForVimMode(ctx, bw) | |
| 3950 | + | } | |
| 3951 | + | ||
| 3864 | 3952 | // Handle pane-local Vim Mode visual ownership. | |
| 3865 | 3953 | // Entering Vim Mode accents the active pane; leaving removes the accent. | |
| 3866 | 3954 | a.handleVimModeOwnership(ctx, bw, to, from) | |
| @@ -124,6 +124,7 @@ type App struct { | |||||
| 124 | focusMgr *focus.Manager | 124 | focusMgr *focus.Manager | ||
| 125 | 125 | ||||
| 126 | vimModePolicyUC *usecase.VimModePolicyUseCase | 126 | vimModePolicyUC *usecase.VimModePolicyUseCase | ||
| 127 | + | vimNavigationUC *usecase.VimNavigationUseCase | |||
| 127 | pageEditableFocusByPane map[entity.PaneID]bool | 128 | pageEditableFocusByPane map[entity.PaneID]bool | ||
| 128 | 129 | ||||
| 129 | resizeModeBorderTarget layout.Widget | 130 | resizeModeBorderTarget layout.Widget | ||
| @@ -217,6 +218,7 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 217 | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | 218 | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | ||
| 218 | browserWindows: make(map[string]*browserWindow), | 219 | browserWindows: make(map[string]*browserWindow), | ||
| 219 | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | 220 | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | ||
| 221 | + | vimNavigationUC: usecase.NewVimNavigationUseCase(), | |||
| 220 | pageEditableFocusByPane: make(map[entity.PaneID]bool), | 222 | pageEditableFocusByPane: make(map[entity.PaneID]bool), | ||
| 221 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | 223 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | ||
| 222 | if fn != nil { | 224 | if fn != nil { | ||
| @@ -1101,6 +1103,12 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||||
| 1101 | a.handleModeChange(ctx, bw, from, to) | 1103 | a.handleModeChange(ctx, bw, from, to) | ||
| 1102 | }) | 1104 | }) | ||
| 1103 | a.bindVimModeSequenceToaster(ctx, bw) | 1105 | a.bindVimModeSequenceToaster(ctx, bw) | ||
| 1106 | + | bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) { | |||
| 1107 | + | a.navigateVimSequenceAction(ctx, bw, action, count) | |||
| 1108 | + | }) | |||
| 1109 | + | bw.keyboardHandler.SetOnPageFocusNavigation(func(navigationCtx context.Context, backward bool) bool { | |||
| 1110 | + | return a.navigatePageFocus(navigationCtx, bw, backward) | |||
| 1111 | + | }) | |||
| 1104 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | 1112 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | ||
| 1105 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | 1113 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | ||
| 1106 | return input.RoutePassToWidget | 1114 | return input.RoutePassToWidget | ||
| @@ -2160,6 +2168,81 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p | |||||
| 2160 | return paneID, a.contentCoord.GetWebView(paneID) | 2168 | return paneID, a.contentCoord.GetWebView(paneID) | ||
| 2161 | } | 2169 | } | ||
| 2162 | 2170 | ||||
| 2171 | + | func (a *App) navigatePageFocus(_ context.Context, bw *browserWindow, backward bool) bool { | |||
| 2172 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |||
| 2173 | + | if paneID == "" || !a.pageEditableFocused(paneID) { | |||
| 2174 | + | return false | |||
| 2175 | + | } | |||
| 2176 | + | navigationUC := a.vimNavigationUseCase() | |||
| 2177 | + | if navigationUC == nil { | |||
| 2178 | + | return false | |||
| 2179 | + | } | |||
| 2180 | + | return navigationUC.NavigatePageFocus(wv, backward) | |||
| 2181 | + | } | |||
| 2182 | + | ||||
| 2183 | + | func (a *App) vimNavigationUseCase() *usecase.VimNavigationUseCase { | |||
| 2184 | + | if a == nil { | |||
| 2185 | + | return nil | |||
| 2186 | + | } | |||
| 2187 | + | if a.vimNavigationUC == nil { | |||
| 2188 | + | a.vimNavigationUC = usecase.NewVimNavigationUseCase() | |||
| 2189 | + | } | |||
| 2190 | + | return a.vimNavigationUC | |||
| 2191 | + | } | |||
| 2192 | + | ||||
| 2193 | + | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | |||
| 2194 | + | _, wv := a.activeWebViewForBrowserWindow(bw) | |||
| 2195 | + | navigationUC := a.vimNavigationUseCase() | |||
| 2196 | + | if navigationUC == nil { | |||
| 2197 | + | return | |||
| 2198 | + | } | |||
| 2199 | + | if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil { | |||
| 2200 | + | logging.FromContext(ctx).Debug(). | |||
| 2201 | + | Err(err). | |||
| 2202 | + | Str("action", action). | |||
| 2203 | + | Msg("vim navigation unavailable") | |||
| 2204 | + | } | |||
| 2205 | + | } | |||
| 2206 | + | ||||
| 2207 | + | func (a *App) vimNavigationHighlightColor() string { | |||
| 2208 | + | if a == nil || a.deps == nil || a.deps.Theme == nil { | |||
| 2209 | + | return "" | |||
| 2210 | + | } | |||
| 2211 | + | return a.deps.Theme.GetCurrentPalette().Accent | |||
| 2212 | + | } | |||
| 2213 | + | ||||
| 2214 | + | func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) { | |||
| 2215 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |||
| 2216 | + | if wv == nil { | |||
| 2217 | + | return | |||
| 2218 | + | } | |||
| 2219 | + | enabler, ok := wv.(port.AccessibilityEnabler) | |||
| 2220 | + | if !ok { | |||
| 2221 | + | return | |||
| 2222 | + | } | |||
| 2223 | + | enabler.EnableAccessibility() | |||
| 2224 | + | windowID := "" | |||
| 2225 | + | if bw != nil { | |||
| 2226 | + | windowID = bw.id | |||
| 2227 | + | } | |||
| 2228 | + | logging.FromContext(ctx).Debug(). | |||
| 2229 | + | Str("window_id", windowID). | |||
| 2230 | + | Str("pane_id", string(paneID)). | |||
| 2231 | + | Msg("webview accessibility enabled for vim mode") | |||
| 2232 | + | } | |||
| 2233 | + | ||||
| 2234 | + | func (a *App) preloadAccessibilityForShownPane(paneID entity.PaneID) { | |||
| 2235 | + | if a == nil || paneID == "" || !a.runtimeConfigSnapshot().UI.Workspace.VimMode.PreloadAccessibility || a.contentCoord == nil { | |||
| 2236 | + | return | |||
| 2237 | + | } | |||
| 2238 | + | wv := a.contentCoord.GetWebView(paneID) | |||
| 2239 | + | enabler, ok := wv.(port.AccessibilityEnabler) | |||
| 2240 | + | if !ok { | |||
| 2241 | + | return | |||
| 2242 | + | } | |||
| 2243 | + | enabler.EnableAccessibility() | |||
| 2244 | + | } | |||
| 2245 | + | ||||
| 2163 | // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes | 2246 | // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes | ||
| 2164 | // navigation through navigate for the supplied browser window. The closure captures | 2247 | // navigation through navigate for the supplied browser window. The closure captures | ||
| 2165 | // bw at creation time, so navigation targets the owning window even when another | 2248 | // bw at creation time, so navigation targets the owning window even when another | ||
| @@ -3300,6 +3383,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3300 | a.deps.OnFirstWebViewShown(ctx) | 3383 | a.deps.OnFirstWebViewShown(ctx) | ||
| 3301 | }) | 3384 | }) | ||
| 3302 | } | 3385 | } | ||
| 3386 | + | a.preloadAccessibilityForShownPane(paneID) | |||
| 3303 | 3387 | ||||
| 3304 | // This only updates focus when the shown pane belongs to the last-focused | 3388 | // This only updates focus when the shown pane belongs to the last-focused | ||
| 3305 | // window's active workspace. activeWorkspace() delegates to | 3389 | // window's active workspace. activeWorkspace() delegates to | ||
| @@ -3861,6 +3945,10 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to | |||||
| 3861 | a.applyResizeModeBorder(ctx, a.activeWorkspace()) | 3945 | a.applyResizeModeBorder(ctx, a.activeWorkspace()) | ||
| 3862 | } | 3946 | } | ||
| 3863 | 3947 | ||||
| 3948 | + | if to == input.ModeVim && from != input.ModeVim { | |||
| 3949 | + | a.enableAccessibilityForVimMode(ctx, bw) | |||
| 3950 | + | } | |||
| 3951 | + | ||||
| 3864 | // Handle pane-local Vim Mode visual ownership. | 3952 | // Handle pane-local Vim Mode visual ownership. | ||
| 3865 | // Entering Vim Mode accents the active pane; leaving removes the accent. | 3953 | // Entering Vim Mode accents the active pane; leaving removes the accent. | ||
| 3866 | a.handleVimModeOwnership(ctx, bw, to, from) | 3954 | a.handleVimModeOwnership(ctx, bw, to, from) | ||
M internal/ui/app_vim_mode_test.go +207 -4
| @@ -10,9 +10,13 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/mock" | |
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |
| 15 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 13 | 16 | "github.com/bnema/dumber/internal/application/usecase" | |
| 14 | 17 | "github.com/bnema/dumber/internal/domain/entity" | |
| 15 | 18 | "github.com/bnema/dumber/internal/ui/component" | |
| 19 | + | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 16 | 20 | "github.com/bnema/dumber/internal/ui/focus" | |
| 17 | 21 | "github.com/bnema/dumber/internal/ui/input" | |
| 18 | 22 | "github.com/bnema/dumber/internal/ui/layout" | |
| @@ -154,6 +158,81 @@ func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) { | |||
| 154 | 158 | overlay.EXPECT().RemoveCssClass("vim-mode-active").Once() | |
| 155 | 159 | } | |
| 156 | 160 | ||
| 161 | + | type accessibilityEnablingWebView struct { | |
| 162 | + | *portmocks.MockWebView | |
| 163 | + | enableAccessibilityCalls int | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func newAccessibilityEnablingWebView(t *testing.T, id port.WebViewID) *accessibilityEnablingWebView { | |
| 167 | + | t.Helper() | |
| 168 | + | wv := portmocks.NewMockWebView(t) | |
| 169 | + | wv.EXPECT().ID().Return(id).Once() | |
| 170 | + | return &accessibilityEnablingWebView{MockWebView: wv} | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (wv *accessibilityEnablingWebView) EnableAccessibility() { | |
| 174 | + | wv.enableAccessibilityCalls++ | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | type semanticNavigatingWebView struct { | |
| 178 | + | *portmocks.MockWebView | |
| 179 | + | requests []dto.SemanticNavigationRequest | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView { | |
| 183 | + | t.Helper() | |
| 184 | + | wv := portmocks.NewMockWebView(t) | |
| 185 | + | wv.EXPECT().ID().Return(id).Once() | |
| 186 | + | return &semanticNavigatingWebView{MockWebView: wv} | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request dto.SemanticNavigationRequest) error { | |
| 190 | + | wv.requests = append(wv.requests, request) | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | type inputFocusingWebView struct { | |
| 195 | + | *portmocks.MockWebView | |
| 196 | + | focusNextInputCalls int | |
| 197 | + | pageFocusNavigationCalls []bool | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView { | |
| 201 | + | t.Helper() | |
| 202 | + | wv := portmocks.NewMockWebView(t) | |
| 203 | + | wv.EXPECT().ID().Return(id).Once() | |
| 204 | + | return &inputFocusingWebView{MockWebView: wv} | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func (wv *inputFocusingWebView) FocusNextInput() { | |
| 208 | + | wv.focusNextInputCalls++ | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (wv *inputFocusingWebView) NavigatePageFocus(backward bool) { | |
| 212 | + | wv.pageFocusNavigationCalls = append(wv.pageFocusNavigationCalls, backward) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | |
| 216 | + | t.Helper() | |
| 217 | + | paneID := entity.PaneID("pane-a") | |
| 218 | + | ws := &entity.Workspace{ | |
| 219 | + | ID: "ws-1", | |
| 220 | + | ActivePaneID: paneID, | |
| 221 | + | Root: &entity.PaneNode{ | |
| 222 | + | ID: "ws-1-root", | |
| 223 | + | Pane: entity.NewPane(paneID), | |
| 224 | + | }, | |
| 225 | + | } | |
| 226 | + | tab := &entity.Tab{ID: "tab-1", Workspace: ws} | |
| 227 | + | tabs := entity.NewTabList() | |
| 228 | + | tabs.Add(tab) | |
| 229 | + | tabs.SetActive(tab.ID) | |
| 230 | + | bw := &browserWindow{id: "win-1", tabs: tabs} | |
| 231 | + | return &App{ | |
| 232 | + | contentCoord: contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil), | |
| 233 | + | }, bw, paneID | |
| 234 | + | } | |
| 235 | + | ||
| 157 | 236 | // setupWorkspaceViewMocks creates a mock-backed WorkspaceView. | |
| 158 | 237 | func setupWorkspaceViewMocks( | |
| 159 | 238 | t *testing.T, | |
| @@ -351,6 +430,130 @@ func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) { | |||
| 351 | 430 | assert.False(t, f.pv1A.IsVimMode()) | |
| 352 | 431 | } | |
| 353 | 432 | ||
| 433 | + | func TestVimMode_Enter_EnablesAccessibilityForSupportedActiveWebView(t *testing.T) { | |
| 434 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 435 | + | wv := newAccessibilityEnablingWebView(t, 1) | |
| 436 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 437 | + | ||
| 438 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |
| 439 | + | ||
| 440 | + | assert.Equal(t, 1, wv.enableAccessibilityCalls) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func TestVimMode_Enter_AccessibilityNoopsForUnsupportedActiveWebView(t *testing.T) { | |
| 444 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 445 | + | wv := portmocks.NewMockWebView(t) | |
| 446 | + | wv.EXPECT().ID().Return(port.WebViewID(2)).Once() | |
| 447 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 448 | + | ||
| 449 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) { | |
| 453 | + | app, bw, _ := newVimModeAccessibilityFixture(t) | |
| 454 | + | ||
| 455 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) { | |
| 459 | + | app, _, paneID := newVimModeAccessibilityFixture(t) | |
| 460 | + | wv := newAccessibilityEnablingWebView(t, 4) | |
| 461 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 462 | + | app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |
| 463 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ | |
| 464 | + | VimMode: entity.VimModeConfig{PreloadAccessibility: true}, | |
| 465 | + | }}, | |
| 466 | + | }) | |
| 467 | + | ||
| 468 | + | app.preloadAccessibilityForShownPane(paneID) | |
| 469 | + | ||
| 470 | + | assert.Equal(t, 1, wv.enableAccessibilityCalls) | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) { | |
| 474 | + | app, _, paneID := newVimModeAccessibilityFixture(t) | |
| 475 | + | wv := newAccessibilityEnablingWebView(t, 5) | |
| 476 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 477 | + | ||
| 478 | + | app.preloadAccessibilityForShownPane(paneID) | |
| 479 | + | ||
| 480 | + | assert.Equal(t, 0, wv.enableAccessibilityCalls) | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) { | |
| 484 | + | resolved := entity.ResolvedTheme{ | |
| 485 | + | PrefersDark: true, | |
| 486 | + | DarkPalette: entity.ColorPalette{ | |
| 487 | + | Accent: "#22c55e", | |
| 488 | + | }, | |
| 489 | + | } | |
| 490 | + | app := &App{deps: &Dependencies{Theme: theme.NewManager(context.Background(), resolved)}} | |
| 491 | + | ||
| 492 | + | assert.Equal(t, "#22c55e", app.vimNavigationHighlightColor()) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |
| 496 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 497 | + | wv := newSemanticNavigatingWebView(t, 4) | |
| 498 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 499 | + | ||
| 500 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3) | |
| 501 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0) | |
| 502 | + | app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1) | |
| 503 | + | ||
| 504 | + | assert.Equal(t, []dto.SemanticNavigationRequest{ | |
| 505 | + | {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationForward, Count: 3}, | |
| 506 | + | {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationBackward, Count: 0}, | |
| 507 | + | }, wv.requests) | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) { | |
| 511 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 512 | + | wv := newInputFocusingWebView(t, 6) | |
| 513 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 514 | + | ||
| 515 | + | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | |
| 516 | + | ||
| 517 | + | assert.Equal(t, 1, wv.focusNextInputCalls) | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) { | |
| 521 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 522 | + | wv := newInputFocusingWebView(t, 7) | |
| 523 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 524 | + | app.pageEditableFocusByPane = map[entity.PaneID]bool{paneID: true} | |
| 525 | + | ||
| 526 | + | assert.True(t, app.navigatePageFocus(context.Background(), bw, true)) | |
| 527 | + | assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls) | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | |
| 531 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 532 | + | wv := portmocks.NewMockWebView(t) | |
| 533 | + | wv.EXPECT().ID().Return(port.WebViewID(5)).Once() | |
| 534 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 535 | + | ||
| 536 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | |
| 537 | + | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) { | |
| 541 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 542 | + | wv := newAccessibilityEnablingWebView(t, 3) | |
| 543 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 544 | + | ||
| 545 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeTab) | |
| 546 | + | app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal) | |
| 547 | + | app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeVim) | |
| 548 | + | assert.Equal(t, 0, wv.enableAccessibilityCalls) | |
| 549 | + | ||
| 550 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |
| 551 | + | assert.Equal(t, 1, wv.enableAccessibilityCalls) | |
| 552 | + | ||
| 553 | + | app.handleModeChange(context.Background(), bw, input.ModePane, input.ModeVim) | |
| 554 | + | assert.Equal(t, 2, wv.enableAccessibilityCalls) | |
| 555 | + | } | |
| 556 | + | ||
| 354 | 557 | // ============================================================================ | |
| 355 | 558 | // 2. Leaving Vim mode clears ownership | |
| 356 | 559 | // ============================================================================ | |
| @@ -717,20 +920,20 @@ func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) { | |||
| 717 | 920 | assert.False(t, f.pv1A.IsVimMode()) | |
| 718 | 921 | } | |
| 719 | 922 | ||
| 720 | - | func TestVimMode_ActivationBypassWhenActivePageIsEditable(t *testing.T) { | |
| 923 | + | func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) { | |
| 721 | 924 | f := newSingleWindowVimModeFixture(t) | |
| 722 | 925 | f.app.pageEditableFocusByPane = map[entity.PaneID]bool{ | |
| 723 | 926 | "pane-a": true, | |
| 724 | 927 | } | |
| 725 | 928 | ||
| 726 | - | assert.True(t, f.app.shouldBypassVimModeActivation(f.bw1)) | |
| 929 | + | assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1)) | |
| 727 | 930 | } | |
| 728 | 931 | ||
| 729 | - | func TestVimMode_ActivationBypassClearsWhenEditableFocusLeaves(t *testing.T) { | |
| 932 | + | func TestVimMode_ActivationAvailabilityDoesNotDependOnEditableFocus(t *testing.T) { | |
| 730 | 933 | f := newSingleWindowVimModeFixture(t) | |
| 731 | 934 | ||
| 732 | 935 | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true) | |
| 733 | - | assert.True(t, f.app.shouldBypassVimModeActivation(f.bw1)) | |
| 936 | + | assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1)) | |
| 734 | 937 | ||
| 735 | 938 | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), false) | |
| 736 | 939 | assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1)) | |
| @@ -10,9 +10,13 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/mock" | 10 | "github.com/stretchr/testify/mock" | ||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 15 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 13 | "github.com/bnema/dumber/internal/application/usecase" | 16 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 14 | "github.com/bnema/dumber/internal/domain/entity" | 17 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 15 | "github.com/bnema/dumber/internal/ui/component" | 18 | "github.com/bnema/dumber/internal/ui/component" | ||
| 19 | + | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |||
| 16 | "github.com/bnema/dumber/internal/ui/focus" | 20 | "github.com/bnema/dumber/internal/ui/focus" | ||
| 17 | "github.com/bnema/dumber/internal/ui/input" | 21 | "github.com/bnema/dumber/internal/ui/input" | ||
| 18 | "github.com/bnema/dumber/internal/ui/layout" | 22 | "github.com/bnema/dumber/internal/ui/layout" | ||
| @@ -154,6 +158,81 @@ func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) { | |||||
| 154 | overlay.EXPECT().RemoveCssClass("vim-mode-active").Once() | 158 | overlay.EXPECT().RemoveCssClass("vim-mode-active").Once() | ||
| 155 | } | 159 | } | ||
| 156 | 160 | ||||
| 161 | + | type accessibilityEnablingWebView struct { | |||
| 162 | + | *portmocks.MockWebView | |||
| 163 | + | enableAccessibilityCalls int | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func newAccessibilityEnablingWebView(t *testing.T, id port.WebViewID) *accessibilityEnablingWebView { | |||
| 167 | + | t.Helper() | |||
| 168 | + | wv := portmocks.NewMockWebView(t) | |||
| 169 | + | wv.EXPECT().ID().Return(id).Once() | |||
| 170 | + | return &accessibilityEnablingWebView{MockWebView: wv} | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (wv *accessibilityEnablingWebView) EnableAccessibility() { | |||
| 174 | + | wv.enableAccessibilityCalls++ | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | type semanticNavigatingWebView struct { | |||
| 178 | + | *portmocks.MockWebView | |||
| 179 | + | requests []dto.SemanticNavigationRequest | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView { | |||
| 183 | + | t.Helper() | |||
| 184 | + | wv := portmocks.NewMockWebView(t) | |||
| 185 | + | wv.EXPECT().ID().Return(id).Once() | |||
| 186 | + | return &semanticNavigatingWebView{MockWebView: wv} | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request dto.SemanticNavigationRequest) error { | |||
| 190 | + | wv.requests = append(wv.requests, request) | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | type inputFocusingWebView struct { | |||
| 195 | + | *portmocks.MockWebView | |||
| 196 | + | focusNextInputCalls int | |||
| 197 | + | pageFocusNavigationCalls []bool | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView { | |||
| 201 | + | t.Helper() | |||
| 202 | + | wv := portmocks.NewMockWebView(t) | |||
| 203 | + | wv.EXPECT().ID().Return(id).Once() | |||
| 204 | + | return &inputFocusingWebView{MockWebView: wv} | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func (wv *inputFocusingWebView) FocusNextInput() { | |||
| 208 | + | wv.focusNextInputCalls++ | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (wv *inputFocusingWebView) NavigatePageFocus(backward bool) { | |||
| 212 | + | wv.pageFocusNavigationCalls = append(wv.pageFocusNavigationCalls, backward) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | |||
| 216 | + | t.Helper() | |||
| 217 | + | paneID := entity.PaneID("pane-a") | |||
| 218 | + | ws := &entity.Workspace{ | |||
| 219 | + | ID: "ws-1", | |||
| 220 | + | ActivePaneID: paneID, | |||
| 221 | + | Root: &entity.PaneNode{ | |||
| 222 | + | ID: "ws-1-root", | |||
| 223 | + | Pane: entity.NewPane(paneID), | |||
| 224 | + | }, | |||
| 225 | + | } | |||
| 226 | + | tab := &entity.Tab{ID: "tab-1", Workspace: ws} | |||
| 227 | + | tabs := entity.NewTabList() | |||
| 228 | + | tabs.Add(tab) | |||
| 229 | + | tabs.SetActive(tab.ID) | |||
| 230 | + | bw := &browserWindow{id: "win-1", tabs: tabs} | |||
| 231 | + | return &App{ | |||
| 232 | + | contentCoord: contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil), | |||
| 233 | + | }, bw, paneID | |||
| 234 | + | } | |||
| 235 | + | ||||
| 157 | // setupWorkspaceViewMocks creates a mock-backed WorkspaceView. | 236 | // setupWorkspaceViewMocks creates a mock-backed WorkspaceView. | ||
| 158 | func setupWorkspaceViewMocks( | 237 | func setupWorkspaceViewMocks( | ||
| 159 | t *testing.T, | 238 | t *testing.T, | ||
| @@ -351,6 +430,130 @@ func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) { | |||||
| 351 | assert.False(t, f.pv1A.IsVimMode()) | 430 | assert.False(t, f.pv1A.IsVimMode()) | ||
| 352 | } | 431 | } | ||
| 353 | 432 | ||||
| 433 | + | func TestVimMode_Enter_EnablesAccessibilityForSupportedActiveWebView(t *testing.T) { | |||
| 434 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 435 | + | wv := newAccessibilityEnablingWebView(t, 1) | |||
| 436 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 437 | + | ||||
| 438 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |||
| 439 | + | ||||
| 440 | + | assert.Equal(t, 1, wv.enableAccessibilityCalls) | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | func TestVimMode_Enter_AccessibilityNoopsForUnsupportedActiveWebView(t *testing.T) { | |||
| 444 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 445 | + | wv := portmocks.NewMockWebView(t) | |||
| 446 | + | wv.EXPECT().ID().Return(port.WebViewID(2)).Once() | |||
| 447 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 448 | + | ||||
| 449 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) { | |||
| 453 | + | app, bw, _ := newVimModeAccessibilityFixture(t) | |||
| 454 | + | ||||
| 455 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) { | |||
| 459 | + | app, _, paneID := newVimModeAccessibilityFixture(t) | |||
| 460 | + | wv := newAccessibilityEnablingWebView(t, 4) | |||
| 461 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 462 | + | app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |||
| 463 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ | |||
| 464 | + | VimMode: entity.VimModeConfig{PreloadAccessibility: true}, | |||
| 465 | + | }}, | |||
| 466 | + | }) | |||
| 467 | + | ||||
| 468 | + | app.preloadAccessibilityForShownPane(paneID) | |||
| 469 | + | ||||
| 470 | + | assert.Equal(t, 1, wv.enableAccessibilityCalls) | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) { | |||
| 474 | + | app, _, paneID := newVimModeAccessibilityFixture(t) | |||
| 475 | + | wv := newAccessibilityEnablingWebView(t, 5) | |||
| 476 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 477 | + | ||||
| 478 | + | app.preloadAccessibilityForShownPane(paneID) | |||
| 479 | + | ||||
| 480 | + | assert.Equal(t, 0, wv.enableAccessibilityCalls) | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) { | |||
| 484 | + | resolved := entity.ResolvedTheme{ | |||
| 485 | + | PrefersDark: true, | |||
| 486 | + | DarkPalette: entity.ColorPalette{ | |||
| 487 | + | Accent: "#22c55e", | |||
| 488 | + | }, | |||
| 489 | + | } | |||
| 490 | + | app := &App{deps: &Dependencies{Theme: theme.NewManager(context.Background(), resolved)}} | |||
| 491 | + | ||||
| 492 | + | assert.Equal(t, "#22c55e", app.vimNavigationHighlightColor()) | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |||
| 496 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 497 | + | wv := newSemanticNavigatingWebView(t, 4) | |||
| 498 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 499 | + | ||||
| 500 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3) | |||
| 501 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0) | |||
| 502 | + | app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1) | |||
| 503 | + | ||||
| 504 | + | assert.Equal(t, []dto.SemanticNavigationRequest{ | |||
| 505 | + | {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationForward, Count: 3}, | |||
| 506 | + | {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationBackward, Count: 0}, | |||
| 507 | + | }, wv.requests) | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) { | |||
| 511 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 512 | + | wv := newInputFocusingWebView(t, 6) | |||
| 513 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 514 | + | ||||
| 515 | + | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | |||
| 516 | + | ||||
| 517 | + | assert.Equal(t, 1, wv.focusNextInputCalls) | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) { | |||
| 521 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 522 | + | wv := newInputFocusingWebView(t, 7) | |||
| 523 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 524 | + | app.pageEditableFocusByPane = map[entity.PaneID]bool{paneID: true} | |||
| 525 | + | ||||
| 526 | + | assert.True(t, app.navigatePageFocus(context.Background(), bw, true)) | |||
| 527 | + | assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls) | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | |||
| 531 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 532 | + | wv := portmocks.NewMockWebView(t) | |||
| 533 | + | wv.EXPECT().ID().Return(port.WebViewID(5)).Once() | |||
| 534 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 535 | + | ||||
| 536 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | |||
| 537 | + | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) { | |||
| 541 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 542 | + | wv := newAccessibilityEnablingWebView(t, 3) | |||
| 543 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 544 | + | ||||
| 545 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeTab) | |||
| 546 | + | app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal) | |||
| 547 | + | app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeVim) | |||
| 548 | + | assert.Equal(t, 0, wv.enableAccessibilityCalls) | |||
| 549 | + | ||||
| 550 | + | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |||
| 551 | + | assert.Equal(t, 1, wv.enableAccessibilityCalls) | |||
| 552 | + | ||||
| 553 | + | app.handleModeChange(context.Background(), bw, input.ModePane, input.ModeVim) | |||
| 554 | + | assert.Equal(t, 2, wv.enableAccessibilityCalls) | |||
| 555 | + | } | |||
| 556 | + | ||||
| 354 | // ============================================================================ | 557 | // ============================================================================ | ||
| 355 | // 2. Leaving Vim mode clears ownership | 558 | // 2. Leaving Vim mode clears ownership | ||
| 356 | // ============================================================================ | 559 | // ============================================================================ | ||
| @@ -717,20 +920,20 @@ func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) { | |||||
| 717 | assert.False(t, f.pv1A.IsVimMode()) | 920 | assert.False(t, f.pv1A.IsVimMode()) | ||
| 718 | } | 921 | } | ||
| 719 | 922 | ||||
| 720 | - | func TestVimMode_ActivationBypassWhenActivePageIsEditable(t *testing.T) { | 923 | + | func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) { |
| 721 | f := newSingleWindowVimModeFixture(t) | 924 | f := newSingleWindowVimModeFixture(t) | ||
| 722 | f.app.pageEditableFocusByPane = map[entity.PaneID]bool{ | 925 | f.app.pageEditableFocusByPane = map[entity.PaneID]bool{ | ||
| 723 | "pane-a": true, | 926 | "pane-a": true, | ||
| 724 | } | 927 | } | ||
| 725 | 928 | ||||
| 726 | - | assert.True(t, f.app.shouldBypassVimModeActivation(f.bw1)) | 929 | + | assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1)) |
| 727 | } | 930 | } | ||
| 728 | 931 | ||||
| 729 | - | func TestVimMode_ActivationBypassClearsWhenEditableFocusLeaves(t *testing.T) { | 932 | + | func TestVimMode_ActivationAvailabilityDoesNotDependOnEditableFocus(t *testing.T) { |
| 730 | f := newSingleWindowVimModeFixture(t) | 933 | f := newSingleWindowVimModeFixture(t) | ||
| 731 | 934 | ||||
| 732 | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true) | 935 | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true) | ||
| 733 | - | assert.True(t, f.app.shouldBypassVimModeActivation(f.bw1)) | 936 | + | assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1)) |
| 734 | 937 | ||||
| 735 | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), false) | 938 | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), false) | ||
| 736 | assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1)) | 939 | assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1)) | ||
M internal/ui/input/keyboard.go +49 -4
| @@ -49,6 +49,10 @@ const ( | |||
| 49 | 49 | // matching stop event without changing the normal configurable action API. | |
| 50 | 50 | type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error | |
| 51 | 51 | ||
| 52 | + | // PageFocusNavigationHandler handles native-like focus traversal inside the | |
| 53 | + | // focused page. It returns true when the event was handled by the page. | |
| 54 | + | type PageFocusNavigationHandler func(ctx context.Context, backward bool) bool | |
| 55 | + | ||
| 52 | 56 | const ( | |
| 53 | 57 | vimScrollHoldDelay = 250 * time.Millisecond | |
| 54 | 58 | vimScrollCadence = 16 * time.Millisecond | |
| @@ -77,8 +81,9 @@ type KeyboardHandler struct { | |||
| 77 | 81 | session *entity.SessionConfig | |
| 78 | 82 | ||
| 79 | 83 | // Action handler callbacks. | |
| 80 | - | onAction ActionHandler | |
| 81 | - | onVimScrollLifecycle VimScrollLifecycleHandler | |
| 84 | + | onAction ActionHandler | |
| 85 | + | onVimScrollLifecycle VimScrollLifecycleHandler | |
| 86 | + | onPageFocusNavigation PageFocusNavigationHandler | |
| 82 | 87 | // Optional routing callback that determines how a key should be handled. | |
| 83 | 88 | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | |
| 84 | 89 | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | |
| @@ -163,6 +168,14 @@ func (h *KeyboardHandler) SetOnVimScrollLifecycle(fn VimScrollLifecycleHandler) | |||
| 163 | 168 | h.onVimScrollLifecycle = fn | |
| 164 | 169 | } | |
| 165 | 170 | ||
| 171 | + | // SetOnPageFocusNavigation sets the optional callback for keeping page focus | |
| 172 | + | // traversal inside the active WebView. | |
| 173 | + | func (h *KeyboardHandler) SetOnPageFocusNavigation(fn PageFocusNavigationHandler) { | |
| 174 | + | h.mu.Lock() | |
| 175 | + | defer h.mu.Unlock() | |
| 176 | + | h.onPageFocusNavigation = fn | |
| 177 | + | } | |
| 178 | + | ||
| 166 | 179 | // SetVimModeActivationPassthrough sets the callback that can block Vim mode | |
| 167 | 180 | // activation and let the original key event propagate instead. | |
| 168 | 181 | func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) { | |
| @@ -407,6 +420,13 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 407 | 420 | } | |
| 408 | 421 | } | |
| 409 | 422 | ||
| 423 | + | // Page focus traversal must stay out of shortcut lookup. The callback keeps | |
| 424 | + | // traversal inside CEF when the active page control needs it; otherwise the | |
| 425 | + | // event passes through to the native widget. | |
| 426 | + | if shouldPassthroughNativePageFocusNavigation(mode, keyval, modifiers) { | |
| 427 | + | return h.handlePageFocusNavigation(keyval, modifiers) | |
| 428 | + | } | |
| 429 | + | ||
| 410 | 430 | // Determine routing for this key event | |
| 411 | 431 | route := RouteHandleShortcuts // default: process through shortcut system | |
| 412 | 432 | if routeKey != nil && mode == ModeNormal { | |
| @@ -612,13 +632,38 @@ func normalizeKeyval(keyval uint) uint { | |||
| 612 | 632 | return keyval | |
| 613 | 633 | } | |
| 614 | 634 | ||
| 635 | + | func (h *KeyboardHandler) handlePageFocusNavigation(keyval uint, modifiers Modifier) bool { | |
| 636 | + | h.mu.RLock() | |
| 637 | + | handler := h.onPageFocusNavigation | |
| 638 | + | h.mu.RUnlock() | |
| 639 | + | if handler == nil { | |
| 640 | + | return false | |
| 641 | + | } | |
| 642 | + | backward := keyval == uint(gdk.KEY_ISO_Left_Tab) || modifiers == ModShift | |
| 643 | + | return handler(h.ctx, backward) | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool { | |
| 647 | + | if mode != ModeNormal && mode != ModeVim { | |
| 648 | + | return false | |
| 649 | + | } | |
| 650 | + | switch keyval { | |
| 651 | + | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | |
| 652 | + | return modifiers == ModNone || modifiers == ModShift | |
| 653 | + | default: | |
| 654 | + | return false | |
| 655 | + | } | |
| 656 | + | } | |
| 657 | + | ||
| 615 | 658 | func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool { | |
| 616 | - | if mode != ModeVim || modifiers != ModNone { | |
| 659 | + | if mode != ModeVim { | |
| 617 | 660 | return false | |
| 618 | 661 | } | |
| 619 | 662 | switch keyval { | |
| 620 | 663 | case uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down): | |
| 621 | - | return true | |
| 664 | + | return modifiers == ModNone | |
| 665 | + | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | |
| 666 | + | return modifiers == ModNone || modifiers == ModShift | |
| 622 | 667 | default: | |
| 623 | 668 | return false | |
| 624 | 669 | } | |
| @@ -49,6 +49,10 @@ const ( | |||||
| 49 | // matching stop event without changing the normal configurable action API. | 49 | // matching stop event without changing the normal configurable action API. | ||
| 50 | type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error | 50 | type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error | ||
| 51 | 51 | ||||
| 52 | + | // PageFocusNavigationHandler handles native-like focus traversal inside the | |||
| 53 | + | // focused page. It returns true when the event was handled by the page. | |||
| 54 | + | type PageFocusNavigationHandler func(ctx context.Context, backward bool) bool | |||
| 55 | + | ||||
| 52 | const ( | 56 | const ( | ||
| 53 | vimScrollHoldDelay = 250 * time.Millisecond | 57 | vimScrollHoldDelay = 250 * time.Millisecond | ||
| 54 | vimScrollCadence = 16 * time.Millisecond | 58 | vimScrollCadence = 16 * time.Millisecond | ||
| @@ -77,8 +81,9 @@ type KeyboardHandler struct { | |||||
| 77 | session *entity.SessionConfig | 81 | session *entity.SessionConfig | ||
| 78 | 82 | ||||
| 79 | // Action handler callbacks. | 83 | // Action handler callbacks. | ||
| 80 | - | onAction ActionHandler | 84 | + | onAction ActionHandler |
| 81 | - | onVimScrollLifecycle VimScrollLifecycleHandler | 85 | + | onVimScrollLifecycle VimScrollLifecycleHandler |
| 86 | + | onPageFocusNavigation PageFocusNavigationHandler | |||
| 82 | // Optional routing callback that determines how a key should be handled. | 87 | // Optional routing callback that determines how a key should be handled. | ||
| 83 | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | 88 | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | ||
| 84 | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | 89 | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | ||
| @@ -163,6 +168,14 @@ func (h *KeyboardHandler) SetOnVimScrollLifecycle(fn VimScrollLifecycleHandler) | |||||
| 163 | h.onVimScrollLifecycle = fn | 168 | h.onVimScrollLifecycle = fn | ||
| 164 | } | 169 | } | ||
| 165 | 170 | ||||
| 171 | + | // SetOnPageFocusNavigation sets the optional callback for keeping page focus | |||
| 172 | + | // traversal inside the active WebView. | |||
| 173 | + | func (h *KeyboardHandler) SetOnPageFocusNavigation(fn PageFocusNavigationHandler) { | |||
| 174 | + | h.mu.Lock() | |||
| 175 | + | defer h.mu.Unlock() | |||
| 176 | + | h.onPageFocusNavigation = fn | |||
| 177 | + | } | |||
| 178 | + | ||||
| 166 | // SetVimModeActivationPassthrough sets the callback that can block Vim mode | 179 | // SetVimModeActivationPassthrough sets the callback that can block Vim mode | ||
| 167 | // activation and let the original key event propagate instead. | 180 | // activation and let the original key event propagate instead. | ||
| 168 | func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) { | 181 | func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) { | ||
| @@ -407,6 +420,13 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 407 | } | 420 | } | ||
| 408 | } | 421 | } | ||
| 409 | 422 | ||||
| 423 | + | // Page focus traversal must stay out of shortcut lookup. The callback keeps | |||
| 424 | + | // traversal inside CEF when the active page control needs it; otherwise the | |||
| 425 | + | // event passes through to the native widget. | |||
| 426 | + | if shouldPassthroughNativePageFocusNavigation(mode, keyval, modifiers) { | |||
| 427 | + | return h.handlePageFocusNavigation(keyval, modifiers) | |||
| 428 | + | } | |||
| 429 | + | ||||
| 410 | // Determine routing for this key event | 430 | // Determine routing for this key event | ||
| 411 | route := RouteHandleShortcuts // default: process through shortcut system | 431 | route := RouteHandleShortcuts // default: process through shortcut system | ||
| 412 | if routeKey != nil && mode == ModeNormal { | 432 | if routeKey != nil && mode == ModeNormal { | ||
| @@ -612,13 +632,38 @@ func normalizeKeyval(keyval uint) uint { | |||||
| 612 | return keyval | 632 | return keyval | ||
| 613 | } | 633 | } | ||
| 614 | 634 | ||||
| 635 | + | func (h *KeyboardHandler) handlePageFocusNavigation(keyval uint, modifiers Modifier) bool { | |||
| 636 | + | h.mu.RLock() | |||
| 637 | + | handler := h.onPageFocusNavigation | |||
| 638 | + | h.mu.RUnlock() | |||
| 639 | + | if handler == nil { | |||
| 640 | + | return false | |||
| 641 | + | } | |||
| 642 | + | backward := keyval == uint(gdk.KEY_ISO_Left_Tab) || modifiers == ModShift | |||
| 643 | + | return handler(h.ctx, backward) | |||
| 644 | + | } | |||
| 645 | + | ||||
| 646 | + | func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool { | |||
| 647 | + | if mode != ModeNormal && mode != ModeVim { | |||
| 648 | + | return false | |||
| 649 | + | } | |||
| 650 | + | switch keyval { | |||
| 651 | + | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | |||
| 652 | + | return modifiers == ModNone || modifiers == ModShift | |||
| 653 | + | default: | |||
| 654 | + | return false | |||
| 655 | + | } | |||
| 656 | + | } | |||
| 657 | + | ||||
| 615 | func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool { | 658 | func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool { | ||
| 616 | - | if mode != ModeVim || modifiers != ModNone { | 659 | + | if mode != ModeVim { |
| 617 | return false | 660 | return false | ||
| 618 | } | 661 | } | ||
| 619 | switch keyval { | 662 | switch keyval { | ||
| 620 | case uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down): | 663 | case uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down): | ||
| 621 | - | return true | 664 | + | return modifiers == ModNone |
| 665 | + | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | |||
| 666 | + | return modifiers == ModNone || modifiers == ModShift | |||
| 622 | default: | 667 | default: | ||
| 623 | return false | 668 | return false | ||
| 624 | } | 669 | } | ||
M internal/ui/input/keyboard_test.go +76 -0
| @@ -884,6 +884,82 @@ func TestHandleKeyPress_VimModeArrowKeysPassThroughNatively(t *testing.T) { | |||
| 884 | 884 | } | |
| 885 | 885 | } | |
| 886 | 886 | ||
| 887 | + | func TestHandleKeyPress_VimModeTabKeysPassThroughNatively(t *testing.T) { | |
| 888 | + | ctx := context.Background() | |
| 889 | + | workspace := newTestWorkspace() | |
| 890 | + | workspace.VimMode = entity.VimModeConfig{ActivationShortcut: "ctrl+y"} | |
| 891 | + | ||
| 892 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |
| 893 | + | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |
| 894 | + | if h.Mode() != ModeVim { | |
| 895 | + | t.Fatal("failed to enter vim mode") | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | for _, test := range []struct { | |
| 899 | + | name string | |
| 900 | + | keyval uint | |
| 901 | + | modifiers gdk.ModifierType | |
| 902 | + | }{ | |
| 903 | + | {name: "tab", keyval: uint(gdk.KEY_Tab)}, | |
| 904 | + | {name: "shift tab", keyval: uint(gdk.KEY_Tab), modifiers: gdk.ShiftMaskValue}, | |
| 905 | + | {name: "iso left tab", keyval: uint(gdk.KEY_ISO_Left_Tab), modifiers: gdk.ShiftMaskValue}, | |
| 906 | + | } { | |
| 907 | + | t.Run(test.name, func(t *testing.T) { | |
| 908 | + | consumed := h.handleKeyPress(test.keyval, 0, test.modifiers) | |
| 909 | + | if consumed { | |
| 910 | + | t.Fatal("tab key should pass through to native page focus navigation in vim mode") | |
| 911 | + | } | |
| 912 | + | if h.Mode() != ModeVim { | |
| 913 | + | t.Fatal("tab key should keep vim mode active") | |
| 914 | + | } | |
| 915 | + | }) | |
| 916 | + | } | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | func TestHandleKeyPress_NormalModeTabKeysPassThroughNatively(t *testing.T) { | |
| 920 | + | ctx := context.Background() | |
| 921 | + | workspace := newTestWorkspace() | |
| 922 | + | ||
| 923 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |
| 924 | + | for _, test := range []struct { | |
| 925 | + | name string | |
| 926 | + | keyval uint | |
| 927 | + | modifiers gdk.ModifierType | |
| 928 | + | }{ | |
| 929 | + | {name: "tab", keyval: uint(gdk.KEY_Tab)}, | |
| 930 | + | {name: "shift tab", keyval: uint(gdk.KEY_Tab), modifiers: gdk.ShiftMaskValue}, | |
| 931 | + | {name: "iso left tab", keyval: uint(gdk.KEY_ISO_Left_Tab), modifiers: gdk.ShiftMaskValue}, | |
| 932 | + | } { | |
| 933 | + | t.Run(test.name, func(t *testing.T) { | |
| 934 | + | consumed := h.handleKeyPress(test.keyval, 0, test.modifiers) | |
| 935 | + | if consumed { | |
| 936 | + | t.Fatal("tab key should pass through to native page focus navigation in normal mode") | |
| 937 | + | } | |
| 938 | + | if h.Mode() != ModeNormal { | |
| 939 | + | t.Fatal("tab key should keep normal mode active") | |
| 940 | + | } | |
| 941 | + | }) | |
| 942 | + | } | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | func TestHandleKeyPress_PageFocusNavigationHandlerConsumesReverseTab(t *testing.T) { | |
| 946 | + | ctx := context.Background() | |
| 947 | + | workspace := newTestWorkspace() | |
| 948 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |
| 949 | + | var gotBackward bool | |
| 950 | + | h.SetOnPageFocusNavigation(func(_ context.Context, backward bool) bool { | |
| 951 | + | gotBackward = backward | |
| 952 | + | return true | |
| 953 | + | }) | |
| 954 | + | ||
| 955 | + | if !h.handleKeyPress(uint(gdk.KEY_ISO_Left_Tab), 0, gdk.ShiftMaskValue) { | |
| 956 | + | t.Fatal("handled page focus navigation should consume reverse tab") | |
| 957 | + | } | |
| 958 | + | if !gotBackward { | |
| 959 | + | t.Fatal("reverse tab should call page focus navigation backward") | |
| 960 | + | } | |
| 961 | + | } | |
| 962 | + | ||
| 887 | 963 | func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) { | |
| 888 | 964 | ctx := context.Background() | |
| 889 | 965 | workspace := newTestWorkspace() | |
| @@ -884,6 +884,82 @@ func TestHandleKeyPress_VimModeArrowKeysPassThroughNatively(t *testing.T) { | |||||
| 884 | } | 884 | } | ||
| 885 | } | 885 | } | ||
| 886 | 886 | ||||
| 887 | + | func TestHandleKeyPress_VimModeTabKeysPassThroughNatively(t *testing.T) { | |||
| 888 | + | ctx := context.Background() | |||
| 889 | + | workspace := newTestWorkspace() | |||
| 890 | + | workspace.VimMode = entity.VimModeConfig{ActivationShortcut: "ctrl+y"} | |||
| 891 | + | ||||
| 892 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 893 | + | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 894 | + | if h.Mode() != ModeVim { | |||
| 895 | + | t.Fatal("failed to enter vim mode") | |||
| 896 | + | } | |||
| 897 | + | ||||
| 898 | + | for _, test := range []struct { | |||
| 899 | + | name string | |||
| 900 | + | keyval uint | |||
| 901 | + | modifiers gdk.ModifierType | |||
| 902 | + | }{ | |||
| 903 | + | {name: "tab", keyval: uint(gdk.KEY_Tab)}, | |||
| 904 | + | {name: "shift tab", keyval: uint(gdk.KEY_Tab), modifiers: gdk.ShiftMaskValue}, | |||
| 905 | + | {name: "iso left tab", keyval: uint(gdk.KEY_ISO_Left_Tab), modifiers: gdk.ShiftMaskValue}, | |||
| 906 | + | } { | |||
| 907 | + | t.Run(test.name, func(t *testing.T) { | |||
| 908 | + | consumed := h.handleKeyPress(test.keyval, 0, test.modifiers) | |||
| 909 | + | if consumed { | |||
| 910 | + | t.Fatal("tab key should pass through to native page focus navigation in vim mode") | |||
| 911 | + | } | |||
| 912 | + | if h.Mode() != ModeVim { | |||
| 913 | + | t.Fatal("tab key should keep vim mode active") | |||
| 914 | + | } | |||
| 915 | + | }) | |||
| 916 | + | } | |||
| 917 | + | } | |||
| 918 | + | ||||
| 919 | + | func TestHandleKeyPress_NormalModeTabKeysPassThroughNatively(t *testing.T) { | |||
| 920 | + | ctx := context.Background() | |||
| 921 | + | workspace := newTestWorkspace() | |||
| 922 | + | ||||
| 923 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 924 | + | for _, test := range []struct { | |||
| 925 | + | name string | |||
| 926 | + | keyval uint | |||
| 927 | + | modifiers gdk.ModifierType | |||
| 928 | + | }{ | |||
| 929 | + | {name: "tab", keyval: uint(gdk.KEY_Tab)}, | |||
| 930 | + | {name: "shift tab", keyval: uint(gdk.KEY_Tab), modifiers: gdk.ShiftMaskValue}, | |||
| 931 | + | {name: "iso left tab", keyval: uint(gdk.KEY_ISO_Left_Tab), modifiers: gdk.ShiftMaskValue}, | |||
| 932 | + | } { | |||
| 933 | + | t.Run(test.name, func(t *testing.T) { | |||
| 934 | + | consumed := h.handleKeyPress(test.keyval, 0, test.modifiers) | |||
| 935 | + | if consumed { | |||
| 936 | + | t.Fatal("tab key should pass through to native page focus navigation in normal mode") | |||
| 937 | + | } | |||
| 938 | + | if h.Mode() != ModeNormal { | |||
| 939 | + | t.Fatal("tab key should keep normal mode active") | |||
| 940 | + | } | |||
| 941 | + | }) | |||
| 942 | + | } | |||
| 943 | + | } | |||
| 944 | + | ||||
| 945 | + | func TestHandleKeyPress_PageFocusNavigationHandlerConsumesReverseTab(t *testing.T) { | |||
| 946 | + | ctx := context.Background() | |||
| 947 | + | workspace := newTestWorkspace() | |||
| 948 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 949 | + | var gotBackward bool | |||
| 950 | + | h.SetOnPageFocusNavigation(func(_ context.Context, backward bool) bool { | |||
| 951 | + | gotBackward = backward | |||
| 952 | + | return true | |||
| 953 | + | }) | |||
| 954 | + | ||||
| 955 | + | if !h.handleKeyPress(uint(gdk.KEY_ISO_Left_Tab), 0, gdk.ShiftMaskValue) { | |||
| 956 | + | t.Fatal("handled page focus navigation should consume reverse tab") | |||
| 957 | + | } | |||
| 958 | + | if !gotBackward { | |||
| 959 | + | t.Fatal("reverse tab should call page focus navigation backward") | |||
| 960 | + | } | |||
| 961 | + | } | |||
| 962 | + | ||||
| 887 | func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) { | 963 | func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) { | ||
| 888 | ctx := context.Background() | 964 | ctx := context.Background() | ||
| 889 | workspace := newTestWorkspace() | 965 | workspace := newTestWorkspace() | ||
M internal/ui/input/vimkeys_dispatch_test.go +12 -0
| @@ -87,6 +87,7 @@ func TestNewVimModeSequenceState_NilTrie(t *testing.T) { | |||
| 87 | 87 | func TestTrieOwnsBinding(t *testing.T) { | |
| 88 | 88 | cases := map[string]bool{ | |
| 89 | 89 | "]]": true, | |
| 90 | + | "gi": true, | |
| 90 | 91 | "yah": true, | |
| 91 | 92 | "gO": true, | |
| 92 | 93 | "<C-d>": true, | |
| @@ -156,6 +157,7 @@ func TestBuildVimModeTrie(t *testing.T) { | |||
| 156 | 157 | Actions: map[string]entity.ActionBinding{ | |
| 157 | 158 | "vim-scroll-down": {Keys: []string{"j"}}, | |
| 158 | 159 | "heading-next": {Keys: []string{"]]"}}, | |
| 160 | + | "focus-input": {Keys: []string{"gi"}}, | |
| 159 | 161 | "yank-section": {Keys: []string{"yah"}}, | |
| 160 | 162 | "outline": {Keys: []string{"gO"}}, | |
| 161 | 163 | "half-page-down": {Keys: []string{"<C-d>"}}, | |
| @@ -171,6 +173,7 @@ func TestBuildVimModeTrie(t *testing.T) { | |||
| 171 | 173 | ||
| 172 | 174 | wantOwned := map[string]bool{ | |
| 173 | 175 | "]]": true, | |
| 176 | + | "gi": true, | |
| 174 | 177 | "yah": true, | |
| 175 | 178 | "gO": true, | |
| 176 | 179 | "<C-d>": true, | |
| @@ -194,6 +197,15 @@ func TestBuildVimModeTrie(t *testing.T) { | |||
| 194 | 197 | if !ok || !node.Exact || node.Action != "heading-next" { | |
| 195 | 198 | t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok) | |
| 196 | 199 | } | |
| 200 | + | ||
| 201 | + | seq, err = vimkeys.ParseBinding("gi") | |
| 202 | + | if err != nil { | |
| 203 | + | t.Fatalf("ParseBinding(gi) error = %v", err) | |
| 204 | + | } | |
| 205 | + | node, ok = trie.Walk(seq) | |
| 206 | + | if !ok || !node.Exact || node.Action != "focus-input" { | |
| 207 | + | t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok) | |
| 208 | + | } | |
| 197 | 209 | } | |
| 198 | 210 | ||
| 199 | 211 | func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) { | |
| @@ -87,6 +87,7 @@ func TestNewVimModeSequenceState_NilTrie(t *testing.T) { | |||||
| 87 | func TestTrieOwnsBinding(t *testing.T) { | 87 | func TestTrieOwnsBinding(t *testing.T) { | ||
| 88 | cases := map[string]bool{ | 88 | cases := map[string]bool{ | ||
| 89 | "]]": true, | 89 | "]]": true, | ||
| 90 | + | "gi": true, | |||
| 90 | "yah": true, | 91 | "yah": true, | ||
| 91 | "gO": true, | 92 | "gO": true, | ||
| 92 | "<C-d>": true, | 93 | "<C-d>": true, | ||
| @@ -156,6 +157,7 @@ func TestBuildVimModeTrie(t *testing.T) { | |||||
| 156 | Actions: map[string]entity.ActionBinding{ | 157 | Actions: map[string]entity.ActionBinding{ | ||
| 157 | "vim-scroll-down": {Keys: []string{"j"}}, | 158 | "vim-scroll-down": {Keys: []string{"j"}}, | ||
| 158 | "heading-next": {Keys: []string{"]]"}}, | 159 | "heading-next": {Keys: []string{"]]"}}, | ||
| 160 | + | "focus-input": {Keys: []string{"gi"}}, | |||
| 159 | "yank-section": {Keys: []string{"yah"}}, | 161 | "yank-section": {Keys: []string{"yah"}}, | ||
| 160 | "outline": {Keys: []string{"gO"}}, | 162 | "outline": {Keys: []string{"gO"}}, | ||
| 161 | "half-page-down": {Keys: []string{"<C-d>"}}, | 163 | "half-page-down": {Keys: []string{"<C-d>"}}, | ||
| @@ -171,6 +173,7 @@ func TestBuildVimModeTrie(t *testing.T) { | |||||
| 171 | 173 | ||||
| 172 | wantOwned := map[string]bool{ | 174 | wantOwned := map[string]bool{ | ||
| 173 | "]]": true, | 175 | "]]": true, | ||
| 176 | + | "gi": true, | |||
| 174 | "yah": true, | 177 | "yah": true, | ||
| 175 | "gO": true, | 178 | "gO": true, | ||
| 176 | "<C-d>": true, | 179 | "<C-d>": true, | ||
| @@ -194,6 +197,15 @@ func TestBuildVimModeTrie(t *testing.T) { | |||||
| 194 | if !ok || !node.Exact || node.Action != "heading-next" { | 197 | if !ok || !node.Exact || node.Action != "heading-next" { | ||
| 195 | t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok) | 198 | t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok) | ||
| 196 | } | 199 | } | ||
| 200 | + | ||||
| 201 | + | seq, err = vimkeys.ParseBinding("gi") | |||
| 202 | + | if err != nil { | |||
| 203 | + | t.Fatalf("ParseBinding(gi) error = %v", err) | |||
| 204 | + | } | |||
| 205 | + | node, ok = trie.Walk(seq) | |||
| 206 | + | if !ok || !node.Exact || node.Action != "focus-input" { | |||
| 207 | + | t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok) | |||
| 208 | + | } | |||
| 197 | } | 209 | } | ||
| 198 | 210 | ||||
| 199 | func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) { | 211 | func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) { | ||