detached c443420

Merge pull request #382 from bnema/feat/vim-live-a11y-navigation

feat(vim): add live page navigation and input focus
commit
c443420abdf65ed66f8090d97755712c9d99e835
author
bnema · · 2026-08-15 13:20 UTC
committer
GitHub · · 2026-08-15 13:20 UTC
merge of
21d72f7 + 114ac55 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
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
200200
201201 Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend.
202202
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.
204204
205205 ### Build from source
206206
@@ -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 source205 ### Build from source
206 206
M cmd/dumber/main.go +5 -0
@@ -164,6 +164,11 @@ type startupTiming struct {
164164 }
165165
166166 func main() {
167+ if err := bootstrap.ApplyDevelopmentEnvironment(); err != nil {
168+ _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err)
169+ os.Exit(1)
170+ }
171+
167172 // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can
168173 // invalidate frames retained across those boundaries and make the GC abort
169174 // 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 can172 // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can
168 // invalidate frames retained across those boundaries and make the GC abort173 // invalidate frames retained across those boundaries and make the GC abort
169 // with "traceback did not unwind completely". Re-exec once so the runtime174 // 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"]
404404 |-----|------|---------|-------------|
405405 | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key |
406406 | `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 |
407409 | `workspace.vim_mode.actions` | map | See below | Action→binding mappings |
408410
409411 **Default vim mode actions:**
@@ -433,6 +435,18 @@ desc = "Scroll page down fast"
433435 keys = ["shift+k"]
434436 desc = "Scroll page up fast"
435437
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+
436450 [workspace.vim_mode.actions.confirm]
437451 keys = ["enter"]
438452 desc = "Confirm action"
@@ -443,9 +457,13 @@ desc = "Cancel/exit mode"
443457 ```
444458
445459 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.
448464 - 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.
449467 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.
450468 - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.
451469 - 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:
1616 |------|------------|---------|
1717 | Pane Mode | `Ctrl+P` | Split, close, focus panes |
1818 | 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 |
2020 | Resize Mode | `Ctrl+N` | Resize pane splits |
2121 | Session Mode | `Ctrl+O` | Session management |
2222
@@ -36,9 +36,11 @@ Vim Mode stays local to the active pane and automatically leaves the mode when f
3636 2. Use `h`, `j`, `k`, `l` to scroll left, down, up, or right
3737 3. Use `Shift+J` / `Shift+K` for faster vertical jumps
3838 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
4244
4345 > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the
4446 > 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 right36 2. Use `h`, `j`, `k`, `l` to scroll left, down, up, or right
37 3. Use `Shift+J` / `Shift+K` for faster vertical jumps37 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 active38 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 Mode39+5. Use `gi` to cycle through visible page inputs; use `]]` and `[[` to move through headings
40-6. Press `Escape` or `Enter` to leave the mode40+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 keypress41+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 the45 > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the
44 > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller46 > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller
M docs/reference/configuration.md +2 -0
@@ -102,6 +102,8 @@
102102 | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings |
103103 | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | |
104104 | `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 |
105107 | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) |
106108 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |
107109 | `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
88 |------|-----|---------|
99 | Pane Mode | `Ctrl+P` | Split, close, focus panes |
1010 | 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 |
1212 | Resize Mode | `Ctrl+N` | Resize pane splits |
1313 | Session Mode | `Ctrl+O` | Session management |
1414
1515 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.
1717
1818 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.
1919
@@ -55,10 +55,12 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke
5555
5656 ## Vim Mode (`Ctrl+Y`)
5757
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.
5959
6060 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.
6161
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+
6264 | Action | Keys |
6365 |--------|------|
6466 | Scroll left | `H` |
@@ -67,6 +69,10 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
6769 | Scroll right | `L` |
6870 | Scroll down fast | `Shift+J` |
6971 | Scroll up fast | `Shift+K` |
72+| Focus next page input | `gi` |
73+| Next heading | `]]` |
74+| Previous heading | `[[` |
75+| Page focus traversal | `Tab`, `Shift+Tab` |
7076 | Confirm | `Enter` |
7177 | Cancel | `Escape` |
7278
@@ -159,6 +165,15 @@ keys = ["shift+j"]
159165 [workspace.vim_mode.actions.vim-scroll-up-fast]
160166 keys = ["shift+k"]
161167
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+
162177 [workspace.shortcuts.actions.close-pane]
163178 keys = ["ctrl+w"]
164179
@@ -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 {
444444 type AccessibilityEnabler interface {
445445 EnableAccessibility()
446446 }
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 {
4646 func (*VimModePolicyUseCase) Evaluate(input VimModePolicyInput) VimModePolicyTransition {
4747 switch input.Trigger {
4848 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.
5252 return VimModePolicyTransitionStay
5353 case VimModePolicyTriggerOmniboxFocus,
5454 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 VimModePolicyTransitionBlockActivation50+ // available while a page input is focused so users can leave typing mode
51- }51+ // without first moving focus elsewhere.
52 return VimModePolicyTransitionStay52 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) {
1313 want VimModePolicyTransition
1414 }{
1515 {
16- name: "activation blocked while page editable focused",
16+ name: "activation stays allowed while page editable focused",
1717 input: VimModePolicyInput{
1818 Trigger: VimModePolicyTriggerActivationAttempt,
1919 PageEditableFocused: true,
2020 },
21- want: VimModePolicyTransitionBlockActivation,
21+ want: VimModePolicyTransitionStay,
2222 },
2323 {
2424 name: "activation stays allowed when page is not editable",
@@ -13,12 +13,12 @@ func TestVimModePolicyEvaluate(t *testing.T) {
13 want VimModePolicyTransition13 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 {
320320 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
321321 "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322322 "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},
323+ "focus-input": {Keys: []string{"gi"}, Desc: "Focus next page input"},
323324 "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"},
324325 "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"},
325326 "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) {
816816 assert.Equal(t, "Custom move pane", move["desc"])
817817 }
818818
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+
819854 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
820855 m := NewMigrator()
821856
@@ -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) {
1818
1919 var missing []string
2020 for _, key := range NewSchemaProvider().GetSchema() {
21- if _, pending := schemaKeysPendingProductDocs[key.Key]; pending {
22- continue
23- }
2421 if configurationReferenceCoversKey(docKeys, key.Key) {
2522 continue
2623 }
@@ -31,13 +28,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
3128 require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")
3229 }
3330
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-
4131 func configurationReferenceKeys(t *testing.T) map[string]struct{} {
4232 t.Helper()
4333
@@ -18,9 +18,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
18 18
19 var missing []string19 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 continue22 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{
2222 var sequenceVimModeActions = map[string]string{
2323 "heading-next": "]]",
2424 "heading-prev": "[[",
25+ "focus-input": "gi",
2526 "code-next": "]c",
2627 "code-prev": "[c",
2728 "table-next": "]t",
@@ -65,7 +66,7 @@ func TestVimModeSequenceDefaults(t *testing.T) {
6566 vimMode := cfg.Workspace.VimMode
6667
6768 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
68- require.Len(t, sequenceVimModeActions, 11)
69+ require.Len(t, sequenceVimModeActions, 12)
6970
7071 for action, key := range sequenceVimModeActions {
7172 requireActionBinding(t, vimMode.Actions, action, []string{key})
@@ -118,6 +119,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
118119 assert.Equal(t, "cancel", bindings["escape"])
119120 assert.Equal(t, "heading-next", bindings["]]"])
120121 assert.Equal(t, "heading-prev", bindings["[["])
122+ assert.Equal(t, "focus-input", bindings["gi"])
121123 assert.Equal(t, "code-next", bindings["]c"])
122124 assert.Equal(t, "code-prev", bindings["[c"])
123125 assert.Equal(t, "table-next", bindings["]t"])
@@ -127,7 +129,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
127129 assert.Equal(t, "yank-section", bindings["yah"])
128130 assert.Equal(t, "half-page-down", bindings["<C-d>"])
129131 assert.Equal(t, "half-page-up", bindings["<C-u>"])
130- assert.Len(t, bindings, 19)
132+ assert.Len(t, bindings, 20)
131133 }
132134
133135 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.VimMode66 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 {
194194 const accentKeys = new Set([%s]);
195195 const editableFocusToken = %q;
196196 let pressedKey = null;
197+ let lastEditableFocusState = null;
197198
198199 function postEditableFocus(editable) {
200+ if (lastEditableFocusState === editable) return;
201+ lastEditableFocusState = editable;
199202 if (!editableFocusToken) return;
200203 if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return;
201204 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) {
3838 assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'")
3939 assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`)
4040 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;")
4143 assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`)
4244 assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`)
4345 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 {
124124 focusMgr *focus.Manager
125125
126126 vimModePolicyUC *usecase.VimModePolicyUseCase
127+ vimNavigationUC *usecase.VimNavigationUseCase
127128 pageEditableFocusByPane map[entity.PaneID]bool
128129
129130 resizeModeBorderTarget layout.Widget
@@ -217,6 +218,7 @@ func New(deps *Dependencies) (*App, error) {
217218 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
218219 browserWindows: make(map[string]*browserWindow),
219220 vimModePolicyUC: usecase.NewVimModePolicyUseCase(),
221+ vimNavigationUC: usecase.NewVimNavigationUseCase(),
220222 pageEditableFocusByPane: make(map[entity.PaneID]bool),
221223 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
222224 if fn != nil {
@@ -1101,6 +1103,12 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
11011103 a.handleModeChange(ctx, bw, from, to)
11021104 })
11031105 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+ })
11041112 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
11051113 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
11061114 return input.RoutePassToWidget
@@ -2160,6 +2168,81 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
21602168 return paneID, a.contentCoord.GetWebView(paneID)
21612169 }
21622170
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+
21632246 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
21642247 // navigation through navigate for the supplied browser window. The closure captures
21652248 // bw at creation time, so navigation targets the owning window even when another
@@ -3300,6 +3383,7 @@ func (a *App) initCoordinators(ctx context.Context) {
33003383 a.deps.OnFirstWebViewShown(ctx)
33013384 })
33023385 }
3386+ a.preloadAccessibilityForShownPane(paneID)
33033387
33043388 // This only updates focus when the shown pane belongs to the last-focused
33053389 // window's active workspace. activeWorkspace() delegates to
@@ -3861,6 +3945,10 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
38613945 a.applyResizeModeBorder(ctx, a.activeWorkspace())
38623946 }
38633947
3948+ if to == input.ModeVim && from != input.ModeVim {
3949+ a.enableAccessibilityForVimMode(ctx, bw)
3950+ }
3951+
38643952 // Handle pane-local Vim Mode visual ownership.
38653953 // Entering Vim Mode accents the active pane; leaving removes the accent.
38663954 a.handleVimModeOwnership(ctx, bw, to, from)
@@ -124,6 +124,7 @@ type App struct {
124 focusMgr *focus.Manager124 focusMgr *focus.Manager
125 125
126 vimModePolicyUC *usecase.VimModePolicyUseCase126 vimModePolicyUC *usecase.VimModePolicyUseCase
127+ vimNavigationUC *usecase.VimNavigationUseCase
127 pageEditableFocusByPane map[entity.PaneID]bool128 pageEditableFocusByPane map[entity.PaneID]bool
128 129
129 resizeModeBorderTarget layout.Widget130 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.RoutePassToWidget1114 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 routes2246 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
2164 // navigation through navigate for the supplied browser window. The closure captures2247 // navigation through navigate for the supplied browser window. The closure captures
2165 // bw at creation time, so navigation targets the owning window even when another2248 // 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-focused3388 // This only updates focus when the shown pane belongs to the last-focused
3305 // window's active workspace. activeWorkspace() delegates to3389 // 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 (
1010 "github.com/stretchr/testify/mock"
1111 "github.com/stretchr/testify/require"
1212
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"
1316 "github.com/bnema/dumber/internal/application/usecase"
1417 "github.com/bnema/dumber/internal/domain/entity"
1518 "github.com/bnema/dumber/internal/ui/component"
19+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
1620 "github.com/bnema/dumber/internal/ui/focus"
1721 "github.com/bnema/dumber/internal/ui/input"
1822 "github.com/bnema/dumber/internal/ui/layout"
@@ -154,6 +158,81 @@ func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) {
154158 overlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
155159 }
156160
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+
157236 // setupWorkspaceViewMocks creates a mock-backed WorkspaceView.
158237 func setupWorkspaceViewMocks(
159238 t *testing.T,
@@ -351,6 +430,130 @@ func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {
351430 assert.False(t, f.pv1A.IsVimMode())
352431 }
353432
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+
354557 // ============================================================================
355558 // 2. Leaving Vim mode clears ownership
356559 // ============================================================================
@@ -717,20 +920,20 @@ func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) {
717920 assert.False(t, f.pv1A.IsVimMode())
718921 }
719922
720-func TestVimMode_ActivationBypassWhenActivePageIsEditable(t *testing.T) {
923+func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) {
721924 f := newSingleWindowVimModeFixture(t)
722925 f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
723926 "pane-a": true,
724927 }
725928
726- assert.True(t, f.app.shouldBypassVimModeActivation(f.bw1))
929+ assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
727930 }
728931
729-func TestVimMode_ActivationBypassClearsWhenEditableFocusLeaves(t *testing.T) {
932+func TestVimMode_ActivationAvailabilityDoesNotDependOnEditableFocus(t *testing.T) {
730933 f := newSingleWindowVimModeFixture(t)
731934
732935 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))
734937
735938 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), false)
736939 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 ownership558 // 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 (
4949 // matching stop event without changing the normal configurable action API.
5050 type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error
5151
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+
5256 const (
5357 vimScrollHoldDelay = 250 * time.Millisecond
5458 vimScrollCadence = 16 * time.Millisecond
@@ -77,8 +81,9 @@ type KeyboardHandler struct {
7781 session *entity.SessionConfig
7882
7983 // Action handler callbacks.
80- onAction ActionHandler
81- onVimScrollLifecycle VimScrollLifecycleHandler
84+ onAction ActionHandler
85+ onVimScrollLifecycle VimScrollLifecycleHandler
86+ onPageFocusNavigation PageFocusNavigationHandler
8287 // Optional routing callback that determines how a key should be handled.
8388 // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused
8489 // widget handle it), or RouteAccentDetection (long-press accent for GTK entries).
@@ -163,6 +168,14 @@ func (h *KeyboardHandler) SetOnVimScrollLifecycle(fn VimScrollLifecycleHandler)
163168 h.onVimScrollLifecycle = fn
164169 }
165170
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+
166179 // SetVimModeActivationPassthrough sets the callback that can block Vim mode
167180 // activation and let the original key event propagate instead.
168181 func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) {
@@ -407,6 +420,13 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
407420 }
408421 }
409422
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+
410430 // Determine routing for this key event
411431 route := RouteHandleShortcuts // default: process through shortcut system
412432 if routeKey != nil && mode == ModeNormal {
@@ -612,13 +632,38 @@ func normalizeKeyval(keyval uint) uint {
612632 return keyval
613633 }
614634
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+
615658 func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool {
616- if mode != ModeVim || modifiers != ModNone {
659+ if mode != ModeVim {
617660 return false
618661 }
619662 switch keyval {
620663 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
622667 default:
623668 return false
624669 }
@@ -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) error50 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.Millisecond57 vimScrollHoldDelay = 250 * time.Millisecond
54 vimScrollCadence = 16 * time.Millisecond58 vimScrollCadence = 16 * time.Millisecond
@@ -77,8 +81,9 @@ type KeyboardHandler struct {
77 session *entity.SessionConfig81 session *entity.SessionConfig
78 82
79 // Action handler callbacks.83 // Action handler callbacks.
80- onAction ActionHandler84+ onAction ActionHandler
81- onVimScrollLifecycle VimScrollLifecycleHandler85+ 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 focused88 // 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 = fn168 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 mode179 // 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 event430 // Determine routing for this key event
411 route := RouteHandleShortcuts // default: process through shortcut system431 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 keyval632 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 false660 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 true664+ 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 false668 return false
624 }669 }
M internal/ui/input/keyboard_test.go +76 -0
@@ -884,6 +884,82 @@ func TestHandleKeyPress_VimModeArrowKeysPassThroughNatively(t *testing.T) {
884884 }
885885 }
886886
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+
887963 func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) {
888964 ctx := context.Background()
889965 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) {
8787 func TestTrieOwnsBinding(t *testing.T) {
8888 cases := map[string]bool{
8989 "]]": true,
90+ "gi": true,
9091 "yah": true,
9192 "gO": true,
9293 "<C-d>": true,
@@ -156,6 +157,7 @@ func TestBuildVimModeTrie(t *testing.T) {
156157 Actions: map[string]entity.ActionBinding{
157158 "vim-scroll-down": {Keys: []string{"j"}},
158159 "heading-next": {Keys: []string{"]]"}},
160+ "focus-input": {Keys: []string{"gi"}},
159161 "yank-section": {Keys: []string{"yah"}},
160162 "outline": {Keys: []string{"gO"}},
161163 "half-page-down": {Keys: []string{"<C-d>"}},
@@ -171,6 +173,7 @@ func TestBuildVimModeTrie(t *testing.T) {
171173
172174 wantOwned := map[string]bool{
173175 "]]": true,
176+ "gi": true,
174177 "yah": true,
175178 "gO": true,
176179 "<C-d>": true,
@@ -194,6 +197,15 @@ func TestBuildVimModeTrie(t *testing.T) {
194197 if !ok || !node.Exact || node.Action != "heading-next" {
195198 t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
196199 }
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+ }
197209 }
198210
199211 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close