detached bf0756b

Merge pull request #383 from bnema/chore/changelog-v0.31.0

release: merge next into main
commit
bf0756bf1015e3a76a04544424821f938fc32f5e
author
bnema · · 2026-08-18 05:19 UTC
committer
GitHub · · 2026-08-18 05:19 UTC
merge of
11ab80c + 1ca6e0a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.31.0
147 files · +17883 -912
M .github/workflows/ci.yml +18 -4
@@ -54,8 +54,15 @@ jobs:
5454 steps:
5555 - name: Install dependencies
5656 run: |
57- pacman -Syu --noconfirm
58- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell gobject-introspection base-devel brotli
57+ for attempt in 1 2 3; do
58+ if pacman -Syu --noconfirm && pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell gobject-introspection base-devel brotli; then
59+ exit 0
60+ fi
61+ if [ "$attempt" -eq 3 ]; then
62+ exit 1
63+ fi
64+ sleep 5
65+ done
5966 - uses: actions/checkout@v7.0.1
6067 - name: Allow git commands in container checkout
6168 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -98,8 +105,15 @@ jobs:
98105 steps:
99106 - name: Install dependencies
100107 run: |
101- pacman -Syu --noconfirm
102- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell libadwaita gobject-introspection base-devel brotli dbus
108+ for attempt in 1 2 3; do
109+ if pacman -Syu --noconfirm && pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell libadwaita gobject-introspection base-devel brotli dbus; then
110+ exit 0
111+ fi
112+ if [ "$attempt" -eq 3 ]; then
113+ exit 1
114+ fi
115+ sleep 5
116+ done
103117 - uses: actions/checkout@v7.0.1
104118 - name: Set up pinned Go toolchain
105119 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
M .mockery.yaml +1 -0
@@ -59,6 +59,7 @@ packages:
5959 SessionSpawner: {}
6060 WindowStateProvider: {}
6161 WebView: {}
62+ PageScrollable: {}
6263 WebViewFactory: {}
6364 WebViewPool: {}
6465 MemoryPressureApplier: {}
M AGENTS.md +1 -1
@@ -40,4 +40,4 @@ Run before committing:
4040 ## Misc
4141
4242 - vendor dir is gitignored
43-
43+- Ignore Chromium's `--ozone-platform=wayland is not compatible with Vulkan` log message. It is a deprecated warning: Dumber's CEF Wayland + ANGLE/Vulkan stack is compatible and functional. Do not cite this message as evidence of a rendering incompatibility or use it as a root-cause hypothesis.
M CHANGELOG.md +26 -0
@@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Added
8+
9+- **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections.
10+- **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split.
11+- **WebP favicon decoding**: Added safe WebP decoding for sites that publish WebP favicons.
12+
13+### Changed
14+
15+- **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration.
16+- **Page mode configuration**: Page Mode is now called Vim Mode and uses the `workspace.vim_mode` configuration namespace for activation, timeouts, and action sequences. Existing page-mode bindings are migrated by the configuration loader.
17+- **Browsing-context routing**: External links, floating panes, popups, and standalone omnibox launches now use explicit browsing-context ownership so they open in the appropriate pane or browser window.
18+- **CEF presentation and lifecycle**: Browser visibility, first-frame presentation, accessibility enablement, browser recreation, and PipeWire audio handoff now follow stricter lifecycle ownership and synchronization.
19+- **Dependencies and packaging**: Updated the CEF/GTK bridge and PureGo dependencies, refreshed vendored build inputs, and synchronized CI, Flatpak, AUR, and release workflow metadata.
20+
21+### Fixed
22+
23+- **Popup and floating-pane stability**: Hardened deferred popup cleanup, failed popup creation, opener-capable fallback behavior, and pane/window rollback paths.
24+- **WebView resource cleanup**: Fixed stale callback, timer, tick, split, hover-controller, and rebuilt-WebView ownership paths that could leak resources or act on detached views.
25+- **CEF rendering and input**: Improved OSR input and clipboard handling, effective visibility transitions, canvas presentation, repeated GPU relaunch recovery, and runtime propagation to helper processes.
26+- **Omnibox and link handling**: Fixed standalone links opening in the wrong context, stale entry-buffer reconciliation, external-pane chrome, and global shortcut ownership.
27+- **Vim input handling**: Fixed ambiguous and modified-key sequence parsing, legacy binding collisions, detach cancellation, editable-focus transitions, and repeat/scroll scheduling.
28+
29+### Security
30+
31+- **WebView bridge hardening**: Tightened reviewed WebKit bridge paths and runtime/development boundary handling.
32+
733 ## [0.30.0] - 2026-06-27
834
935 ### Added
M README.md +27 -29
@@ -1,21 +1,18 @@
1-# Dumber
1+<h1 align="center">Dumber</h1>
22
3-[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![Go Report Card](https://goreportcard.com/badge/github.com/bnema/dumber)](https://goreportcard.com/report/github.com/bnema/dumber)
3+<p align="center">
4+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License: MIT"></a>
5+ <a href="https://github.com/bnema/dumber/releases"><img src="https://img.shields.io/badge/platform-Linux%20Wayland%20only-blue?style=flat-square" alt="Platform: Linux Wayland"></a>
6+ <a href="https://github.com/bnema/dumber/commits/main"><img src="https://badgen.net/github/last-commit/bnema/dumber/main?icon=github" alt="Last commit"></a>
7+ <a href="https://github.com/bnema/dumber/stargazers"><img src="https://badgen.net/github/stars/bnema/dumber?icon=github" alt="GitHub stars"></a>
8+</p>
49
5-Dumber is a keyboard-driven web browser, built around panes, workspaces, and modal controls.
10+<p align="center">Minimal Keyboard-driven web browser for tiling WMs, inspired by Zellij, built in Go.</p>
611
7-Tabs contain workspaces. Workspaces contain panes. Panes can be split, stacked, moved, resized, and closed from the keyboard.
8-
9-The layout model is inspired by terminal multiplexers such as Zellij and tmux, but applied to web browsing this is particularly suited for Wayland compositors such as Niri or Hyprland.
10-
11-[Website](https://dumber.bnema.dev) · [Documentation](https://dumber.bnema.dev/docs) · [Keybindings](https://dumber.bnema.dev/docs/reference/keybindings)
12-
13-## Demo
12+<p align="center"><a href="https://bnema.dev/dumber">Website</a> · <a href="https://bnema.dev/dumber/docs">Documentation</a> · <a href="https://bnema.dev/dumber/docs/reference/keybindings">Keybindings</a></p>
1413
1514 https://github.com/user-attachments/assets/232822af-08e4-4a74-9416-87f79c96b118
1615
17-The demo shows split panes, stacked panes, modal navigation, and workspace switching.
18-
1916 ## Overview
2017
2118 Dumber uses three layout levels:
@@ -24,7 +21,7 @@ Dumber uses three layout levels:
2421 - **Workspaces** hold a layout of panes.
2522 - **Panes** display web pages and can be split, stacked, moved, resized, or closed.
2623
27-Most browser-management actions are exposed through modal keybindings. Enter a mode, run one or more commands, then leave the mode. The default modes cover pane management, tabs, resizing, and session commands.
24+Most browser-management actions are exposed through modal keybindings. Enter a mode, run one or more commands, then leave the mode. The default modes cover pane management, tabs, Vim-style scrolling, resizing, and session commands.
2825
2926 The browser chrome stays out of the way by default. Open the omnibox when you want to navigate; otherwise the pane is just the page or web app you are using. This makes Dumber work well as a side pane next to an editor, terminal, or another desktop application.
3027
@@ -46,7 +43,7 @@ The browser chrome stays out of the way by default. Open the omnibox when you wa
4643
4744 ### Keyboard workflow
4845
49-- Pane, tab, resize, and session modes
46+- Pane, tab, Vim, resize, and session modes
5047 - Vim/Zellij-style navigation patterns
5148 - Search bangs such as `!g`, `!gi`, and `!ddg`
5249 - Launcher integration for `rofi`, `fuzzel`, and `dmenu`
@@ -70,7 +67,7 @@ The browser chrome stays out of the way by default. Open the omnibox when you wa
7067 ### Install script
7168
7269 ```bash
73-curl -fsSL https://dumber.bnema.dev/install | sh
70+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/v0.31.0/install.sh | DUMBER_VERSION=v0.31.0 bash
7471 dumber browse
7572 ```
7673
@@ -89,7 +86,7 @@ flatpak install --user dumber.flatpak
8986 flatpak run dev.bnema.Dumber browse
9087 ```
9188
92-For dependencies, distribution notes, and troubleshooting, see the [installation documentation](https://dumber.bnema.dev/docs).
89+For dependencies, distribution notes, and troubleshooting, see the [installation documentation](https://bnema.dev/dumber/docs).
9390
9491 ## Keyboard modes
9592
@@ -99,6 +96,7 @@ Dumber uses modal keybindings for browser management.
9996 |------|-------------|----------|
10097 | Pane | `Ctrl+P` | Split, stack, close, and move panes |
10198 | Tab | `Ctrl+T` | Create, close, switch, and rename tabs |
99+| Vim Mode | `Ctrl+Y` | Navigate the active webpage with configurable Vim-style sequences; arrow keys stay native and other app shortcuts wait until exit |
102100 | Resize | `Ctrl+N` | Resize panes with `hjkl` or arrow keys |
103101 | Session | `Ctrl+O` | Snapshot, restore, and browse sessions |
104102
@@ -111,7 +109,7 @@ The floating pane is a temporary browser pane that can be toggled without changi
111109 - Profile shortcuts such as `Alt+G` are optional and configured under `workspace.floating_pane.profiles`.
112110 - Some `Alt+<key>` bindings may conflict with browser-engine shortcuts or desktop-level handlers.
113111
114-See the [floating pane reference](https://dumber.bnema.dev/docs/reference/floating-pane) for setup and behavior details.
112+See the [floating pane reference](https://bnema.dev/dumber/docs/reference/floating-pane) for setup and behavior details.
115113
116114 ## Configuration
117115
@@ -125,11 +123,19 @@ type = "cef"
125123 enabled = true
126124 ```
127125
128-The config reloads while Dumber is running. See the [configuration documentation](https://dumber.bnema.dev/docs) for all options.
126+The config reloads while Dumber is running. See the [configuration documentation](https://bnema.dev/dumber/docs) for all options.
127+
128+## Extensions and ad blocking
129+
130+CEF does not expose the Extensions API, so browser extensions are not supported. While WebKitGTK can use
131+injected filter lists, this approach is not available with CEF.
132+
133+For consistent ad and tracker blocking across applications and devices, use network-level DNS filtering such as
134+[AdGuard](https://adguard.com/) or [Pi-hole](https://pi-hole.net/). DNS filtering is more reliable than treating ad blocking as a browser extension, and it protects every device that uses your network.
129135
130136 ## Browser engine
131137
132-Dumber uses Chromium Embedded Framework by default. WebKitGTK is available as a fallback backend.
138+Dumber uses Chromium Embedded Framework by default. WebKitGTK is available as a second option but I don't use it daily so it is more prone to bugs.
133139
134140 On Arch Linux, install the CEF runtime with:
135141
@@ -166,7 +172,7 @@ WebKit can be selected explicitly:
166172 type = "webkit"
167173 ```
168174
169-### Rendering notes
175+### Rendering
170176
171177 CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`.
172178
@@ -190,19 +196,11 @@ sudo apt install libwebkitgtk-6.0-4 libgtk-4-1 gstreamer1.0-plugins-base gstream
190196
191197 > Ubuntu 24.04 ships GLib 2.80, but Dumber requires GLib 2.84+. Use Arch, Fedora 41+, or the Flatpak.
192198
193-## Status
194-
195-Dumber is usable for regular web browsing on Wayland compositors, with the main focus on panes, keyboard control, floating workflows, and desktop integration.
196-
197-It is not a drop-in replacement for every mainstream browser workflow. Extension compatibility, engine behavior, and media support depend on the selected backend and system libraries.
198-
199-Bug reports and reproducible Wayland/backend issues are welcome.
200-
201199 ## Development
202200
203201 Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend.
204202
205-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.
206204
207205 ### Build from source
208206
M cmd/dumber/main.go +39 -9
@@ -96,18 +96,35 @@ func launchModeFromArgs(args []string) (launchMode, string) {
9696 }
9797
9898 func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.BrowserLaunchRelay, browseURL string) (bool, error) {
99+ return tryForwardBrowserURLToRunningInstance(ctx, relay, browseURL, func(ctx context.Context, url string) (bool, error) {
100+ return relay.DeliverOpenExternalURL(ctx, url)
101+ })
102+}
103+
104+func tryForwardFreshWindowURLToRunningInstance(ctx context.Context, relay port.BrowserLaunchRelay, browseURL string) (bool, error) {
105+ return tryForwardBrowserURLToRunningInstance(ctx, relay, browseURL, func(ctx context.Context, url string) (bool, error) {
106+ return relay.DeliverOpenFreshWindow(ctx, url)
107+ })
108+}
109+
110+func tryForwardBrowserURLToRunningInstance(
111+ ctx context.Context,
112+ relay port.BrowserLaunchRelay,
113+ browseURL string,
114+ deliver func(context.Context, string) (bool, error),
115+) (bool, error) {
99116 if relay == nil {
100117 return false, nil
101118 }
102119
103- delivered, err := relay.DeliverOpenFreshWindow(ctx, browseURL)
120+ delivered, err := deliver(ctx, browseURL)
121+ if delivered {
122+ return true, nil
123+ }
104124 if err != nil {
105- if delivered && errors.Is(err, desktop.ErrBrowserLaunchRelayUnconfirmed) {
106- return true, nil
107- }
108125 return false, err
109126 }
110- return delivered, nil
127+ return false, nil
111128 }
112129
113130 func launchStandaloneBrowserURL(ctx context.Context, launch func(context.Context, string) error, uri string) error {
@@ -147,6 +164,11 @@ type startupTiming struct {
147164 }
148165
149166 func main() {
167+ if err := bootstrap.ApplyDevelopmentEnvironment(); err != nil {
168+ _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err)
169+ os.Exit(1)
170+ }
171+
150172 // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can
151173 // invalidate frames retained across those boundaries and make the GC abort
152174 // with "traceback did not unwind completely". Re-exec once so the runtime
@@ -193,7 +215,15 @@ func main() {
193215 timing.configComplete = time.Now()
194216 configureBrowserLaunchRelay(cfg)
195217 startupURL := domainurl.ResolveBrowserStartupURL(browseURL)
196- if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil {
218+ freshWindow := os.Getenv(desktop.FreshWindowLaunchEnvVar) == "1"
219+ if freshWindow {
220+ _ = os.Unsetenv(desktop.FreshWindowLaunchEnvVar)
221+ }
222+ forward := tryForwardBrowseURLToRunningInstance
223+ if freshWindow {
224+ forward = tryForwardFreshWindowURLToRunningInstance
225+ }
226+ if forwarded, err := forward(context.Background(), browserLaunchRelay, startupURL); err != nil {
197227 fmt.Fprintf(
198228 os.Stderr,
199229 "warning: failed to forward browse URL %q to a running instance, falling back to a new process: %v\n",
@@ -297,8 +327,8 @@ func runGUI(cfg *config.Config, timing startupTiming) int {
297327 }
298328 if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
299329 relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
300- if err := app.OpenFreshWindow(ctx, url); err != nil {
301- log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window")
330+ if err := app.OpenExternalURL(ctx, url); err != nil {
331+ log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch external URL")
302332 }
303333 })
304334 }
@@ -961,7 +991,7 @@ func buildUIDependencies(
961991 },
962992 LaunchExternalURL: desktop.LaunchExternalURL,
963993 LaunchBrowserURL: func(navCtx context.Context, uri string) error {
964- return launchStandaloneBrowserURL(navCtx, browserLauncher.LaunchURL, uri)
994+ return launchStandaloneBrowserURL(navCtx, browserLauncher.LaunchFreshWindowURL, uri)
965995 },
966996 BrowserLaunchRelay: browserLaunchRelay,
967997 MigrationChecker: config.NewMigrator(),
M cmd/dumber/main_test.go +19 -5
@@ -230,7 +230,7 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) {
230230
231231 func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testing.T) {
232232 relay := mocks.NewMockBrowserLaunchRelay(t)
233- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "dumb://history").Return(true, nil)
233+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "dumb://history").Return(true, nil)
234234
235235 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "dumb://history")
236236
@@ -244,7 +244,7 @@ func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testi
244244
245245 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) {
246246 relay := mocks.NewMockBrowserLaunchRelay(t)
247- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)
247+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, nil)
248248
249249 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
250250
@@ -256,9 +256,23 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T
256256 }
257257 }
258258
259+func TestTryForwardFreshWindowURLToRunningInstance_UsesFreshWindowRelayAction(t *testing.T) {
260+ relay := mocks.NewMockBrowserLaunchRelay(t)
261+ relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)
262+
263+ forwarded, err := tryForwardFreshWindowURLToRunningInstance(context.Background(), relay, "https://example.com")
264+
265+ if err != nil {
266+ t.Fatalf("expected no error, got %v", err)
267+ }
268+ if !forwarded {
269+ t.Fatal("expected fresh-window relay delivery to be forwarded")
270+ }
271+}
272+
259273 func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) {
260274 relay := mocks.NewMockBrowserLaunchRelay(t)
261- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)
275+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, nil)
262276
263277 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
264278
@@ -272,7 +286,7 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing
272286
273287 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnUnconfirmedRelayDelivery(t *testing.T) {
274288 relay := mocks.NewMockBrowserLaunchRelay(t)
275- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, desktop.ErrBrowserLaunchRelayUnconfirmed)
289+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, desktop.ErrBrowserLaunchRelayUnconfirmed)
276290
277291 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
278292
@@ -309,7 +323,7 @@ func TestLaunchStandaloneBrowserURL_PropagatesOtherErrors(t *testing.T) {
309323 func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) {
310324 relay := mocks.NewMockBrowserLaunchRelay(t)
311325 wantErr := errors.New("relay error")
312- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr)
326+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, wantErr)
313327
314328 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
315329
M docs/config/index.md +75 -4
@@ -398,6 +398,76 @@ cancel = ["escape"]
398398
399399 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
400400
401+### Vim Mode
402+
403+| Key | Type | Default | Description |
404+|-----|------|---------|-------------|
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) |
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 |
409+| `workspace.vim_mode.actions` | map | See below | Action→binding mappings |
410+
411+**Default vim mode actions:**
412+
413+```toml
414+[workspace.vim_mode.actions.vim-scroll-left]
415+keys = ["h"]
416+desc = "Scroll page left"
417+
418+[workspace.vim_mode.actions.vim-scroll-down]
419+keys = ["j"]
420+desc = "Scroll page down"
421+
422+[workspace.vim_mode.actions.vim-scroll-up]
423+keys = ["k"]
424+desc = "Scroll page up"
425+
426+[workspace.vim_mode.actions.vim-scroll-right]
427+keys = ["l"]
428+desc = "Scroll page right"
429+
430+[workspace.vim_mode.actions.vim-scroll-down-fast]
431+keys = ["shift+j"]
432+desc = "Scroll page down fast"
433+
434+[workspace.vim_mode.actions.vim-scroll-up-fast]
435+keys = ["shift+k"]
436+desc = "Scroll page up fast"
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+
450+[workspace.vim_mode.actions.confirm]
451+keys = ["enter"]
452+desc = "Confirm action"
453+
454+[workspace.vim_mode.actions.cancel]
455+keys = ["escape"]
456+desc = "Cancel/exit mode"
457+```
458+
459+Notes:
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.
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.
467+- Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.
468+- `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.
469+- Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.
470+
401471 ### Resize Mode
402472
403473 | Key | Type | Default | Description |
@@ -529,20 +599,21 @@ desc = "Open floating pane on GitHub"
529599 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
530600 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
531601 | `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |
532-| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster |
602+| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue); also reused for Vim mode pane-local accent, indicator, and pulse |
533603 | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
534604 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
535605 | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |
536-| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active |
537-| `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
606+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show bottom-left mode toaster when modal modes are active; Vim Mode keeps a persistent `VIM MODE` indicator until exit or toggle off |
607+| `workspace.styling.transition_duration` | int | `120` | Border and pulse transition duration (ms) |
538608
539609 **Example:**
610+
540611 ```toml
541612 [workspace.styling]
542613 border_width = 1
543614 border_color = "@theme_selected_bg_color"
544615 mode_border_width = 4
545-pane_mode_color = "#4A90E2" # Blue for pane mode
616+pane_mode_color = "#4A90E2" # Blue for pane mode and Vim mode local accent
546617 tab_mode_color = "#FFA500" # Orange for tab mode
547618 session_mode_color = "#9B59B6" # Purple for session mode
548619 resize_mode_color = "#00D4AA" # Teal for resize mode
M docs/getting-started.md +21 -0
@@ -16,10 +16,12 @@ 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, 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 |
1920 | Resize Mode | `Ctrl+N` | Resize pane splits |
2021 | Session Mode | `Ctrl+O` | Session management |
2122
2223 Press `Escape` or `Enter` to exit any mode.
24+Vim Mode stays local to the active pane and automatically leaves the mode when focus moves into the omnibox, find bar, overlays, or a page editable.
2325
2426 ## Pane Mode Quick Reference
2527
@@ -28,6 +30,25 @@ Press `Escape` or `Enter` to exit any mode.
2830 3. `Shift+arrows` to focus adjacent panes
2931 4. `X` to close current pane
3032
33+## Vim Mode Quick Reference
34+
35+1. Press `Ctrl+Y` to enter Vim Mode
36+2. Use `h`, `j`, `k`, `l` to scroll left, down, up, or right
37+3. Use `Shift+J` / `Shift+K` for faster vertical jumps
38+4. Arrow keys continue to use the browser engine's native page navigation while Vim Mode is active
39+5. Use `gi` to cycle through visible page inputs; use `]]` and `[[` to move through headings
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
44+
45+> **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the
46+> shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller
47+> handoff, document fallback). The application repeater owns held-key cadence;
48+> each engine runs one immediate step per tick. Cross-origin frames are
49+> best-effort. CEF may use native precision-wheel input only before the browser
50+> frame is ready.
51+
3152 ## Omnibox
3253
3354 Press `Ctrl+L` to open the omnibox for:
M docs/installation.md +4 -4
@@ -3,7 +3,7 @@
33 ## Quick Install (Linux x86_64)
44
55 ```bash
6-curl -fsSL https://dumber.bnema.dev/install | bash
6+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | bash
77 ```
88
99 Installs to `~/.local/bin` if available, otherwise `/usr/local/bin` (requires sudo).
@@ -13,19 +13,19 @@ Installs to `~/.local/bin` if available, otherwise `/usr/local/bin` (requires su
1313 Install a specific version:
1414
1515 ```bash
16-DUMBER_VERSION=v0.26.2 curl -fsSL https://dumber.bnema.dev/install | bash
16+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_VERSION=v0.26.2 bash
1717 ```
1818
1919 Install the latest prerelease:
2020
2121 ```bash
22-DUMBER_PRERELEASE=1 curl -fsSL https://dumber.bnema.dev/install | bash
22+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_PRERELEASE=1 bash
2323 ```
2424
2525 Combine both to install a specific prerelease:
2626
2727 ```bash
28-DUMBER_VERSION=v0.27.0-rc.1 DUMBER_PRERELEASE=1 curl -fsSL https://dumber.bnema.dev/install | bash
28+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_VERSION=v0.27.0-rc.1 DUMBER_PRERELEASE=1 bash
2929 ```
3030
3131 ## Flatpak
M docs/reference/configuration.md +9 -0
@@ -1,5 +1,7 @@
11 # Configuration Reference
22
3+`workspace.external_links` controls URLs opened from another application. Non-`windowed` modes target the last-focused window in the current Dumber profile and engine; if that destination is unavailable, Dumber falls back to opening a new window. `placement` applies only to `split`.
4+
35 | Key | Type | Default | Valid Values |
46 |-----|------|---------|--------------|
57 | `database.path` | string | `~/.local/share/dumber/dumber.db` | |
@@ -98,6 +100,11 @@
98100 | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | |
99101 | `workspace.tab_mode.timeout_ms` | int | `3000` | |
100102 | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings |
103+| `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | |
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 |
107+| `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) |
101108 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |
102109 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
103110 | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings |
@@ -111,6 +118,8 @@
111118 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
112119 | `workspace.floating_pane.profiles.<name>.url` | string | | required URL |
113120 | `workspace.floating_pane.profiles.<name>.desc` | string | | |
121+| `workspace.external_links.behavior` | string | `windowed` | `windowed`, `tabbed`, `split`, `stacked` |
122+| `workspace.external_links.placement` | string | `right` | `right`, `left`, `top`, `bottom` (used only with `split`) |
114123 | `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
115124 | `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
116125 | `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | |
M docs/reference/keybindings.md +58 -2
@@ -8,10 +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, 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 |
1112 | Resize Mode | `Ctrl+N` | Resize pane splits |
1213 | Session Mode | `Ctrl+O` | Session management |
1314
1415 Press `Escape` or `Enter` to exit any mode.
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.
1517
1618 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.
1719
@@ -51,6 +53,29 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke
5153 | Confirm | `Enter` |
5254 | Cancel | `Escape` |
5355
56+## Vim Mode (`Ctrl+Y`)
57+
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+
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+
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+
64+| Action | Keys |
65+|--------|------|
66+| Scroll left | `H` |
67+| Scroll down | `J` |
68+| Scroll up | `K` |
69+| Scroll right | `L` |
70+| Scroll down fast | `Shift+J` |
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` |
76+| Confirm | `Enter` |
77+| Cancel | `Escape` |
78+
5479 ## Resize Mode (`Ctrl+N`)
5580
5681 | Action | Keys |
@@ -98,7 +123,7 @@ These work outside modal modes:
98123 - `Alt+F` is the only floating-pane shortcut enabled by default.
99124 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
100125 - `Ctrl+H` toggles the native GTK history sidebar. The sidebar shows browsing history grouped by day with search/filter, keyboard navigation (arrows, Home/End, Ctrl+arrows for day jumps), and activation modes (Enter to navigate while keeping the sidebar open, Ctrl+Enter to navigate while keeping the sidebar open, Shift+Enter to open in a new split). If the native sidebar is unavailable, the shortcut returns an error instead of falling back to `dumb://history`.
101-- `Ctrl+B` toggles the native GTK Favorites sidebar. Search matches favorite titles, URLs, and tag names. `Tab`/`Shift+Tab` cycle sidebar zones; `Enter` and `Ctrl+Enter` open the selected favorite in the current pane while keeping the sidebar open; `Shift+Enter` opens it in a new split. Inside the sidebar, `a` adds, `e` edits, `t` opens tag mode, `s` opens shortcut mode, `Delete` starts delete confirmation, `/` focuses search, `Esc` clears/cancels/closes, `r` reloads, and `c` clears search and filters.
126+- `Ctrl+B` toggles the native GTK Favorites sidebar. Search matches favorite titles, URLs, and tag names. `Tab`/`Shift+Tab` traverse search, every tag filter, and the favorites list; arrow keys navigate favorites once the list is focused. The `+` at the right end of the tag-filter row creates a named tag. With a favorite selected in the list, `+` opens its tag binding controls. `Enter` and `Ctrl+Enter` open the selected favorite in the current pane while keeping the sidebar open; `Shift+Enter` opens it in a new split. Inside the sidebar, `a` adds, `e` edits, `s` opens shortcut mode, `Delete` starts delete confirmation, `/` focuses search, `Esc` clears/cancels/closes, `r` reloads, and `c` clears search and filters.
102127 - `Ctrl+D` toggles the active page as a favorite/bookmark. Favorite shortcut metadata can be assigned in the sidebar, but global `Alt+1..9` remains tab switching.
103128 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
104129 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
@@ -118,6 +143,37 @@ All keybindings can be customized in `~/.config/dumber/config.toml`:
118143 split-right = ["arrowright", "r"]
119144 close-pane = ["x", "q"]
120145
146+[workspace.vim_mode]
147+activation_shortcut = "ctrl+y"
148+timeout_ms = 0
149+
150+[workspace.vim_mode.actions.vim-scroll-left]
151+keys = ["h"]
152+
153+[workspace.vim_mode.actions.vim-scroll-down]
154+keys = ["j"]
155+
156+[workspace.vim_mode.actions.vim-scroll-up]
157+keys = ["k"]
158+
159+[workspace.vim_mode.actions.vim-scroll-right]
160+keys = ["l"]
161+
162+[workspace.vim_mode.actions.vim-scroll-down-fast]
163+keys = ["shift+j"]
164+
165+[workspace.vim_mode.actions.vim-scroll-up-fast]
166+keys = ["shift+k"]
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+
121177 [workspace.shortcuts.actions.close-pane]
122178 keys = ["ctrl+w"]
123179
@@ -127,7 +183,7 @@ keys = ["alt+f"]
127183 [workspace.shortcuts.actions.toggle-history-systemview]
128184 keys = ["ctrl+h"]
129185
130-[workspace.shortcuts.actions.toggle-favorites-systemview]
186+[workspace.shortcuts.actions.toggle-favorites-sidebar]
131187 keys = ["ctrl+b"]
132188
133189 [workspace.shortcuts.actions.toggle-current-page-favorite]
M go.mod +5 -5
@@ -1,17 +1,17 @@
11 module github.com/bnema/dumber
22
3-go 1.26.3
3+go 1.26
44
55 require (
66 github.com/a-h/templ v0.3.1020
77 github.com/andybalholm/brotli v1.2.2
88 github.com/bnema/purego v0.11.0-bnema.4
99 github.com/bnema/purego-cef v0.14.2
10- github.com/bnema/purego-cef2gtk v0.9.0
11- github.com/bnema/purego-pipewire v0.1.5
12- github.com/bnema/purego-sqlite v0.1.4
10+ github.com/bnema/purego-cef2gtk v0.9.3
11+ github.com/bnema/purego-pipewire v0.1.6
12+ github.com/bnema/purego-sqlite v0.1.5
1313 github.com/bnema/purego-webp v0.2.1
14- github.com/bnema/puregotk v0.7.1
14+ github.com/bnema/puregotk v0.7.2
1515 github.com/charmbracelet/bubbles v1.0.0
1616 github.com/charmbracelet/bubbletea v1.3.10
1717 github.com/charmbracelet/lipgloss v1.1.0
M go.sum +8 -8 generated file: collapsed
M install.sh +2 -2
@@ -2,8 +2,8 @@
22 set -e
33
44 # Dumber installer script
5-# Usage: curl -fsSL https://dumber.bnema.dev/install | sh
6-# Usage with pre-release: curl -fsSL https://dumber.bnema.dev/install | DUMBER_PRERELEASE=1 sh
5+# Usage: curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | sh
6+# Usage with pre-release: curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_PRERELEASE=1 sh
77
88 REPO="bnema/dumber"
99 VERSION="${DUMBER_VERSION:-latest}"
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+}
M internal/application/port/browser_launch.go +7 -3
@@ -5,18 +5,22 @@ import (
55 "io"
66 )
77
8-// BrowserWindowOpener opens a fresh browser window.
8+// BrowserWindowOpener opens browser URLs received through the launch relay.
99 type BrowserWindowOpener interface {
10+ OpenExternalURL(ctx context.Context, url string) error
1011 OpenFreshWindow(ctx context.Context, url string) error
1112 }
1213
13-// BrowserLaunchRelay delivers fresh-window launch requests.
14+// BrowserLaunchRelay delivers browser URL launch requests.
1415 type BrowserLaunchRelay interface {
15- // DeliverOpenFreshWindow attempts to deliver a request to open a fresh window.
16+ // DeliverOpenExternalURL attempts to deliver a request to open an external URL.
1617 // The bool reports whether the relay accepted the request; false means the
1718 // caller should treat it as undelivered and try another path.
1819 // An error may still be returned with delivered=true when the relay accepted
1920 // the request but could not confirm completion before the caller timed out.
21+ DeliverOpenExternalURL(ctx context.Context, url string) (bool, error)
22+ // DeliverOpenFreshWindow attempts to deliver a request that must open in a
23+ // new browser window, independently of external-link workspace settings.
2024 DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error)
2125 Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error)
2226 }
M internal/application/port/favorites_sidebar.go +2 -0
@@ -11,10 +11,12 @@ import (
1111 type FavoritesSidebarFavorites interface {
1212 GetAll(ctx context.Context) ([]*entity.Favorite, error)
1313 GetAllTags(ctx context.Context) ([]*entity.Tag, error)
14+ AddTag(ctx context.Context, name, color string) (*entity.Tag, error)
1415 AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
1516 UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
1617 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
1718 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
1819 TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
1920 UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
21+ UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, tagIDs []entity.TagID) error
2022 }
M internal/application/port/mocks/browser_launch.go +123 -0
@@ -39,6 +39,63 @@ func (_m *MockBrowserWindowOpener) EXPECT() *MockBrowserWindowOpener_Expecter {
3939 return &MockBrowserWindowOpener_Expecter{mock: &_m.Mock}
4040 }
4141
42+// OpenExternalURL provides a mock function for the type MockBrowserWindowOpener
43+func (_mock *MockBrowserWindowOpener) OpenExternalURL(ctx context.Context, url string) error {
44+ ret := _mock.Called(ctx, url)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for OpenExternalURL")
48+ }
49+
50+ var r0 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
52+ r0 = returnFunc(ctx, url)
53+ } else {
54+ r0 = ret.Error(0)
55+ }
56+ return r0
57+}
58+
59+// MockBrowserWindowOpener_OpenExternalURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenExternalURL'
60+type MockBrowserWindowOpener_OpenExternalURL_Call struct {
61+ *mock.Call
62+}
63+
64+// OpenExternalURL is a helper method to define mock.On call
65+// - ctx context.Context
66+// - url string
67+func (_e *MockBrowserWindowOpener_Expecter) OpenExternalURL(ctx any, url any) *MockBrowserWindowOpener_OpenExternalURL_Call {
68+ return &MockBrowserWindowOpener_OpenExternalURL_Call{Call: _e.mock.On("OpenExternalURL", ctx, url)}
69+}
70+
71+func (_c *MockBrowserWindowOpener_OpenExternalURL_Call) Run(run func(ctx context.Context, url string)) *MockBrowserWindowOpener_OpenExternalURL_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 context.Context
74+ if args[0] != nil {
75+ arg0 = args[0].(context.Context)
76+ }
77+ var arg1 string
78+ if args[1] != nil {
79+ arg1 = args[1].(string)
80+ }
81+ run(
82+ arg0,
83+ arg1,
84+ )
85+ })
86+ return _c
87+}
88+
89+func (_c *MockBrowserWindowOpener_OpenExternalURL_Call) Return(err error) *MockBrowserWindowOpener_OpenExternalURL_Call {
90+ _c.Call.Return(err)
91+ return _c
92+}
93+
94+func (_c *MockBrowserWindowOpener_OpenExternalURL_Call) RunAndReturn(run func(ctx context.Context, url string) error) *MockBrowserWindowOpener_OpenExternalURL_Call {
95+ _c.Call.Return(run)
96+ return _c
97+}
98+
4299 // OpenFreshWindow provides a mock function for the type MockBrowserWindowOpener
43100 func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {
44101 ret := _mock.Called(ctx, url)
@@ -123,6 +180,72 @@ func (_m *MockBrowserLaunchRelay) EXPECT() *MockBrowserLaunchRelay_Expecter {
123180 return &MockBrowserLaunchRelay_Expecter{mock: &_m.Mock}
124181 }
125182
183+// DeliverOpenExternalURL provides a mock function for the type MockBrowserLaunchRelay
184+func (_mock *MockBrowserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
185+ ret := _mock.Called(ctx, url)
186+
187+ if len(ret) == 0 {
188+ panic("no return value specified for DeliverOpenExternalURL")
189+ }
190+
191+ var r0 bool
192+ var r1 error
193+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok {
194+ return returnFunc(ctx, url)
195+ }
196+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
197+ r0 = returnFunc(ctx, url)
198+ } else {
199+ r0 = ret.Get(0).(bool)
200+ }
201+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
202+ r1 = returnFunc(ctx, url)
203+ } else {
204+ r1 = ret.Error(1)
205+ }
206+ return r0, r1
207+}
208+
209+// MockBrowserLaunchRelay_DeliverOpenExternalURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeliverOpenExternalURL'
210+type MockBrowserLaunchRelay_DeliverOpenExternalURL_Call struct {
211+ *mock.Call
212+}
213+
214+// DeliverOpenExternalURL is a helper method to define mock.On call
215+// - ctx context.Context
216+// - url string
217+func (_e *MockBrowserLaunchRelay_Expecter) DeliverOpenExternalURL(ctx any, url any) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
218+ return &MockBrowserLaunchRelay_DeliverOpenExternalURL_Call{Call: _e.mock.On("DeliverOpenExternalURL", ctx, url)}
219+}
220+
221+func (_c *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call) Run(run func(ctx context.Context, url string)) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
222+ _c.Call.Run(func(args mock.Arguments) {
223+ var arg0 context.Context
224+ if args[0] != nil {
225+ arg0 = args[0].(context.Context)
226+ }
227+ var arg1 string
228+ if args[1] != nil {
229+ arg1 = args[1].(string)
230+ }
231+ run(
232+ arg0,
233+ arg1,
234+ )
235+ })
236+ return _c
237+}
238+
239+func (_c *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call) Return(b bool, err error) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
240+ _c.Call.Return(b, err)
241+ return _c
242+}
243+
244+func (_c *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call) RunAndReturn(run func(ctx context.Context, url string) (bool, error)) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
245+ _c.Call.Return(run)
246+ return _c
247+}
248+
126249 // DeliverOpenFreshWindow provides a mock function for the type MockBrowserLaunchRelay
127250 func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
128251 ret := _mock.Called(ctx, url)
M internal/application/port/mocks/webview.go +84 -0
@@ -3112,3 +3112,87 @@ func (_c *MockTextInputTargetProvider_TextInputTarget_Call) RunAndReturn(run fun
31123112 _c.Call.Return(run)
31133113 return _c
31143114 }
3115+
3116+// NewMockPageScrollable creates a new instance of MockPageScrollable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3117+// The first argument is typically a *testing.T value.
3118+func NewMockPageScrollable(t interface {
3119+ mock.TestingT
3120+ Cleanup(func())
3121+}) *MockPageScrollable {
3122+ mock := &MockPageScrollable{}
3123+ mock.Mock.Test(t)
3124+
3125+ t.Cleanup(func() { mock.AssertExpectations(t) })
3126+
3127+ return mock
3128+}
3129+
3130+// MockPageScrollable is an autogenerated mock type for the PageScrollable type
3131+type MockPageScrollable struct {
3132+ mock.Mock
3133+}
3134+
3135+type MockPageScrollable_Expecter struct {
3136+ mock *mock.Mock
3137+}
3138+
3139+func (_m *MockPageScrollable) EXPECT() *MockPageScrollable_Expecter {
3140+ return &MockPageScrollable_Expecter{mock: &_m.Mock}
3141+}
3142+
3143+// ScrollPage provides a mock function for the type MockPageScrollable
3144+func (_mock *MockPageScrollable) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
3145+ ret := _mock.Called(ctx, request)
3146+
3147+ if len(ret) == 0 {
3148+ panic("no return value specified for ScrollPage")
3149+ }
3150+
3151+ var r0 error
3152+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.PageScrollRequest) error); ok {
3153+ r0 = returnFunc(ctx, request)
3154+ } else {
3155+ r0 = ret.Error(0)
3156+ }
3157+ return r0
3158+}
3159+
3160+// MockPageScrollable_ScrollPage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ScrollPage'
3161+type MockPageScrollable_ScrollPage_Call struct {
3162+ *mock.Call
3163+}
3164+
3165+// ScrollPage is a helper method to define mock.On call
3166+// - ctx context.Context
3167+// - request port.PageScrollRequest
3168+func (_e *MockPageScrollable_Expecter) ScrollPage(ctx any, request any) *MockPageScrollable_ScrollPage_Call {
3169+ return &MockPageScrollable_ScrollPage_Call{Call: _e.mock.On("ScrollPage", ctx, request)}
3170+}
3171+
3172+func (_c *MockPageScrollable_ScrollPage_Call) Run(run func(ctx context.Context, request port.PageScrollRequest)) *MockPageScrollable_ScrollPage_Call {
3173+ _c.Call.Run(func(args mock.Arguments) {
3174+ var arg0 context.Context
3175+ if args[0] != nil {
3176+ arg0 = args[0].(context.Context)
3177+ }
3178+ var arg1 port.PageScrollRequest
3179+ if args[1] != nil {
3180+ arg1 = args[1].(port.PageScrollRequest)
3181+ }
3182+ run(
3183+ arg0,
3184+ arg1,
3185+ )
3186+ })
3187+ return _c
3188+}
3189+
3190+func (_c *MockPageScrollable_ScrollPage_Call) Return(err error) *MockPageScrollable_ScrollPage_Call {
3191+ _c.Call.Return(err)
3192+ return _c
3193+}
3194+
3195+func (_c *MockPageScrollable_ScrollPage_Call) RunAndReturn(run func(ctx context.Context, request port.PageScrollRequest) error) *MockPageScrollable_ScrollPage_Call {
3196+ _c.Call.Return(run)
3197+ return _c
3198+}
M internal/application/port/webview.go +95 -0
@@ -5,6 +5,7 @@ package port
55
66 import (
77 "context"
8+ "fmt"
89
910 "github.com/bnema/dumber/internal/application/dto"
1011 "github.com/bnema/dumber/internal/domain/entity"
@@ -142,6 +143,10 @@ type WebViewCallbacks struct {
142143 // OnTouchpadNavigationGesture is called while a two-finger touchpad history
143144 // navigation gesture is progressing or finishing.
144145 OnTouchpadNavigationGesture func(gesture entity.TouchpadNavigationGesture)
146+
147+ // OnEditableFocusChanged is called when the page focus enters or leaves an
148+ // editable target such as input, textarea, or contenteditable content.
149+ OnEditableFocusChanged func(editable bool)
145150 }
146151
147152 // FindOptions configures search behavior.
@@ -390,3 +395,93 @@ type OAuthCallbackCapable interface {
390395 type TextInputTargetProvider interface {
391396 TextInputTarget() TextInputTarget
392397 }
398+
399+// PageScrollCommand identifies a semantic vim-scroll request at the
400+// application boundary without depending on usecase package enums.
401+type PageScrollCommand int
402+
403+const (
404+ PageScrollCommandLeft PageScrollCommand = iota
405+ PageScrollCommandRight
406+ PageScrollCommandUp
407+ PageScrollCommandDown
408+ PageScrollCommandUpFast
409+ PageScrollCommandDownFast
410+)
411+
412+// PageScrollRequest carries a semantic vim-scroll command identity and its
413+// fallback pixel deltas. Engines own the execution strategy and may use the
414+// fallback delta when no native mechanism exists for the command.
415+type PageScrollRequest struct {
416+ Command PageScrollCommand
417+ FallbackDX int
418+ FallbackDY int
419+ // Continuous distinguishes autonomous held-key steps from the guaranteed
420+ // tap step so adapters can cancel queued held work without dropping taps.
421+ Continuous bool
422+}
423+
424+// IsValid reports whether c is a supported semantic page-scroll command.
425+func (c PageScrollCommand) IsValid() bool {
426+ return c >= PageScrollCommandLeft && c <= PageScrollCommandDownFast
427+}
428+
429+// Validate checks the command identity carried by a page-scroll request.
430+func (r PageScrollRequest) Validate() error {
431+ if !r.Command.IsValid() {
432+ return fmt.Errorf("unsupported page scroll command %d", r.Command)
433+ }
434+ return nil
435+}
436+
437+// PageScrollable is an optional capability for WebViews that support semantic
438+// page scrolling. Page scrolling is used by keyboard-driven navigation modes
439+// such as Vim mode.
440+//
441+// Engines own the execution strategy. When a native mechanism exists for the
442+// requested command, the engine may ignore the fallback delta. Otherwise it
443+// should use the fallback delta as a JavaScript-driven scroll amount.
444+type PageScrollable interface {
445+ ScrollPage(ctx context.Context, request PageScrollRequest) error
446+}
447+
448+// PageScrollCanceler is an optional capability for adapters that queue held
449+// vim-scroll work. Implementations discard unconsumed continuous deltas while
450+// preserving any guaranteed tap already accepted.
451+type PageScrollCanceler interface {
452+ CancelPageScroll(ctx context.Context)
453+}
454+
455+// AccessibilityEnabler is an optional capability for adapters that can enable
456+// browser accessibility tooling once per WebView. Engines that do not support
457+// accessibility leave this unimplemented so callers can type-assert safely.
458+type AccessibilityEnabler interface {
459+ EnableAccessibility()
460+}
461+
462+// SemanticNavigable is an optional WebView capability for visible structural
463+// navigation. Unsupported engines safely leave it unimplemented.
464+type SemanticNavigable interface {
465+ NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error
466+}
467+
468+// SemanticNavigationHighlightClearer is an optional WebView capability for
469+// removing the visual target left by semantic navigation.
470+type SemanticNavigationHighlightClearer interface {
471+ ClearSemanticNavigationHighlight(ctx context.Context) error
472+}
473+
474+// PageInputFocuser is an optional WebView capability for moving Vim Mode
475+// input focus to the next eligible page input. With no active eligible input,
476+// implementations focus the first one. Unsupported engines safely leave it
477+// unimplemented.
478+type PageInputFocuser interface {
479+ FocusNextInput()
480+}
481+
482+// PageFocusNavigator is an optional WebView capability for keeping native-like
483+// focus traversal inside the active page when the host window would otherwise
484+// move focus outside the WebView.
485+type PageFocusNavigator interface {
486+ NavigatePageFocus(backward bool)
487+}
M internal/application/usecase/keybindings.go +1 -0
@@ -18,6 +18,7 @@ var validModes = map[string]bool{
1818 "global": true,
1919 "pane": true,
2020 "tab": true,
21+ "vim": true,
2122 "resize": true,
2223 "session": true,
2324 }
M internal/application/usecase/keybindings_test.go +11 -0
@@ -72,6 +72,17 @@ func TestGetKeybindingsUseCase_Execute(t *testing.T) {
7272 }
7373
7474 func TestSetKeybindingUseCase_Execute(t *testing.T) {
75+ t.Run("accepts vim mode", func(t *testing.T) {
76+ mockProvider := mocks.NewMockKeybindingsProvider(t)
77+ mockSaver := mocks.NewMockKeybindingsSaver(t)
78+ req := port.SetKeybindingRequest{Mode: "vim", Action: "vim-scroll-down", Keys: []string{"j"}}
79+ mockProvider.EXPECT().CheckConflicts(mock.Anything, req.Mode, req.Action, req.Keys).Return(nil, nil)
80+ mockSaver.EXPECT().SetKeybinding(mock.Anything, req).Return(nil)
81+
82+ _, err := usecase.NewSetKeybindingUseCase(mockProvider, mockSaver).Execute(context.Background(), req)
83+ require.NoError(t, err)
84+ })
85+
7586 t.Run("successfully sets keybinding", func(t *testing.T) {
7687 // Arrange
7788 mockProvider := mocks.NewMockKeybindingsProvider(t)
M internal/application/usecase/manage_favorites.go +77 -0
@@ -599,6 +599,83 @@ func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entit
599599 return nil
600600 }
601601
602+// UpdateFavoriteTags reconciles a favorite's tag assignments with the requested state.
603+// Failed mutations are rolled back in reverse order when the repository cannot
604+// provide a transaction boundary.
605+func (uc *ManageFavoritesUseCase) UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, tagIDs []entity.TagID) error {
606+ currentTags, err := uc.tagRepo.GetForFavorite(ctx, favID)
607+ if err != nil {
608+ return fmt.Errorf("failed to load tags for favorite %d: %w", favID, err)
609+ }
610+
611+ current := make(map[entity.TagID]struct{}, len(currentTags))
612+ for _, tag := range currentTags {
613+ if tag != nil {
614+ current[tag.ID] = struct{}{}
615+ }
616+ }
617+ wanted := make(map[entity.TagID]struct{}, len(tagIDs))
618+ for _, id := range tagIDs {
619+ wanted[id] = struct{}{}
620+ }
621+
622+ toRemove := make([]entity.TagID, 0, len(current))
623+ for id := range current {
624+ if _, ok := wanted[id]; !ok {
625+ toRemove = append(toRemove, id)
626+ }
627+ }
628+ toAdd := make([]entity.TagID, 0, len(wanted))
629+ for id := range wanted {
630+ if _, ok := current[id]; !ok {
631+ toAdd = append(toAdd, id)
632+ }
633+ }
634+ sort.Slice(toRemove, func(i, j int) bool { return toRemove[i] < toRemove[j] })
635+ sort.Slice(toAdd, func(i, j int) bool { return toAdd[i] < toAdd[j] })
636+
637+ type mutation struct {
638+ tagID entity.TagID
639+ added bool
640+ }
641+ applied := make([]mutation, 0, len(toRemove)+len(toAdd))
642+ rollbackLog := logging.FromContext(ctx)
643+ rollback := func() {
644+ for i := len(applied) - 1; i >= 0; i-- {
645+ change := applied[i]
646+ var rollbackErr error
647+ if change.added {
648+ rollbackErr = uc.UntagFavorite(ctx, favID, change.tagID)
649+ } else {
650+ rollbackErr = uc.TagFavorite(ctx, favID, change.tagID)
651+ }
652+ if rollbackErr != nil {
653+ rollbackLog.Warn().
654+ Err(rollbackErr).
655+ Int64("favorite_id", int64(favID)).
656+ Int64("tag_id", int64(change.tagID)).
657+ Msg("failed to roll back favorite tag mutation")
658+ }
659+ }
660+ }
661+
662+ for _, id := range toRemove {
663+ if err := uc.UntagFavorite(ctx, favID, id); err != nil {
664+ rollback()
665+ return fmt.Errorf("failed to remove tag %d from favorite %d: %w", id, favID, err)
666+ }
667+ applied = append(applied, mutation{tagID: id})
668+ }
669+ for _, id := range toAdd {
670+ if err := uc.TagFavorite(ctx, favID, id); err != nil {
671+ rollback()
672+ return fmt.Errorf("failed to add tag %d to favorite %d: %w", id, favID, err)
673+ }
674+ applied = append(applied, mutation{tagID: id, added: true})
675+ }
676+ return nil
677+}
678+
602679 // GetTagsForFavorite retrieves all tags for a favorite.
603680 func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
604681 log := logging.FromContext(ctx)
A internal/application/usecase/page_scroll.go +150 -0
@@ -0,0 +1,150 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+// PageScrollCommand represents a semantic page scroll action.
13+// Each command maps to a fixed pixel delta defined in this package.
14+type PageScrollCommand int
15+
16+const (
17+ PageScrollLeft PageScrollCommand = iota // scroll left by small horizontal amount
18+ PageScrollRight // scroll right by small horizontal amount
19+ PageScrollUp // scroll up by small vertical amount
20+ PageScrollDown // scroll down by small vertical amount
21+ PageScrollUpFast // scroll up by fast vertical amount
22+ PageScrollDownFast // scroll down by fast vertical amount
23+)
24+
25+// Internal fixed pixel defaults for page scroll commands.
26+// These are application-layer constants, not user-configurable.
27+const (
28+ scrollSmallHorizontal = 80
29+ scrollSmallVertical = 80
30+ scrollFastVertical = 320
31+)
32+
33+// PageScrollUseCase handles semantic page scrolling commands.
34+// It maps semantic scroll actions (left, down, up-fast, etc.) to pixel deltas
35+// and delegates to the WebView's scroll capability. No UI logic or
36+// engine-specific JavaScript exists in this layer.
37+type PageScrollUseCase struct{}
38+
39+// NewPageScrollUseCase creates a new PageScrollUseCase.
40+func NewPageScrollUseCase() *PageScrollUseCase {
41+ return &PageScrollUseCase{}
42+}
43+
44+// Scroll applies a semantic scroll command to a WebView.
45+// It returns an error if the WebView does not support semantic page scrolling
46+// (i.e., does not implement port.PageScrollable).
47+func (uc *PageScrollUseCase) Scroll(ctx context.Context, wv port.WebView, cmd PageScrollCommand) error {
48+ return uc.scroll(ctx, wv, cmd, false)
49+}
50+
51+// ScrollContinuous applies one autonomous held-key step. The pixel policy is
52+// identical to Scroll; only lifecycle metadata differs for adapter coalescing.
53+func (uc *PageScrollUseCase) ScrollContinuous(ctx context.Context, wv port.WebView, cmd PageScrollCommand) error {
54+ return uc.scroll(ctx, wv, cmd, true)
55+}
56+
57+// Stop cancels queued continuous work when the adapter supports cancellation.
58+// Adapters such as WebKit that execute each step immediately need no special
59+// capability and therefore treat this as a no-op.
60+func (*PageScrollUseCase) Stop(ctx context.Context, wv port.WebView) error {
61+ if wv == nil {
62+ return errors.New("page scroll: nil webview")
63+ }
64+ if canceler, ok := wv.(port.PageScrollCanceler); ok {
65+ canceler.CancelPageScroll(ctx)
66+ }
67+ return nil
68+}
69+
70+func (*PageScrollUseCase) scroll(ctx context.Context, wv port.WebView, cmd PageScrollCommand, continuous bool) error {
71+ if wv == nil {
72+ return errors.New("page scroll: nil webview")
73+ }
74+
75+ scroller, ok := wv.(port.PageScrollable)
76+ if !ok {
77+ return errors.New("page scroll: webview does not support page scrolling")
78+ }
79+
80+ dx, dy := scrollDelta(cmd)
81+ req := port.PageScrollRequest{
82+ Command: toPortPageScrollCommand(cmd),
83+ FallbackDX: dx,
84+ FallbackDY: dy,
85+ Continuous: continuous,
86+ }
87+ if err := scroller.ScrollPage(ctx, req); err != nil {
88+ return fmt.Errorf("page scroll: %w", err)
89+ }
90+ return nil
91+}
92+
93+func toPortPageScrollCommand(cmd PageScrollCommand) port.PageScrollCommand {
94+ switch cmd {
95+ case PageScrollLeft:
96+ return port.PageScrollCommandLeft
97+ case PageScrollRight:
98+ return port.PageScrollCommandRight
99+ case PageScrollUp:
100+ return port.PageScrollCommandUp
101+ case PageScrollDown:
102+ return port.PageScrollCommandDown
103+ case PageScrollUpFast:
104+ return port.PageScrollCommandUpFast
105+ case PageScrollDownFast:
106+ return port.PageScrollCommandDownFast
107+ default:
108+ return port.PageScrollCommand(-1)
109+ }
110+}
111+
112+// scrollDelta returns the pixel delta for a PageScrollCommand.
113+func scrollDelta(cmd PageScrollCommand) (dx, dy int) {
114+ switch cmd {
115+ case PageScrollLeft:
116+ return -scrollSmallHorizontal, 0
117+ case PageScrollRight:
118+ return scrollSmallHorizontal, 0
119+ case PageScrollUp:
120+ return 0, -scrollSmallVertical
121+ case PageScrollDown:
122+ return 0, scrollSmallVertical
123+ case PageScrollUpFast:
124+ return 0, -scrollFastVertical
125+ case PageScrollDownFast:
126+ return 0, scrollFastVertical
127+ default:
128+ return 0, 0
129+ }
130+}
131+
132+// String returns a human-readable name for the scroll command.
133+func (cmd PageScrollCommand) String() string {
134+ switch cmd {
135+ case PageScrollLeft:
136+ return "left"
137+ case PageScrollRight:
138+ return "right"
139+ case PageScrollUp:
140+ return "up"
141+ case PageScrollDown:
142+ return "down"
143+ case PageScrollUpFast:
144+ return "up-fast"
145+ case PageScrollDownFast:
146+ return "down-fast"
147+ default:
148+ return "unknown"
149+ }
150+}
A internal/application/usecase/page_scroll_test.go +320 -0
@@ -0,0 +1,320 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+func TestPageScroll_SmallVerticalDown(t *testing.T) {
12+ wv := newScrollableStub(nil)
13+ uc := NewPageScrollUseCase()
14+
15+ err := uc.Scroll(context.Background(), wv, PageScrollDown)
16+ if err != nil {
17+ t.Fatalf("Scroll() unexpected error: %v", err)
18+ }
19+
20+ if wv.lastRequest.Command != port.PageScrollCommandDown {
21+ t.Fatalf("ScrollPage request.Command = %d, want %d", wv.lastRequest.Command, port.PageScrollCommandDown)
22+ }
23+ if wv.lastRequest.FallbackDX != 0 || wv.lastRequest.FallbackDY != 80 {
24+ t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (0, 80)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY)
25+ }
26+}
27+
28+func TestPageScroll_FastVerticalUp(t *testing.T) {
29+ wv := newScrollableStub(nil)
30+ uc := NewPageScrollUseCase()
31+
32+ err := uc.Scroll(context.Background(), wv, PageScrollUpFast)
33+ if err != nil {
34+ t.Fatalf("Scroll() unexpected error: %v", err)
35+ }
36+
37+ if wv.lastRequest.Command != port.PageScrollCommandUpFast {
38+ t.Fatalf("ScrollPage request.Command = %d, want %d", wv.lastRequest.Command, port.PageScrollCommandUpFast)
39+ }
40+ if wv.lastRequest.FallbackDX != 0 || wv.lastRequest.FallbackDY != -320 {
41+ t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (0, -320)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY)
42+ }
43+}
44+
45+func TestPageScroll_HorizontalDirectionMapping(t *testing.T) {
46+ t.Run("left", func(t *testing.T) {
47+ wv := newScrollableStub(nil)
48+ uc := NewPageScrollUseCase()
49+
50+ if err := uc.Scroll(context.Background(), wv, PageScrollLeft); err != nil {
51+ t.Fatalf("Scroll() unexpected error: %v", err)
52+ }
53+
54+ if wv.lastRequest.Command != port.PageScrollCommandLeft {
55+ t.Fatalf("ScrollPage request.Command = %d, want %d", wv.lastRequest.Command, port.PageScrollCommandLeft)
56+ }
57+ if wv.lastRequest.FallbackDX != -80 || wv.lastRequest.FallbackDY != 0 {
58+ t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (-80, 0)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY)
59+ }
60+ })
61+
62+ t.Run("right", func(t *testing.T) {
63+ wv := newScrollableStub(nil)
64+ uc := NewPageScrollUseCase()
65+ if err := uc.Scroll(context.Background(), wv, PageScrollRight); err != nil {
66+ t.Fatalf("Scroll() unexpected error: %v", err)
67+ }
68+
69+ if wv.lastRequest.Command != port.PageScrollCommandRight {
70+ t.Fatalf("ScrollPage request.Command = %d, want %d", wv.lastRequest.Command, port.PageScrollCommandRight)
71+ }
72+ if wv.lastRequest.FallbackDX != 80 || wv.lastRequest.FallbackDY != 0 {
73+ t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (80, 0)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY)
74+ }
75+ })
76+}
77+
78+func TestPageScroll_UnsupportedWebView(t *testing.T) {
79+ wv := &nonScrollableStub{}
80+ uc := NewPageScrollUseCase()
81+
82+ err := uc.Scroll(context.Background(), wv, PageScrollDown)
83+ if err == nil {
84+ t.Fatal("Scroll() expected error for unsupported webview, got nil")
85+ }
86+ if err.Error() != "page scroll: webview does not support page scrolling" {
87+ t.Fatalf("Scroll() error = %q, want %q", err.Error(), "page scroll: webview does not support page scrolling")
88+ }
89+}
90+
91+func TestPageScroll_UnsupportedWebView_AllCommandsStable(t *testing.T) {
92+ wv := &nonScrollableStub{}
93+ uc := NewPageScrollUseCase()
94+
95+ cmds := []PageScrollCommand{
96+ PageScrollLeft,
97+ PageScrollRight,
98+ PageScrollUp,
99+ PageScrollDown,
100+ PageScrollUpFast,
101+ PageScrollDownFast,
102+ }
103+
104+ for _, cmd := range cmds {
105+ err := uc.Scroll(context.Background(), wv, cmd)
106+ if err == nil {
107+ t.Fatalf("Scroll(command=%s) expected error for unsupported webview, got nil", cmd)
108+ }
109+ if err.Error() != "page scroll: webview does not support page scrolling" {
110+ t.Fatalf("Scroll(command=%s) error = %q, want %q", cmd, err.Error(), "page scroll: webview does not support page scrolling")
111+ }
112+ }
113+}
114+
115+func TestPageScroll_NilWebView_StableFailure(t *testing.T) {
116+ uc := NewPageScrollUseCase()
117+
118+ err := uc.Scroll(context.Background(), nil, PageScrollDown)
119+ if err == nil {
120+ t.Fatal("Scroll() expected error for nil webview, got nil")
121+ }
122+ if err.Error() != "page scroll: nil webview" {
123+ t.Fatalf("Scroll() error = %q, want %q", err.Error(), "page scroll: nil webview")
124+ }
125+}
126+
127+func TestPageScroll_PropagatesScrollError(t *testing.T) {
128+ wantErr := errors.New("engine scroll failure")
129+ wv := newScrollableStub(wantErr)
130+ uc := NewPageScrollUseCase()
131+
132+ err := uc.Scroll(context.Background(), wv, PageScrollDown)
133+ if err == nil {
134+ t.Fatal("Scroll() expected error, got nil")
135+ }
136+ if !errors.Is(err, wantErr) {
137+ t.Fatalf("Scroll() error = %v, want %v", err, wantErr)
138+ }
139+}
140+
141+func TestPageScroll_TapDistancesRemainFixed(t *testing.T) {
142+ tests := []struct {
143+ name string
144+ cmd PageScrollCommand
145+ dx int
146+ dy int
147+ }{
148+ {"left 80 px", PageScrollLeft, -80, 0},
149+ {"right 80 px", PageScrollRight, 80, 0},
150+ {"up 80 px", PageScrollUp, 0, -80},
151+ {"down 80 px", PageScrollDown, 0, 80},
152+ {"up fast 320 px", PageScrollUpFast, 0, -320},
153+ {"down fast 320 px", PageScrollDownFast, 0, 320},
154+ }
155+ for _, tt := range tests {
156+ t.Run(tt.name, func(t *testing.T) {
157+ wv := newScrollableStub(nil)
158+ if err := NewPageScrollUseCase().Scroll(context.Background(), wv, tt.cmd); err != nil {
159+ t.Fatal(err)
160+ }
161+ if wv.lastRequest.FallbackDX != tt.dx || wv.lastRequest.FallbackDY != tt.dy {
162+ t.Fatalf("tap delta=(%d,%d), want (%d,%d)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY, tt.dx, tt.dy)
163+ }
164+ if wv.lastRequest.Continuous {
165+ t.Fatal("tap request must not be marked continuous")
166+ }
167+ })
168+ }
169+}
170+
171+func TestPageScroll_ScrollContinuousMarksRequest(t *testing.T) {
172+ wv := newScrollableStub(nil)
173+ if err := NewPageScrollUseCase().ScrollContinuous(context.Background(), wv, PageScrollDown); err != nil {
174+ t.Fatal(err)
175+ }
176+ if !wv.lastRequest.Continuous || wv.lastRequest.FallbackDY != 80 {
177+ t.Fatalf("continuous request = %+v, want continuous 80 px", wv.lastRequest)
178+ }
179+}
180+
181+func TestPageScroll_StopCancelsOptionalCapability(t *testing.T) {
182+ wv := &cancelableScrollableStub{scrollableStub: newScrollableStub(nil)}
183+ if err := NewPageScrollUseCase().Stop(context.Background(), wv); err != nil {
184+ t.Fatal(err)
185+ }
186+ if wv.cancelCalls != 1 {
187+ t.Fatalf("cancel calls=%d, want 1", wv.cancelCalls)
188+ }
189+ if err := NewPageScrollUseCase().Stop(context.Background(), newScrollableStub(nil)); err != nil {
190+ t.Fatalf("non-cancelable WebKit-style adapter should be a no-op: %v", err)
191+ }
192+}
193+
194+func TestPageScroll_DeltaMethod(t *testing.T) {
195+ tests := []struct {
196+ cmd PageScrollCommand
197+ wantDx int
198+ wantDy int
199+ wantName string
200+ }{
201+ {PageScrollLeft, -80, 0, "left"},
202+ {PageScrollRight, 80, 0, "right"},
203+ {PageScrollUp, 0, -80, "up"},
204+ {PageScrollDown, 0, 80, "down"},
205+ {PageScrollUpFast, 0, -320, "up-fast"},
206+ {PageScrollDownFast, 0, 320, "down-fast"},
207+ }
208+
209+ for _, tt := range tests {
210+ t.Run(tt.wantName, func(t *testing.T) {
211+ dx, dy := scrollDelta(tt.cmd)
212+ if dx != tt.wantDx || dy != tt.wantDy {
213+ t.Errorf("scrollDelta(%v) = (%d, %d), want (%d, %d)", tt.cmd, dx, dy, tt.wantDx, tt.wantDy)
214+ }
215+ if got := tt.cmd.String(); got != tt.wantName {
216+ t.Errorf("PageScrollCommand.String() = %q, want %q", got, tt.wantName)
217+ }
218+ })
219+ }
220+}
221+
222+func TestPageScroll_CommandIdentityPropagation(t *testing.T) {
223+ // Prove that PageScrollDownFast forwards command identity separately
224+ // from fallback delta (dy=320).
225+ wv := newScrollableStub(nil)
226+ uc := NewPageScrollUseCase()
227+
228+ err := uc.Scroll(context.Background(), wv, PageScrollDownFast)
229+ if err != nil {
230+ t.Fatalf("Scroll() unexpected error: %v", err)
231+ }
232+
233+ if wv.lastRequest.Command != port.PageScrollCommandDownFast {
234+ t.Fatalf("ScrollPage request.Command = %d, want %d (PageScrollDownFast)",
235+ wv.lastRequest.Command, port.PageScrollCommandDownFast)
236+ }
237+ if int(wv.lastRequest.Command) == wv.lastRequest.FallbackDY {
238+ t.Fatal("command identity must NOT be confused with fallback delta: Command and FallbackDY should differ")
239+ }
240+ if wv.lastRequest.FallbackDX != 0 || wv.lastRequest.FallbackDY != 320 {
241+ t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (0, 320)",
242+ wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY)
243+ }
244+}
245+
246+func TestPageScroll_UnknownCommand_ReturnsNoOp(t *testing.T) {
247+ dx, dy := scrollDelta(PageScrollCommand(999))
248+ if dx != 0 || dy != 0 {
249+ t.Errorf("scrollDelta(unknown) = (%d, %d), want (0, 0)", dx, dy)
250+ }
251+
252+ got := PageScrollCommand(999).String()
253+ if got != "unknown" {
254+ t.Errorf("PageScrollCommand(999).String() = %q, want %q", got, "unknown")
255+ }
256+}
257+
258+// --- test helpers ---
259+
260+// scrollableStub satisfies both port.WebView (via embedded nonScrollableStub)
261+// and port.PageScrollable (via ScrollPage).
262+type scrollableStub struct {
263+ *nonScrollableStub
264+ lastRequest port.PageScrollRequest
265+ scrollErr error
266+}
267+
268+type cancelableScrollableStub struct {
269+ *scrollableStub
270+ cancelCalls int
271+}
272+
273+func (s *cancelableScrollableStub) CancelPageScroll(context.Context) {
274+ s.cancelCalls++
275+}
276+
277+func newScrollableStub(scrollErr error) *scrollableStub {
278+ return &scrollableStub{
279+ nonScrollableStub: &nonScrollableStub{},
280+ scrollErr: scrollErr,
281+ }
282+}
283+
284+// ScrollPage implements port.PageScrollable.
285+func (s *scrollableStub) ScrollPage(_ context.Context, req port.PageScrollRequest) error {
286+ s.lastRequest = req
287+ return s.scrollErr
288+}
289+
290+// nonScrollableStub satisfies port.WebView but NOT port.PageScrollable.
291+type nonScrollableStub struct{}
292+
293+func (*nonScrollableStub) ID() port.WebViewID { return 0 }
294+func (*nonScrollableStub) LoadURI(_ context.Context, _ string) error { return nil }
295+func (*nonScrollableStub) LoadHTML(_ context.Context, _, _ string) error { return nil }
296+func (*nonScrollableStub) Reload(_ context.Context) error { return nil }
297+func (*nonScrollableStub) ReloadBypassCache(_ context.Context) error { return nil }
298+func (*nonScrollableStub) Stop(_ context.Context) error { return nil }
299+func (*nonScrollableStub) GoBack(_ context.Context) error { return nil }
300+func (*nonScrollableStub) GoForward(_ context.Context) error { return nil }
301+func (*nonScrollableStub) State() port.WebViewState { return port.WebViewState{} }
302+func (*nonScrollableStub) URI() string { return "" }
303+func (*nonScrollableStub) Title() string { return "" }
304+func (*nonScrollableStub) IsLoading() bool { return false }
305+func (*nonScrollableStub) EstimatedProgress() float64 { return 0 }
306+func (*nonScrollableStub) CanGoBack() bool { return false }
307+func (*nonScrollableStub) CanGoForward() bool { return false }
308+func (*nonScrollableStub) SetZoomLevel(_ context.Context, _ float64) error { return nil }
309+func (*nonScrollableStub) GetZoomLevel() float64 { return 1.0 }
310+func (*nonScrollableStub) GetFindController() port.FindController { return nil }
311+func (*nonScrollableStub) SetCallbacks(_ *port.WebViewCallbacks) {}
312+func (*nonScrollableStub) RunJavaScript(_ context.Context, _ string) {}
313+func (*nonScrollableStub) SetBackgroundColor(_, _, _, _ float64) {}
314+func (*nonScrollableStub) ResetBackgroundToDefault() {}
315+func (*nonScrollableStub) Favicon() port.Texture { return nil }
316+func (*nonScrollableStub) Generation() uint64 { return 0 }
317+func (*nonScrollableStub) IsFullscreen() bool { return false }
318+func (*nonScrollableStub) IsPlayingAudio() bool { return false }
319+func (*nonScrollableStub) IsDestroyed() bool { return false }
320+func (*nonScrollableStub) Destroy() {}
A internal/application/usecase/vim_mode_policy.go +72 -0
@@ -0,0 +1,72 @@
1+package usecase
2+
3+// VimModePolicyTransition describes what the UI should do after a Vim mode
4+// policy evaluation.
5+type VimModePolicyTransition string
6+
7+const (
8+ VimModePolicyTransitionStay VimModePolicyTransition = "stay"
9+ VimModePolicyTransitionExit VimModePolicyTransition = "exit"
10+)
11+
12+// VimModePolicyTrigger identifies the focus or activation event being
13+// evaluated.
14+type VimModePolicyTrigger string
15+
16+const (
17+ VimModePolicyTriggerActivationAttempt VimModePolicyTrigger = "activation_attempt"
18+ VimModePolicyTriggerOmniboxFocus VimModePolicyTrigger = "omnibox_focus"
19+ VimModePolicyTriggerFindBarFocus VimModePolicyTrigger = "find_bar_focus"
20+ VimModePolicyTriggerOverlayFocus VimModePolicyTrigger = "overlay_focus"
21+ VimModePolicyTriggerPageEditableFocusChanged VimModePolicyTrigger = "page_editable_focus_changed"
22+ VimModePolicyTriggerContextChanged VimModePolicyTrigger = "context_changed"
23+)
24+
25+// VimModePolicyInput captures the current Vim mode state and the event being
26+// evaluated.
27+type VimModePolicyInput struct {
28+ Trigger VimModePolicyTrigger
29+ VimModeActive bool
30+ PageEditableFocused bool
31+ EventInActiveContext bool
32+ PreserveOnContextChange bool
33+}
34+
35+// VimModePolicyUseCase evaluates whether Vim mode should stay active, exit,
36+// or be blocked from activating.
37+type VimModePolicyUseCase struct{}
38+
39+// NewVimModePolicyUseCase creates a VimModePolicyUseCase.
40+func NewVimModePolicyUseCase() *VimModePolicyUseCase {
41+ return &VimModePolicyUseCase{}
42+}
43+
44+// Evaluate decides the Vim mode transition for the provided event.
45+func (*VimModePolicyUseCase) Evaluate(input VimModePolicyInput) VimModePolicyTransition {
46+ switch input.Trigger {
47+ case VimModePolicyTriggerActivationAttempt:
48+ // Ctrl+Y is the explicit Vim Mode activation command. It must remain
49+ // available while a page input is focused so users can leave typing mode
50+ // without first moving focus elsewhere.
51+ return VimModePolicyTransitionStay
52+ case VimModePolicyTriggerOmniboxFocus,
53+ VimModePolicyTriggerFindBarFocus,
54+ VimModePolicyTriggerOverlayFocus:
55+ if input.VimModeActive {
56+ return VimModePolicyTransitionExit
57+ }
58+ return VimModePolicyTransitionStay
59+ case VimModePolicyTriggerPageEditableFocusChanged:
60+ if input.VimModeActive && input.PageEditableFocused && input.EventInActiveContext {
61+ return VimModePolicyTransitionExit
62+ }
63+ return VimModePolicyTransitionStay
64+ case VimModePolicyTriggerContextChanged:
65+ if input.VimModeActive && !input.PreserveOnContextChange {
66+ return VimModePolicyTransitionExit
67+ }
68+ return VimModePolicyTransitionStay
69+ default:
70+ return VimModePolicyTransitionStay
71+ }
72+}
A internal/application/usecase/vim_mode_policy_test.go +109 -0
@@ -0,0 +1,109 @@
1+package usecase
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestVimModePolicyEvaluate(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input VimModePolicyInput
13+ want VimModePolicyTransition
14+ }{
15+ {
16+ name: "activation stays allowed while page editable focused",
17+ input: VimModePolicyInput{
18+ Trigger: VimModePolicyTriggerActivationAttempt,
19+ PageEditableFocused: true,
20+ },
21+ want: VimModePolicyTransitionStay,
22+ },
23+ {
24+ name: "activation stays allowed when page is not editable",
25+ input: VimModePolicyInput{
26+ Trigger: VimModePolicyTriggerActivationAttempt,
27+ PageEditableFocused: false,
28+ },
29+ want: VimModePolicyTransitionStay,
30+ },
31+ {
32+ name: "active page editable focus exits vim mode",
33+ input: VimModePolicyInput{
34+ Trigger: VimModePolicyTriggerPageEditableFocusChanged,
35+ VimModeActive: true,
36+ PageEditableFocused: true,
37+ EventInActiveContext: true,
38+ },
39+ want: VimModePolicyTransitionExit,
40+ },
41+ {
42+ name: "background page editable focus does not exit vim mode",
43+ input: VimModePolicyInput{
44+ Trigger: VimModePolicyTriggerPageEditableFocusChanged,
45+ VimModeActive: true,
46+ PageEditableFocused: true,
47+ EventInActiveContext: false,
48+ },
49+ want: VimModePolicyTransitionStay,
50+ },
51+ {
52+ name: "overlay focus exits vim mode",
53+ input: VimModePolicyInput{
54+ Trigger: VimModePolicyTriggerOverlayFocus,
55+ VimModeActive: true,
56+ },
57+ want: VimModePolicyTransitionExit,
58+ },
59+ {
60+ name: "omnibox focus exits vim mode",
61+ input: VimModePolicyInput{
62+ Trigger: VimModePolicyTriggerOmniboxFocus,
63+ VimModeActive: true,
64+ },
65+ want: VimModePolicyTransitionExit,
66+ },
67+ {
68+ name: "find bar focus exits vim mode",
69+ input: VimModePolicyInput{
70+ Trigger: VimModePolicyTriggerFindBarFocus,
71+ VimModeActive: true,
72+ },
73+ want: VimModePolicyTransitionExit,
74+ },
75+ {
76+ name: "context change preserves vim mode when scoped context stays valid",
77+ input: VimModePolicyInput{
78+ Trigger: VimModePolicyTriggerContextChanged,
79+ VimModeActive: true,
80+ PreserveOnContextChange: true,
81+ },
82+ want: VimModePolicyTransitionStay,
83+ },
84+ {
85+ name: "context change exits vim mode when scoped context is lost",
86+ input: VimModePolicyInput{
87+ Trigger: VimModePolicyTriggerContextChanged,
88+ VimModeActive: true,
89+ PreserveOnContextChange: false,
90+ },
91+ want: VimModePolicyTransitionExit,
92+ },
93+ {
94+ name: "non vim mode focus change stays normal",
95+ input: VimModePolicyInput{
96+ Trigger: VimModePolicyTriggerOverlayFocus,
97+ VimModeActive: false,
98+ },
99+ want: VimModePolicyTransitionStay,
100+ },
101+ }
102+
103+ uc := NewVimModePolicyUseCase()
104+ for _, tt := range tests {
105+ t.Run(tt.name, func(t *testing.T) {
106+ assert.Equal(t, tt.want, uc.Evaluate(tt.input))
107+ })
108+ }
109+}
A internal/application/usecase/vim_navigation.go +93 -0
@@ -0,0 +1,93 @@
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 (
12+ errUnsupportedVimNavigationAction = errors.New("vim navigation: unsupported action")
13+ errUnsupportedVimNavigationEngine = errors.New("vim navigation: unsupported webview capability")
14+)
15+
16+// VimNavigationUseCase maps configured Vim Mode navigation actions to optional
17+// WebView capabilities. The UI layer supplies the active WebView and remains
18+// responsible only for event and theme context.
19+type VimNavigationUseCase struct{}
20+
21+// NewVimNavigationUseCase creates a stateless Vim navigation use case.
22+func NewVimNavigationUseCase() *VimNavigationUseCase {
23+ return &VimNavigationUseCase{}
24+}
25+
26+// Execute dispatches a configured semantic Vim action to the active WebView.
27+func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error {
28+ if wv == nil {
29+ return errors.New("vim navigation: nil webview")
30+ }
31+
32+ if action == "focus-input" {
33+ focuser, ok := wv.(port.PageInputFocuser)
34+ if !ok {
35+ return errUnsupportedVimNavigationEngine
36+ }
37+ focuser.FocusNextInput()
38+ return nil
39+ }
40+
41+ var request dto.SemanticNavigationRequest
42+ switch action {
43+ case "heading-next":
44+ request = dto.SemanticNavigationRequest{
45+ Target: dto.SemanticNavigationTargetHeading,
46+ Direction: dto.SemanticNavigationForward,
47+ Count: count,
48+ HighlightColor: highlightColor,
49+ }
50+ case "heading-prev":
51+ request = dto.SemanticNavigationRequest{
52+ Target: dto.SemanticNavigationTargetHeading,
53+ Direction: dto.SemanticNavigationBackward,
54+ Count: count,
55+ HighlightColor: highlightColor,
56+ }
57+ default:
58+ return errUnsupportedVimNavigationAction
59+ }
60+
61+ navigator, ok := wv.(port.SemanticNavigable)
62+ if !ok {
63+ return errUnsupportedVimNavigationEngine
64+ }
65+ return navigator.NavigateSemantic(ctx, request)
66+}
67+
68+// ClearSemanticNavigationHighlight removes the visual target left by semantic
69+// navigation when the active Vim Mode session ends.
70+func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error {
71+ if wv == nil {
72+ return nil
73+ }
74+ clearer, ok := wv.(port.SemanticNavigationHighlightClearer)
75+ if !ok {
76+ return nil
77+ }
78+ return clearer.ClearSemanticNavigationHighlight(ctx)
79+}
80+
81+// NavigatePageFocus keeps focus traversal inside a page when the active page
82+// control would otherwise hand the host window a Tab event.
83+func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool {
84+ if wv == nil {
85+ return false
86+ }
87+ navigator, ok := wv.(port.PageFocusNavigator)
88+ if !ok {
89+ return false
90+ }
91+ navigator.NavigatePageFocus(backward)
92+ return true
93+}
A internal/application/usecase/vim_navigation_test.go +77 -0
@@ -0,0 +1,77 @@
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+ clearNavigationHighlightCalls int
19+}
20+
21+func (wv *vimNavigationWebView) FocusNextInput() {
22+ wv.focusNextInputCalls++
23+}
24+
25+func (wv *vimNavigationWebView) NavigatePageFocus(backward bool) {
26+ wv.pageFocusBackward = append(wv.pageFocusBackward, backward)
27+}
28+
29+func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto.SemanticNavigationRequest) error {
30+ wv.semanticRequests = append(wv.semanticRequests, request)
31+ return nil
32+}
33+
34+func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error {
35+ wv.clearNavigationHighlightCalls++
36+ return nil
37+}
38+
39+func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
40+ wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
41+ uc := NewVimNavigationUseCase()
42+
43+ require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, ""))
44+ require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc"))
45+
46+ assert.Equal(t, 1, wv.focusNextInputCalls)
47+ require.Len(t, wv.semanticRequests, 1)
48+ assert.Equal(t, dto.SemanticNavigationRequest{
49+ Target: dto.SemanticNavigationTargetHeading,
50+ Direction: dto.SemanticNavigationBackward,
51+ Count: 3,
52+ HighlightColor: "#aabbcc",
53+ }, wv.semanticRequests[0])
54+}
55+
56+func TestVimNavigationUseCaseClearSemanticNavigationHighlight(t *testing.T) {
57+ wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
58+
59+ require.NoError(t, NewVimNavigationUseCase().ClearSemanticNavigationHighlight(context.Background(), wv))
60+ assert.Equal(t, 1, wv.clearNavigationHighlightCalls)
61+}
62+
63+func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) {
64+ wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
65+
66+ handled := NewVimNavigationUseCase().NavigatePageFocus(wv, true)
67+
68+ assert.True(t, handled)
69+ assert.Equal(t, []bool{true}, wv.pageFocusBackward)
70+}
71+
72+func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) {
73+ wv := portmocks.NewMockWebView(t)
74+
75+ require.ErrorIs(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, ""), errUnsupportedVimNavigationEngine)
76+ assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true))
77+}
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+}
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+}
M internal/bootstrap/runtime_config.go +1 -0
@@ -140,6 +140,7 @@ func cloneRuntimeSearchShortcuts(in map[string]entity.RuntimeSearchShortcut) map
140140 func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig {
141141 in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions)
142142 in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions)
143+ in.VimMode.Actions = cloneActionBindings(in.VimMode.Actions)
143144 in.ResizeMode.Actions = cloneActionBindings(in.ResizeMode.Actions)
144145 in.Shortcuts.Actions = cloneActionBindings(in.Shortcuts.Actions)
145146 in.FloatingPane.Profiles = cloneFloatingPaneProfiles(in.FloatingPane.Profiles)
M internal/bootstrap/runtime_config_test.go +12 -0
@@ -258,6 +258,9 @@ func runtimeConfigWithNestedMutableFields() *config.Config {
258258 cfg.Workspace.TabMode.Actions = map[string]entity.ActionBinding{
259259 "next-tab": {Keys: []string{"l"}, Desc: "Next tab"},
260260 }
261+ cfg.Workspace.VimMode.Actions = map[string]entity.ActionBinding{
262+ "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll down"},
263+ }
261264 cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{
262265 "grow-right": {Keys: []string{"right"}, Desc: "Grow right"},
263266 }
@@ -280,6 +283,9 @@ func mutateNestedRuntimeConfigSnapshot(snapshot entity.RuntimeConfigSnapshot) {
280283 mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab")
281284 snapshot.UI.Workspace.TabMode.Actions["added-tab"] = entity.ActionBinding{Keys: []string{"added"}}
282285
286+ mutateActionBinding(snapshot.UI.Workspace.VimMode.Actions, "vim-scroll-down", "mutated-page")
287+ snapshot.UI.Workspace.VimMode.Actions["added-page"] = entity.ActionBinding{Keys: []string{"added"}}
288+
283289 mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize")
284290 snapshot.UI.Workspace.ResizeMode.Actions["added-resize"] = entity.ActionBinding{Keys: []string{"added"}}
285291
@@ -310,6 +316,9 @@ func assertNestedRuntimeConfigSnapshotUnchanged(t *testing.T, snapshot entity.Ru
310316 assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l")
311317 assertMapEntryAbsent(t, snapshot.UI.Workspace.TabMode.Actions, "added-tab")
312318
319+ assertActionBinding(t, snapshot.UI.Workspace.VimMode.Actions, "vim-scroll-down", "j")
320+ assertMapEntryAbsent(t, snapshot.UI.Workspace.VimMode.Actions, "added-page")
321+
313322 assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right")
314323 assertMapEntryAbsent(t, snapshot.UI.Workspace.ResizeMode.Actions, "added-resize")
315324
@@ -357,6 +366,9 @@ func assertNestedSourceConfigUnchanged(t *testing.T, cfg *config.Config) {
357366 assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l")
358367 assertMapEntryAbsent(t, cfg.Workspace.TabMode.Actions, "added-tab")
359368
369+ assertActionBinding(t, cfg.Workspace.VimMode.Actions, "vim-scroll-down", "j")
370+ assertMapEntryAbsent(t, cfg.Workspace.VimMode.Actions, "added-page")
371+
360372 assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right")
361373 assertMapEntryAbsent(t, cfg.Workspace.ResizeMode.Actions, "added-resize")
362374
M internal/domain/entity/config_types.go +48 -6
@@ -78,6 +78,20 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
7878 return keyBindingsFromActions(t.Actions)
7979 }
8080
81+// VimModeConfig holds vim scroll mode shortcut configuration.
82+type VimModeConfig struct {
83+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
84+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
85+ SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line
86+ PreloadAccessibility bool `mapstructure:"preload_accessibility" yaml:"preload_accessibility" toml:"preload_accessibility" json:"preload_accessibility"` //nolint:lll // struct tags must stay on one line
87+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
88+}
89+
90+// GetKeyBindings returns a map from key string to action name.
91+func (p *VimModeConfig) GetKeyBindings() map[string]string {
92+ return keyBindingsFromActions(p.Actions)
93+}
94+
8195 // ResizeModeConfig holds resize mode shortcut configuration.
8296 type ResizeModeConfig struct {
8397 ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
@@ -197,14 +211,42 @@ type BrowsingContextConfig struct {
197211 // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
198212 type PopupBehaviorConfig = BrowsingContextConfig
199213
214+// ExternalLinkBehavior defines how externally opened URLs are placed in the workspace.
215+type ExternalLinkBehavior string
216+
217+const (
218+ ExternalLinkBehaviorWindowed ExternalLinkBehavior = "windowed"
219+ ExternalLinkBehaviorTabbed ExternalLinkBehavior = "tabbed"
220+ ExternalLinkBehaviorSplit ExternalLinkBehavior = "split"
221+ ExternalLinkBehaviorStacked ExternalLinkBehavior = "stacked"
222+)
223+
224+// ExternalLinkPlacement defines the direction used when splitting for an external URL.
225+type ExternalLinkPlacement string
226+
227+const (
228+ ExternalLinkPlacementRight ExternalLinkPlacement = "right"
229+ ExternalLinkPlacementLeft ExternalLinkPlacement = "left"
230+ ExternalLinkPlacementTop ExternalLinkPlacement = "top"
231+ ExternalLinkPlacementBottom ExternalLinkPlacement = "bottom"
232+)
233+
234+// ExternalLinksConfig controls how externally opened URLs are placed in the workspace.
235+type ExternalLinksConfig struct {
236+ Behavior ExternalLinkBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
237+ Placement ExternalLinkPlacement `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
238+}
239+
200240 // WorkspaceConfig holds all workspace layout and behavior settings.
201241 type WorkspaceConfig struct {
202- NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
203- PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
204- TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
205- ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
206- Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
207- FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
242+ NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
243+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
244+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
245+ VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"`
246+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
247+ Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
248+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
249+ ExternalLinks ExternalLinksConfig `mapstructure:"external_links" yaml:"external_links" toml:"external_links" json:"external_links"`
208250
209251 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
210252 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
A internal/domain/vimkeys/key.go +169 -0
@@ -0,0 +1,169 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "strings"
6+)
7+
8+// Mods holds keyboard modifier flags for a parsed key.
9+type Mods uint8
10+
11+const (
12+ ModCtrl Mods = 1 << iota
13+ ModShift
14+ ModAlt
15+)
16+
17+const (
18+ symCR = "CR"
19+ symEsc = "Esc"
20+)
21+
22+// Key is a single key event with symbol and modifiers.
23+type Key struct {
24+ Sym string
25+ Mods Mods
26+}
27+
28+// Sequence is an ordered list of keys forming a Vim-style binding.
29+type Sequence []Key
30+
31+var (
32+ ErrEmptyBinding = errors.New("vimkeys: empty binding")
33+ ErrBadBinding = errors.New("vimkeys: malformed binding")
34+)
35+
36+// String renders a key in canonical form.
37+func (k Key) String() string {
38+ if k.Sym == "" {
39+ return ""
40+ }
41+
42+ if k.Mods == ModShift && len(k.Sym) == 1 {
43+ if c := k.Sym[0]; c >= 'a' && c <= 'z' {
44+ return strings.ToUpper(k.Sym)
45+ }
46+ }
47+
48+ if k.Mods == 0 {
49+ if k.Sym == "+" {
50+ return "<Plus>"
51+ }
52+ if alias := angleAliasForSym(k.Sym); alias != "" {
53+ return "<" + alias + ">"
54+ }
55+ return k.Sym
56+ }
57+
58+ return formatAngleKey(k)
59+}
60+
61+// String renders the full binding sequence in canonical form.
62+// When the naive concatenation would case-insensitively equal an exact legacy
63+// atom (enter/return/escape/esc), the first key is forced to an angle literal
64+// so ParseBinding cannot reabsorb the sequence as that atom.
65+func (s Sequence) String() string {
66+ rendered := s.render(false)
67+ if collidesWithLegacyAtom(rendered) {
68+ return s.render(true)
69+ }
70+ return rendered
71+}
72+
73+func (s Sequence) render(forceAngleFirst bool) string {
74+ var b strings.Builder
75+ for i, key := range s {
76+ if forceAngleFirst && i == 0 {
77+ b.WriteString(key.angleLiteralString())
78+ continue
79+ }
80+ b.WriteString(key.String())
81+ }
82+ return b.String()
83+}
84+
85+func collidesWithLegacyAtom(s string) bool {
86+ switch strings.ToLower(s) {
87+ case "enter", "return", "escape", "esc":
88+ return true
89+ default:
90+ return false
91+ }
92+}
93+
94+// angleLiteralString forces angle-bracket form for keys that Key.String would
95+// otherwise emit as bare or shifted letters (e.g. "e" / "E").
96+func (k Key) angleLiteralString() string {
97+ if len(k.Sym) == 1 {
98+ c := k.Sym[0]
99+ if c >= 'a' && c <= 'z' {
100+ switch k.Mods {
101+ case 0:
102+ return "<" + k.Sym + ">"
103+ case ModShift:
104+ return formatAngleKey(k)
105+ }
106+ }
107+ }
108+ return k.String()
109+}
110+
111+// Equal reports whether two sequences contain the same keys.
112+func (s Sequence) Equal(other Sequence) bool {
113+ if len(s) != len(other) {
114+ return false
115+ }
116+ for i := range s {
117+ if s[i] != other[i] {
118+ return false
119+ }
120+ }
121+ return true
122+}
123+
124+func angleAliasForSym(sym string) string {
125+ switch sym {
126+ case symCR:
127+ return "Return"
128+ case symEsc:
129+ return "Escape"
130+ case "Space", "Tab", "BackSpace", "Delete",
131+ "Left", "Right", "Up", "Down",
132+ "Home", "End", "PageUp", "PageDown":
133+ return sym
134+ case "<":
135+ return "lt"
136+ case ">":
137+ return "gt"
138+ default:
139+ return ""
140+ }
141+}
142+
143+func formatAngleKey(k Key) string {
144+ var b strings.Builder
145+ b.WriteByte('<')
146+ if k.Mods&ModCtrl != 0 {
147+ b.WriteByte('C')
148+ }
149+ if k.Mods&ModShift != 0 {
150+ if b.Len() > 1 {
151+ b.WriteByte('-')
152+ }
153+ b.WriteByte('S')
154+ }
155+ if k.Mods&ModAlt != 0 {
156+ if b.Len() > 1 {
157+ b.WriteByte('-')
158+ }
159+ b.WriteByte('A')
160+ }
161+ b.WriteByte('-')
162+ if alias := angleAliasForSym(k.Sym); alias != "" {
163+ b.WriteString(alias)
164+ } else {
165+ b.WriteString(k.Sym)
166+ }
167+ b.WriteByte('>')
168+ return b.String()
169+}
A internal/domain/vimkeys/key_test.go +102 -0
@@ -0,0 +1,102 @@
1+package vimkeys
2+
3+import (
4+ "testing"
5+)
6+
7+func TestKeyString(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ key Key
11+ want string
12+ }{
13+ {name: "letter", key: Key{Sym: "j"}, want: "j"},
14+ {name: "shift letter", key: Key{Sym: "j", Mods: ModShift}, want: "J"},
15+ {name: "ctrl letter", key: Key{Sym: "d", Mods: ModCtrl}, want: "<C-d>"},
16+ {name: "ctrl plus", key: Key{Sym: "+", Mods: ModCtrl}, want: "<C-+>"},
17+ {name: "bare plus", key: Key{Sym: "+"}, want: "<Plus>"},
18+ {name: "shift glyph", key: Key{Sym: "!"}, want: "!"},
19+ {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},
20+ {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
21+ {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},
22+ {name: "special Tab", key: Key{Sym: "Tab"}, want: "<Tab>"},
23+ {name: "special BackSpace", key: Key{Sym: "BackSpace"}, want: "<BackSpace>"},
24+ {name: "special Delete", key: Key{Sym: "Delete"}, want: "<Delete>"},
25+ {name: "special Left", key: Key{Sym: "Left"}, want: "<Left>"},
26+ {name: "special Right", key: Key{Sym: "Right"}, want: "<Right>"},
27+ {name: "special Up", key: Key{Sym: "Up"}, want: "<Up>"},
28+ {name: "special Down", key: Key{Sym: "Down"}, want: "<Down>"},
29+ {name: "special Home", key: Key{Sym: "Home"}, want: "<Home>"},
30+ {name: "special End", key: Key{Sym: "End"}, want: "<End>"},
31+ {name: "special PageUp", key: Key{Sym: "PageUp"}, want: "<PageUp>"},
32+ {name: "special PageDown", key: Key{Sym: "PageDown"}, want: "<PageDown>"},
33+ {name: "ctrl Tab", key: Key{Sym: "Tab", Mods: ModCtrl}, want: "<C-Tab>"},
34+ {name: "shift BackSpace", key: Key{Sym: "BackSpace", Mods: ModShift}, want: "<S-BackSpace>"},
35+ {name: "alt Delete", key: Key{Sym: "Delete", Mods: ModAlt}, want: "<A-Delete>"},
36+ {name: "ctrl Left", key: Key{Sym: "Left", Mods: ModCtrl}, want: "<C-Left>"},
37+ {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
38+ {name: "literal gt", key: Key{Sym: ">"}, want: "<gt>"},
39+ {name: "ctrl gt", key: Key{Sym: ">", Mods: ModCtrl}, want: "<C-gt>"},
40+ {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},
41+ {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},
42+ {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},
43+ }
44+ for _, tt := range tests {
45+ t.Run(tt.name, func(t *testing.T) {
46+ if got := tt.key.String(); got != tt.want {
47+ t.Errorf("Key.String() = %q, want %q", got, tt.want)
48+ }
49+ })
50+ }
51+}
52+
53+func TestSequenceString(t *testing.T) {
54+ tests := []struct {
55+ name string
56+ seq Sequence
57+ want string
58+ }{
59+ {name: "yah", seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, want: "yah"},
60+ {name: "double bracket", seq: Sequence{{Sym: "]"}, {Sym: "]"}}, want: "]]"},
61+ {name: "gO", seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, want: "gO"},
62+ {name: "esc collision", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}}, want: "<e>sc"},
63+ {name: "enter collision", seq: Sequence{{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}, want: "<e>nter"},
64+ }
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ if got := tt.seq.String(); got != tt.want {
68+ t.Errorf("Sequence.String() = %q, want %q", got, tt.want)
69+ }
70+ })
71+ }
72+}
73+
74+func TestSequenceEqual(t *testing.T) {
75+ a := Sequence{{Sym: "j"}, {Sym: "k"}}
76+ b := Sequence{{Sym: "j"}, {Sym: "k"}}
77+ c := Sequence{{Sym: "j"}}
78+ if !a.Equal(b) {
79+ t.Fatal("equal sequences reported unequal")
80+ }
81+ if a.Equal(c) {
82+ t.Fatal("unequal length sequences reported equal")
83+ }
84+ if a.Equal(Sequence{{Sym: "j"}, {Sym: "x"}}) {
85+ t.Fatal("different keys reported equal")
86+ }
87+}
88+
89+func TestKeyStringEmpty(t *testing.T) {
90+ if got := (Key{}).String(); got != "" {
91+ t.Fatalf("empty Key.String() = %q, want empty", got)
92+ }
93+}
94+
95+func TestErrorSentinels(t *testing.T) {
96+ if ErrEmptyBinding.Error() != "vimkeys: empty binding" {
97+ t.Errorf("ErrEmptyBinding = %q, want %q", ErrEmptyBinding.Error(), "vimkeys: empty binding")
98+ }
99+ if ErrBadBinding.Error() != "vimkeys: malformed binding" {
100+ t.Errorf("ErrBadBinding = %q, want %q", ErrBadBinding.Error(), "vimkeys: malformed binding")
101+ }
102+}
A internal/domain/vimkeys/matcher.go +197 -0
@@ -0,0 +1,197 @@
1+package vimkeys
2+
3+import "strconv"
4+
5+// ResultKind classifies matcher feed outcomes.
6+type ResultKind int
7+
8+const (
9+ ResultPending ResultKind = iota
10+ ResultComplete
11+ ResultInvalid
12+)
13+
14+// Result is the outcome of feeding a key into the matcher.
15+type Result struct {
16+ Kind ResultKind
17+ Count int
18+ Action string
19+ Pending string
20+}
21+
22+// Matcher incrementally matches key sequences against a trie.
23+type Matcher struct {
24+ trie *Trie
25+ node *trieNode
26+ pending Sequence
27+ count int
28+ inCount bool
29+ ambiguous bool
30+}
31+
32+// NewMatcher creates a matcher backed by trie.
33+func NewMatcher(trie *Trie) *Matcher {
34+ return &Matcher{trie: trie}
35+}
36+
37+// Reset clears matcher state.
38+func (m *Matcher) Reset() {
39+ if m == nil {
40+ return
41+ }
42+ m.node = nil
43+ m.pending = nil
44+ m.count = 0
45+ m.inCount = false
46+ m.ambiguous = false
47+}
48+
49+// Pending returns the count prefix and keys matched so far.
50+func (m *Matcher) Pending() string {
51+ if !m.hasTrie() {
52+ return ""
53+ }
54+ return formatPending(m.count, m.pending)
55+}
56+
57+// Ambiguous reports whether the current prefix is both exact and extendable.
58+func (m *Matcher) Ambiguous() bool {
59+ return m.hasTrie() && m.ambiguous
60+}
61+
62+// Feed consumes one key and returns the current match state.
63+func (m *Matcher) Feed(key Key) Result {
64+ if !m.hasTrie() {
65+ m.Reset()
66+ return Result{Kind: ResultInvalid}
67+ }
68+
69+ if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount {
70+ m.beginSequence()
71+ }
72+
73+ if m.inCount {
74+ return m.feedCountDigit(key)
75+ }
76+
77+ if len(m.pending) == 0 && isCountStartDigit(key) {
78+ m.inCount = true
79+ return m.feedCountDigit(key)
80+ }
81+
82+ return m.feedSequenceKey(key)
83+}
84+
85+func (m *Matcher) hasTrie() bool {
86+ return m != nil && m.trie != nil && m.trie.root != nil
87+}
88+
89+func (m *Matcher) beginSequence() {
90+ if !m.hasTrie() {
91+ m.Reset()
92+ return
93+ }
94+ m.node = m.trie.root
95+ m.pending = nil
96+ m.count = 0
97+ m.inCount = false
98+ m.ambiguous = false
99+}
100+
101+func (m *Matcher) feedCountDigit(key Key) Result {
102+ if !isCountDigit(key) {
103+ m.inCount = false
104+ m.node = m.trie.root
105+ return m.feedSequenceKey(key)
106+ }
107+
108+ digit := int(key.Sym[0] - '0')
109+ next := m.count*10 + digit
110+ if next > maxCount {
111+ next = maxCount
112+ }
113+ m.count = next
114+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
115+}
116+
117+func (m *Matcher) feedSequenceKey(key Key) Result {
118+ if m.node == nil {
119+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
120+ m.Reset()
121+ return result
122+ }
123+
124+ child, ok := m.node.children[key]
125+ if !ok {
126+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
127+ m.Reset()
128+ return result
129+ }
130+
131+ m.pending = append(m.pending, key)
132+ m.node = child
133+
134+ exact := m.node.hasAction
135+ children := len(m.node.children) > 0
136+ m.ambiguous = exact && children
137+
138+ switch {
139+ case m.ambiguous:
140+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
141+ case exact:
142+ result := Result{
143+ Kind: ResultComplete,
144+ Count: normalizedCount(m.count),
145+ Action: m.node.action,
146+ }
147+ m.Reset()
148+ return result
149+ case children:
150+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
151+ default:
152+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
153+ m.Reset()
154+ return result
155+ }
156+}
157+
158+// ResolveAmbiguity completes the current short exact binding.
159+func (m *Matcher) ResolveAmbiguity() (Result, bool) {
160+ if !m.hasTrie() || !m.ambiguous || m.node == nil || !m.node.hasAction {
161+ return Result{}, false
162+ }
163+
164+ result := Result{
165+ Kind: ResultComplete,
166+ Count: normalizedCount(m.count),
167+ Action: m.node.action,
168+ }
169+ m.Reset()
170+ return result, true
171+}
172+
173+func isCountStartDigit(key Key) bool {
174+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
175+}
176+
177+func isCountDigit(key Key) bool {
178+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '0' && key.Sym[0] <= '9'
179+}
180+
181+func normalizedCount(count int) int {
182+ if count <= 0 {
183+ return 0
184+ }
185+ return count
186+}
187+
188+func formatPending(count int, pending Sequence) string {
189+ var out string
190+ if count > 0 {
191+ out = strconv.Itoa(count)
192+ }
193+ if len(pending) > 0 {
194+ out += pending.String()
195+ }
196+ return out
197+}
A internal/domain/vimkeys/matcher_test.go +406 -0
@@ -0,0 +1,406 @@
1+package vimkeys
2+
3+import (
4+ "testing"
5+)
6+
7+func testTrie(t *testing.T) *Trie {
8+ t.Helper()
9+ trie := NewTrie()
10+ bindings := []struct {
11+ seq Sequence
12+ action string
13+ }{
14+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
15+ {seq: Sequence{{Sym: "]"}, {Sym: "c"}}, action: "bracket-c"},
16+ {seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
17+ {seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
18+ {seq: Sequence{{Sym: "]"}}, action: "single-bracket"},
19+ {seq: Sequence{{Sym: "j"}}, action: "down"},
20+ {seq: Sequence{{Sym: "0"}}, action: "zero"},
21+ }
22+ for _, binding := range bindings {
23+ if err := trie.Insert(binding.seq, binding.action); err != nil {
24+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
25+ }
26+ }
27+ return trie
28+}
29+
30+func TestMatcher_FeedPrefixesPending(t *testing.T) {
31+ m := NewMatcher(testTrie(t))
32+
33+ cases := []struct {
34+ name string
35+ keys []Key
36+ want ResultKind
37+ pending string
38+ }{
39+ {
40+ name: "]] first bracket",
41+ keys: []Key{{Sym: "]"}},
42+ want: ResultPending,
43+ pending: "]",
44+ },
45+ {
46+ name: "gO prefix g",
47+ keys: []Key{{Sym: "g"}},
48+ want: ResultPending,
49+ pending: "g",
50+ },
51+ {
52+ name: "yah prefix ya",
53+ keys: []Key{{Sym: "y"}, {Sym: "a"}},
54+ want: ResultPending,
55+ pending: "ya",
56+ },
57+ }
58+ for _, tt := range cases {
59+ t.Run(tt.name, func(t *testing.T) {
60+ m.Reset()
61+ var result Result
62+ for _, key := range tt.keys {
63+ result = m.Feed(key)
64+ }
65+ if result.Kind != tt.want {
66+ t.Fatalf("Feed kind = %v, want %v", result.Kind, tt.want)
67+ }
68+ if result.Pending != tt.pending {
69+ t.Fatalf("Pending = %q, want %q", result.Pending, tt.pending)
70+ }
71+ if m.Pending() != tt.pending {
72+ t.Fatalf("Matcher.Pending() = %q, want %q", m.Pending(), tt.pending)
73+ }
74+ if result.Count != 0 {
75+ t.Fatalf("Count = %d, want 0 (absent)", result.Count)
76+ }
77+ })
78+ }
79+}
80+
81+func TestMatcher_FeedComplete(t *testing.T) {
82+ m := NewMatcher(testTrie(t))
83+
84+ cases := []struct {
85+ name string
86+ keys []Key
87+ action string
88+ }{
89+ {name: "]]", keys: []Key{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
90+ {name: "gO", keys: []Key{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
91+ {name: "yah", keys: []Key{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
92+ }
93+ for _, tt := range cases {
94+ t.Run(tt.name, func(t *testing.T) {
95+ m.Reset()
96+ var result Result
97+ for _, key := range tt.keys {
98+ result = m.Feed(key)
99+ }
100+ if result.Kind != ResultComplete {
101+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
102+ }
103+ if result.Action != tt.action {
104+ t.Fatalf("Action = %q, want %q", result.Action, tt.action)
105+ }
106+ if result.Count != 0 {
107+ t.Fatalf("Count = %d, want 0", result.Count)
108+ }
109+ })
110+ }
111+}
112+
113+func TestMatcher_Counts(t *testing.T) {
114+ m := NewMatcher(testTrie(t))
115+
116+ cases := []struct {
117+ name string
118+ keys []Key
119+ count int
120+ action string
121+ }{
122+ {
123+ name: "2]]",
124+ keys: []Key{{Sym: "2"}, {Sym: "]"}, {Sym: "]"}},
125+ count: 2,
126+ action: "double-bracket",
127+ },
128+ {
129+ name: "3j",
130+ keys: []Key{{Sym: "3"}, {Sym: "j"}},
131+ count: 3,
132+ action: "down",
133+ },
134+ {
135+ name: "10j",
136+ keys: []Key{{Sym: "1"}, {Sym: "0"}, {Sym: "j"}},
137+ count: 10,
138+ action: "down",
139+ },
140+ {
141+ name: "0 binding",
142+ keys: []Key{{Sym: "0"}},
143+ count: 0,
144+ action: "zero",
145+ },
146+ }
147+ for _, tt := range cases {
148+ t.Run(tt.name, func(t *testing.T) {
149+ m.Reset()
150+ var result Result
151+ for _, key := range tt.keys {
152+ result = m.Feed(key)
153+ }
154+ if result.Kind != ResultComplete {
155+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
156+ }
157+ if result.Count != tt.count {
158+ t.Fatalf("Count = %d, want %d", result.Count, tt.count)
159+ }
160+ if result.Action != tt.action {
161+ t.Fatalf("Action = %q, want %q", result.Action, tt.action)
162+ }
163+ })
164+ }
165+}
166+
167+func TestMatcher_ZeroThenCount(t *testing.T) {
168+ m := NewMatcher(testTrie(t))
169+
170+ zero := m.Feed(Key{Sym: "0"})
171+ if zero.Kind != ResultComplete || zero.Action != "zero" || zero.Count != 0 {
172+ t.Fatalf("first zero result = %#v, want complete zero binding", zero)
173+ }
174+
175+ count := m.Feed(Key{Sym: "3"})
176+ if count.Kind != ResultPending || count.Pending != "3" || count.Count != 3 {
177+ t.Fatalf("count prefix result = %#v, want pending count 3", count)
178+ }
179+
180+ complete := m.Feed(Key{Sym: "j"})
181+ if complete.Kind != ResultComplete || complete.Action != "down" || complete.Count != 3 {
182+ t.Fatalf("counted action result = %#v, want complete down count 3", complete)
183+ }
184+}
185+
186+func TestMatcher_CountPendingString(t *testing.T) {
187+ m := NewMatcher(testTrie(t))
188+ m.Reset()
189+ result := m.Feed(Key{Sym: "3"})
190+ if result.Kind != ResultPending {
191+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultPending)
192+ }
193+ if result.Pending != "3" {
194+ t.Fatalf("Pending = %q, want %q", result.Pending, "3")
195+ }
196+ if m.Pending() != "3" {
197+ t.Fatalf("Matcher.Pending() = %q, want %q", m.Pending(), "3")
198+ }
199+}
200+
201+func TestMatcher_CountCap9999(t *testing.T) {
202+ m := NewMatcher(testTrie(t))
203+ digits := make([]Key, 0, 5)
204+ for _, ch := range "10000" {
205+ digits = append(digits, Key{Sym: string(ch)})
206+ }
207+ digits = append(digits, Key{Sym: "j"})
208+
209+ m.Reset()
210+ var result Result
211+ for _, key := range digits {
212+ result = m.Feed(key)
213+ }
214+ if result.Kind != ResultComplete {
215+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
216+ }
217+ if result.Count != 9999 {
218+ t.Fatalf("Count = %d, want 9999", result.Count)
219+ }
220+}
221+
222+func TestMatcher_AutomaticReset(t *testing.T) {
223+ m := NewMatcher(testTrie(t))
224+
225+ m.Reset()
226+ complete := m.Feed(Key{Sym: "j"})
227+ if complete.Kind != ResultComplete {
228+ t.Fatalf("first Feed kind = %v, want %v", complete.Kind, ResultComplete)
229+ }
230+
231+ next := m.Feed(Key{Sym: "g"})
232+ if next.Kind != ResultPending {
233+ t.Fatalf("after complete Feed kind = %v, want %v", next.Kind, ResultPending)
234+ }
235+ if m.Pending() != "g" {
236+ t.Fatalf("Pending after reset = %q, want g", m.Pending())
237+ }
238+
239+ m.Reset()
240+ invalid := m.Feed(Key{Sym: "x"})
241+ if invalid.Kind != ResultInvalid {
242+ t.Fatalf("invalid Feed kind = %v, want %v", invalid.Kind, ResultInvalid)
243+ }
244+
245+ afterInvalid := m.Feed(Key{Sym: "j"})
246+ if afterInvalid.Kind != ResultComplete {
247+ t.Fatalf("after invalid Feed kind = %v, want %v", afterInvalid.Kind, ResultComplete)
248+ }
249+}
250+
251+func TestMatcher_AmbiguityClearsOnTransition(t *testing.T) {
252+ trie := NewTrie()
253+ for _, binding := range []struct {
254+ seq Sequence
255+ action string
256+ }{
257+ {seq: Sequence{{Sym: "]"}}, action: "single-bracket"},
258+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}, {Sym: "x"}}, action: "triple-bracket"},
259+ } {
260+ if err := trie.Insert(binding.seq, binding.action); err != nil {
261+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
262+ }
263+ }
264+
265+ m := NewMatcher(trie)
266+ m.Reset()
267+
268+ result := m.Feed(Key{Sym: "]"})
269+ if result.Kind != ResultPending {
270+ t.Fatalf("after ] Feed kind = %v, want %v", result.Kind, ResultPending)
271+ }
272+ if !m.Ambiguous() {
273+ t.Fatal("Ambiguous() after ] = false, want true")
274+ }
275+
276+ result = m.Feed(Key{Sym: "]"})
277+ if result.Kind != ResultPending {
278+ t.Fatalf("after ]] Feed kind = %v, want %v", result.Kind, ResultPending)
279+ }
280+ if m.Ambiguous() {
281+ t.Fatal("Ambiguous() after ]] = true, want false")
282+ }
283+}
284+
285+func TestMatcher_AmbiguousShortLong(t *testing.T) {
286+ m := NewMatcher(testTrie(t))
287+
288+ m.Reset()
289+ result := m.Feed(Key{Sym: "]"})
290+ if result.Kind != ResultPending {
291+ t.Fatalf("Feed ] kind = %v, want %v", result.Kind, ResultPending)
292+ }
293+ if !m.Ambiguous() {
294+ t.Fatal("Ambiguous() = false, want true after ]")
295+ }
296+
297+ short, ok := m.ResolveAmbiguity()
298+ if !ok {
299+ t.Fatal("ResolveAmbiguity() ok = false, want true")
300+ }
301+ if short.Kind != ResultComplete {
302+ t.Fatalf("ResolveAmbiguity() kind = %v, want %v", short.Kind, ResultComplete)
303+ }
304+ if short.Action != "single-bracket" {
305+ t.Fatalf("short action = %q, want %q", short.Action, "single-bracket")
306+ }
307+
308+ m.Reset()
309+ _ = m.Feed(Key{Sym: "]"})
310+ finish := m.Feed(Key{Sym: "]"})
311+ if finish.Kind != ResultComplete {
312+ t.Fatalf("finish ]] kind = %v, want %v", finish.Kind, ResultComplete)
313+ }
314+ if finish.Action != "double-bracket" {
315+ t.Fatalf("long action = %q, want %q", finish.Action, "double-bracket")
316+ }
317+}
318+
319+func TestMatcher_Reset(t *testing.T) {
320+ m := NewMatcher(testTrie(t))
321+ _ = m.Feed(Key{Sym: "y"})
322+ _ = m.Feed(Key{Sym: "a"})
323+ m.Reset()
324+ if m.Pending() != "" {
325+ t.Fatalf("Pending after Reset = %q, want empty", m.Pending())
326+ }
327+ if m.Ambiguous() {
328+ t.Fatal("Ambiguous() after Reset = true, want false")
329+ }
330+}
331+
332+func TestMatcher_InvalidMidSequence(t *testing.T) {
333+ m := NewMatcher(testTrie(t))
334+ m.Reset()
335+ _ = m.Feed(Key{Sym: "y"})
336+ result := m.Feed(Key{Sym: "x"})
337+ if result.Kind != ResultInvalid {
338+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultInvalid)
339+ }
340+}
341+
342+func TestMatcher_ResolveAmbiguityInvalid(t *testing.T) {
343+ m := NewMatcher(testTrie(t))
344+ m.Reset()
345+ _, ok := m.ResolveAmbiguity()
346+ if ok {
347+ t.Fatal("ResolveAmbiguity without prefix ok = true, want false")
348+ }
349+}
350+
351+func TestMatcher_NilTriePendingEmpty(t *testing.T) {
352+ m := NewMatcher(nil)
353+ if got := m.Pending(); got != "" {
354+ t.Fatalf("Pending() = %q, want empty", got)
355+ }
356+}
357+
358+func TestMatcher_NilTrieAmbiguousFalse(t *testing.T) {
359+ m := NewMatcher(nil)
360+ if m.Ambiguous() {
361+ t.Fatal("Ambiguous() = true, want false")
362+ }
363+}
364+
365+func TestMatcher_NilTrieFeedInvalid(t *testing.T) {
366+ m := NewMatcher(nil)
367+
368+ for _, key := range []Key{{Sym: "x"}, {Sym: "2"}} {
369+ result := m.Feed(key)
370+ if result.Kind != ResultInvalid {
371+ t.Fatalf("Feed(%v) kind = %v, want %v", key, result.Kind, ResultInvalid)
372+ }
373+ if result.Pending != "" {
374+ t.Fatalf("Feed(%v) pending = %q, want empty", key, result.Pending)
375+ }
376+ if got := m.Pending(); got != "" {
377+ t.Fatalf("after Feed(%v) Pending() = %q, want empty", key, got)
378+ }
379+ if m.Ambiguous() {
380+ t.Fatalf("after Feed(%v) Ambiguous() = true, want false", key)
381+ }
382+ }
383+}
384+
385+func TestMatcher_InvalidDeadEnd(t *testing.T) {
386+ trie := NewTrie()
387+ trie.root.children[Key{Sym: "x"}] = newTrieNode()
388+ m := NewMatcher(trie)
389+ result := m.Feed(Key{Sym: "x"})
390+ if result.Kind != ResultInvalid {
391+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultInvalid)
392+ }
393+}
394+
395+func TestMatcher_CountExactCap(t *testing.T) {
396+ m := NewMatcher(testTrie(t))
397+ m.Reset()
398+ var result Result
399+ for _, ch := range "9999" {
400+ result = m.Feed(Key{Sym: string(ch)})
401+ }
402+ result = m.Feed(Key{Sym: "j"})
403+ if result.Count != 9999 {
404+ t.Fatalf("Count = %d, want 9999", result.Count)
405+ }
406+}
A internal/domain/vimkeys/parse.go +415 -0
@@ -0,0 +1,415 @@
1+package vimkeys
2+
3+import (
4+ "strings"
5+ "unicode"
6+ "unicode/utf8"
7+)
8+
9+func ParseBinding(s string) (Sequence, error) {
10+ if !utf8.ValidString(s) {
11+ return nil, ErrBadBinding
12+ }
13+ s = strings.TrimSpace(s)
14+ if s == "" {
15+ return nil, ErrEmptyBinding
16+ }
17+
18+ if key, ok := tryParseLegacyAtom(s); ok {
19+ return Sequence{key}, nil
20+ }
21+
22+ if key, ok, err := tryParseAsLegacyChord(s); ok {
23+ if err != nil {
24+ return nil, err
25+ }
26+ return Sequence{key}, nil
27+ }
28+ return parseRawSequence(s)
29+}
30+
31+// tryParseLegacyAtom treats raw UI key names enter/escape (and aliases) as single
32+// keys so they remain compatible with the GTK shortcut table contract.
33+func tryParseLegacyAtom(s string) (Key, bool) {
34+ switch strings.ToLower(s) {
35+ case "enter", "return":
36+ return Key{Sym: symCR}, true
37+ case "escape", "esc":
38+ return Key{Sym: symEsc}, true
39+ default:
40+ return Key{}, false
41+ }
42+}
43+
44+func legacyChordParts(s string) []string {
45+ parts := strings.Split(s, "+")
46+ tokens := make([]string, 0, len(parts))
47+ for _, part := range parts {
48+ if part = strings.TrimSpace(part); part != "" {
49+ tokens = append(tokens, part)
50+ }
51+ }
52+ return tokens
53+}
54+
55+func isModifierName(part string) bool {
56+ switch strings.ToLower(part) {
57+ case "ctrl", "control", "shift", "alt":
58+ return true
59+ default:
60+ return false
61+ }
62+}
63+
64+func tryParseAsLegacyChord(s string) (Key, bool, error) {
65+ if s == "+" {
66+ return Key{Sym: "+"}, true, nil
67+ }
68+
69+ parts := legacyChordParts(s)
70+ if !legacyChordLooksLikeAttempt(s, parts) {
71+ return Key{}, false, nil
72+ }
73+
74+ key, err := buildLegacyChordKey(s, parts)
75+ return key, true, err
76+}
77+
78+func legacyChordLooksLikeAttempt(s string, parts []string) bool {
79+ if strings.HasSuffix(s, "++") {
80+ return true
81+ }
82+ for _, part := range parts {
83+ if isModifierName(part) {
84+ return true
85+ }
86+ }
87+ return false
88+}
89+
90+func validateLegacyChordLayout(s string, parts []string) error {
91+ if !strings.Contains(s, "+") {
92+ return ErrBadBinding
93+ }
94+ if strings.HasSuffix(s, "++") {
95+ return nil
96+ }
97+ for i, part := range parts {
98+ if isModifierName(part) {
99+ continue
100+ }
101+ if i != len(parts)-1 {
102+ return ErrBadBinding
103+ }
104+ }
105+ return nil
106+}
107+
108+func modsFromLegacyPart(part string) (Mods, bool) {
109+ switch strings.ToLower(part) {
110+ case "ctrl", "control":
111+ return ModCtrl, true
112+ case "shift":
113+ return ModShift, true
114+ case "alt":
115+ return ModAlt, true
116+ default:
117+ return 0, false
118+ }
119+}
120+
121+func collectLegacyChordModsAndKey(s string, parts []string) (Mods, string, error) {
122+ if err := validateLegacyChordLayout(s, parts); err != nil {
123+ return 0, "", err
124+ }
125+
126+ var mods Mods
127+ keyPart := ""
128+ for _, part := range parts {
129+ if mod, ok := modsFromLegacyPart(part); ok {
130+ mods |= mod
131+ continue
132+ }
133+ if keyPart != "" {
134+ return 0, "", ErrBadBinding
135+ }
136+ keyPart = part
137+ }
138+
139+ if keyPart == "" && strings.HasSuffix(s, "++") {
140+ keyPart = "+"
141+ }
142+ if keyPart == "" {
143+ return 0, "", ErrBadBinding
144+ }
145+ return mods, keyPart, nil
146+}
147+
148+func legacyKeyPartWithShift(keyPart string, mods Mods) (string, Mods) {
149+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
150+ return strings.ToLower(keyPart), mods | ModShift
151+ }
152+ return keyPart, mods
153+}
154+
155+func buildLegacyChordKey(s string, parts []string) (Key, error) {
156+ mods, keyPart, err := collectLegacyChordModsAndKey(s, parts)
157+ if err != nil {
158+ return Key{}, err
159+ }
160+ keyPart, mods = legacyKeyPartWithShift(keyPart, mods)
161+ sym, err := symFromToken(keyPart, false)
162+ if err != nil {
163+ return Key{}, err
164+ }
165+ return Key{Sym: sym, Mods: mods}, nil
166+}
167+
168+func parseAngleToken(inner string) (Key, error) {
169+ if !strings.Contains(inner, "-") {
170+ sym, err := symFromAlias(inner)
171+ if err != nil {
172+ return Key{}, err
173+ }
174+ return Key{Sym: sym}, nil
175+ }
176+
177+ mods, symPart, err := splitAngleModsAndSym(inner)
178+ if err != nil {
179+ return Key{}, err
180+ }
181+
182+ sym, err := symFromToken(symPart, true)
183+ if err != nil {
184+ return Key{}, err
185+ }
186+ return Key{Sym: sym, Mods: mods}, nil
187+}
188+
189+func splitAngleModsAndSym(inner string) (Mods, string, error) {
190+ var modInner, symPart string
191+ if strings.HasSuffix(inner, "--") {
192+ modInner = inner[:len(inner)-2]
193+ symPart = "-"
194+ } else {
195+ lastDash := strings.LastIndexByte(inner, '-')
196+ if lastDash < 0 {
197+ return 0, "", ErrBadBinding
198+ }
199+ modInner = inner[:lastDash]
200+ symPart = inner[lastDash+1:]
201+ }
202+ if symPart == "" || modInner == "" {
203+ return 0, "", ErrBadBinding
204+ }
205+
206+ var mods Mods
207+ for _, modPart := range strings.Split(modInner, "-") {
208+ modPart = strings.TrimSpace(modPart)
209+ if modPart == "" {
210+ return 0, "", ErrBadBinding
211+ }
212+ partMods, err := modsFromCompactModString(modPart)
213+ if err != nil {
214+ return 0, "", err
215+ }
216+ mods |= partMods
217+ }
218+ return mods, symPart, nil
219+}
220+
221+func modsFromCompactModString(modPart string) (Mods, error) {
222+ var mods Mods
223+ for _, ch := range modPart {
224+ switch ch {
225+ case 'C', 'c':
226+ mods |= ModCtrl
227+ case 'S', 's':
228+ mods |= ModShift
229+ case 'A', 'a':
230+ mods |= ModAlt
231+ default:
232+ return 0, ErrBadBinding
233+ }
234+ }
235+ return mods, nil
236+}
237+
238+func parseRawSequence(s string) (Sequence, error) {
239+ seq := make(Sequence, 0, len(s))
240+ for i := 0; i < len(s); {
241+ if s[i] == '<' {
242+ key, width, err := parseAngleAt(s, i)
243+ if err != nil {
244+ return nil, err
245+ }
246+ seq = append(seq, key)
247+ i += width
248+ continue
249+ }
250+ // Raw text is runes plus <...> tokens (and whole-string legacy atoms/chords
251+ // handled above). Bare C-/S-/A- must not steal printable runs like "C-d".
252+ r, width := utf8.DecodeRuneInString(s[i:])
253+ key, err := keyFromRune(r)
254+ if err != nil {
255+ return nil, err
256+ }
257+ seq = append(seq, key)
258+ i += width
259+ }
260+ return seq, nil
261+}
262+
263+func parseAngleAt(s string, i int) (Key, int, error) {
264+ if i >= len(s) || s[i] != '<' {
265+ return Key{}, 0, ErrBadBinding
266+ }
267+ closeIdx := strings.IndexByte(s[i+1:], '>')
268+ if closeIdx < 0 {
269+ return Key{}, 0, ErrBadBinding
270+ }
271+ closeIdx += i + 1
272+ inner := s[i+1 : closeIdx]
273+ if inner == "" {
274+ return Key{}, 0, ErrBadBinding
275+ }
276+ key, err := parseAngleToken(inner)
277+ if err != nil {
278+ return Key{}, 0, err
279+ }
280+ return key, closeIdx - i + 1, nil
281+}
282+
283+func keyFromRune(r rune) (Key, error) {
284+ if r >= 'A' && r <= 'Z' {
285+ return Key{Sym: strings.ToLower(string(r)), Mods: ModShift}, nil
286+ }
287+ if shiftedGlyph, ok := shiftedGlyphForRune(r); ok {
288+ return Key{Sym: shiftedGlyph}, nil
289+ }
290+ if r >= 'a' && r <= 'z' {
291+ return Key{Sym: string(r)}, nil
292+ }
293+ if unicode.IsPrint(r) {
294+ return Key{Sym: string(r)}, nil
295+ }
296+ return Key{}, ErrBadBinding
297+}
298+
299+func shiftedGlyphForRune(r rune) (string, bool) {
300+ switch r {
301+ case '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '~', '<', '>', '?':
302+ return string(r), true
303+ default:
304+ return "", false
305+ }
306+}
307+
308+func symFromSpecialAlias(token string) (string, bool) {
309+ lower := strings.ToLower(token)
310+ switch lower {
311+ case "cr", "return":
312+ return symCR, true
313+ case "esc", "escape":
314+ return symEsc, true
315+ case "space":
316+ return "Space", true
317+ case "lt":
318+ return "<", true
319+ case "gt":
320+ return ">", true
321+ case "plus":
322+ return "+", true
323+ default:
324+ return namedSymFromAlias(lower)
325+ }
326+}
327+
328+func namedSymFromAlias(lower string) (string, bool) {
329+ switch lower {
330+ case "tab":
331+ return "Tab", true
332+ case "backspace":
333+ return "BackSpace", true
334+ case "delete":
335+ return "Delete", true
336+ case "left":
337+ return "Left", true
338+ case "right":
339+ return "Right", true
340+ case "up":
341+ return "Up", true
342+ case "down":
343+ return "Down", true
344+ case "home":
345+ return "Home", true
346+ case "end":
347+ return "End", true
348+ case "pageup":
349+ return "PageUp", true
350+ case "pagedown":
351+ return "PageDown", true
352+ default:
353+ return "", false
354+ }
355+}
356+
357+func symFromToken(token string, allowSingleLetter bool) (string, error) {
358+ if sym, ok := symFromSpecialAlias(token); ok {
359+ return sym, nil
360+ }
361+ if allowSingleLetter {
362+ if sym, ok := asciiLetterSym(token); ok {
363+ return sym, nil
364+ }
365+ }
366+ if sym, ok := singlePrintableRuneSym(token); ok {
367+ return sym, nil
368+ }
369+ return "", ErrBadBinding
370+}
371+
372+func symFromAlias(token string) (string, error) {
373+ if sym, ok := symFromSpecialAlias(token); ok {
374+ return sym, nil
375+ }
376+ // One-character / one-rune literal angle tokens (<e>, <é>) disambiguate
377+ // legacy-atom collisions. Multi-rune unknowns such as <Nope> remain invalid.
378+ if sym, ok := asciiLetterSym(token); ok {
379+ return sym, nil
380+ }
381+ if sym, ok := singlePrintableRuneSym(token); ok {
382+ return sym, nil
383+ }
384+ return "", ErrBadBinding
385+}
386+
387+// asciiLetterSym normalizes a single ASCII letter token (A-Z -> a-z).
388+func asciiLetterSym(token string) (string, bool) {
389+ if len(token) != 1 {
390+ return "", false
391+ }
392+ ch := token[0]
393+ if ch >= 'A' && ch <= 'Z' {
394+ return strings.ToLower(token), true
395+ }
396+ if ch >= 'a' && ch <= 'z' {
397+ return token, true
398+ }
399+ return "", false
400+}
401+
402+// singlePrintableRuneSym accepts exactly one valid UTF-8 printable rune.
403+func singlePrintableRuneSym(token string) (string, bool) {
404+ if token == "" || !utf8.ValidString(token) {
405+ return "", false
406+ }
407+ r, size := utf8.DecodeRuneInString(token)
408+ if size != len(token) {
409+ return "", false
410+ }
411+ if !unicode.IsPrint(r) {
412+ return "", false
413+ }
414+ return token, true
415+}
A internal/domain/vimkeys/parse_test.go +1243 -0
@@ -0,0 +1,1243 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestParseBinding_SingleKeys(t *testing.T) {
10+ tests := []struct {
11+ input string
12+ want Sequence
13+ }{
14+ {input: "j", want: Sequence{{Sym: "j"}}},
15+ {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
16+ {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}},
17+ {input: "ctrl+-", want: Sequence{{Sym: "-", Mods: ModCtrl}}},
18+ {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
19+ {input: "<CR>", want: Sequence{{Sym: "CR"}}},
20+ {input: "<Return>", want: Sequence{{Sym: "CR"}}},
21+ {input: "<Esc>", want: Sequence{{Sym: "Esc"}}},
22+ {input: "<Escape>", want: Sequence{{Sym: "Esc"}}},
23+ {input: "<Space>", want: Sequence{{Sym: "Space"}}},
24+ {input: "<Tab>", want: Sequence{{Sym: "Tab"}}},
25+ {input: "<BackSpace>", want: Sequence{{Sym: "BackSpace"}}},
26+ {input: "<Delete>", want: Sequence{{Sym: "Delete"}}},
27+ {input: "<Left>", want: Sequence{{Sym: "Left"}}},
28+ {input: "<Right>", want: Sequence{{Sym: "Right"}}},
29+ {input: "<Up>", want: Sequence{{Sym: "Up"}}},
30+ {input: "<Down>", want: Sequence{{Sym: "Down"}}},
31+ {input: "<Home>", want: Sequence{{Sym: "Home"}}},
32+ {input: "<End>", want: Sequence{{Sym: "End"}}},
33+ {input: "<PageUp>", want: Sequence{{Sym: "PageUp"}}},
34+ {input: "<PageDown>", want: Sequence{{Sym: "PageDown"}}},
35+ {input: "<C-Tab>", want: Sequence{{Sym: "Tab", Mods: ModCtrl}}},
36+ {input: "<S-BackSpace>", want: Sequence{{Sym: "BackSpace", Mods: ModShift}}},
37+ {input: "<A-Delete>", want: Sequence{{Sym: "Delete", Mods: ModAlt}}},
38+ {input: "<C-Left>", want: Sequence{{Sym: "Left", Mods: ModCtrl}}},
39+ {input: "<lt>", want: Sequence{{Sym: "<"}}},
40+ {input: "<gt>", want: Sequence{{Sym: ">"}}},
41+ {input: "<C-gt>", want: Sequence{{Sym: ">", Mods: ModCtrl}}},
42+ {input: "<Plus>", want: Sequence{{Sym: "+"}}},
43+ {input: "enter", want: Sequence{{Sym: "CR"}}},
44+ {input: "escape", want: Sequence{{Sym: "Esc"}}},
45+ {input: "Enter", want: Sequence{{Sym: "CR"}}},
46+ {input: "Escape", want: Sequence{{Sym: "Esc"}}},
47+ }
48+ for _, tt := range tests {
49+ t.Run(tt.input, func(t *testing.T) {
50+ got, err := ParseBinding(tt.input)
51+ if err != nil {
52+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
53+ }
54+ if !got.Equal(tt.want) {
55+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
56+ }
57+ })
58+ }
59+}
60+
61+func TestParseBinding_Sequences(t *testing.T) {
62+ tests := []struct {
63+ input string
64+ want Sequence
65+ }{
66+ {input: "]]", want: Sequence{{Sym: "]"}, {Sym: "]"}}},
67+ {input: "yah", want: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}},
68+ {input: "gO", want: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}},
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.input, func(t *testing.T) {
72+ got, err := ParseBinding(tt.input)
73+ if err != nil {
74+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
75+ }
76+ if !got.Equal(tt.want) {
77+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
78+ }
79+ })
80+ }
81+}
82+
83+func TestParseBinding_Malformed(t *testing.T) {
84+ tests := []struct {
85+ input string
86+ want error
87+ }{
88+ {input: "", want: ErrEmptyBinding},
89+ {input: " ", want: ErrEmptyBinding},
90+ {input: "<", want: ErrBadBinding},
91+ {input: "<>", want: ErrBadBinding},
92+ {input: "<Bad>", want: ErrBadBinding},
93+ {input: "shift+", want: ErrBadBinding},
94+ {input: "ctrl+", want: ErrBadBinding},
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.input, func(t *testing.T) {
98+ _, err := ParseBinding(tt.input)
99+ if err == nil {
100+ t.Fatalf("ParseBinding(%q) expected error", tt.input)
101+ }
102+ if !errors.Is(err, tt.want) {
103+ t.Fatalf("ParseBinding(%q) error = %v, want %v", tt.input, err, tt.want)
104+ }
105+ })
106+ }
107+}
108+
109+func TestParseBinding_CanonicalString(t *testing.T) {
110+ tests := []struct {
111+ input string
112+ want string
113+ }{
114+ {input: "j", want: "j"},
115+ {input: "shift+j", want: "J"},
116+ {input: "ctrl++", want: "<C-+>"},
117+ {input: "ctrl+-", want: "<C-->"},
118+ {input: "<C-->", want: "<C-->"},
119+ {input: "<C-d>", want: "<C-d>"},
120+ {input: "<Plus>", want: "<Plus>"},
121+ {input: "+", want: "<Plus>"},
122+ {input: "<CR>", want: "<Return>"},
123+ {input: "<Return>", want: "<Return>"},
124+ {input: "<Esc>", want: "<Escape>"},
125+ {input: "<Escape>", want: "<Escape>"},
126+ {input: "<Space>", want: "<Space>"},
127+ {input: "<Tab>", want: "<Tab>"},
128+ {input: "<BackSpace>", want: "<BackSpace>"},
129+ {input: "<Delete>", want: "<Delete>"},
130+ {input: "<Left>", want: "<Left>"},
131+ {input: "<Right>", want: "<Right>"},
132+ {input: "<Up>", want: "<Up>"},
133+ {input: "<Down>", want: "<Down>"},
134+ {input: "<Home>", want: "<Home>"},
135+ {input: "<End>", want: "<End>"},
136+ {input: "<PageUp>", want: "<PageUp>"},
137+ {input: "<PageDown>", want: "<PageDown>"},
138+ {input: "<C-Tab>", want: "<C-Tab>"},
139+ {input: "<S-BackSpace>", want: "<S-BackSpace>"},
140+ {input: "<A-Delete>", want: "<A-Delete>"},
141+ {input: "<C-Home>", want: "<C-Home>"},
142+ {input: "<A-PageDown>", want: "<A-PageDown>"},
143+ {input: "<lt>", want: "<lt>"},
144+ {input: "<gt>", want: "<gt>"},
145+ {input: ">", want: "<gt>"},
146+ {input: "ctrl+>", want: "<C-gt>"},
147+ {input: "<C-gt>", want: "<C-gt>"},
148+ {input: "gO", want: "gO"},
149+ }
150+ for _, tt := range tests {
151+ t.Run(tt.input, func(t *testing.T) {
152+ seq, err := ParseBinding(tt.input)
153+ if err != nil {
154+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
155+ }
156+ if got := seq.String(); got != tt.want {
157+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.want)
158+ }
159+ })
160+ }
161+}
162+
163+func TestParseBinding_LegacyChords(t *testing.T) {
164+ tests := []struct {
165+ input string
166+ want Sequence
167+ }{
168+ {input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
169+ {input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
170+ {input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
171+ {input: "ctrl+J", want: Sequence{{Sym: "j", Mods: ModCtrl | ModShift}}},
172+ }
173+ for _, tt := range tests {
174+ t.Run(tt.input, func(t *testing.T) {
175+ got, err := ParseBinding(tt.input)
176+ if err != nil {
177+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
178+ }
179+ if !got.Equal(tt.want) {
180+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
181+ }
182+ })
183+ }
184+}
185+
186+func TestParseBinding_MoreMalformed(t *testing.T) {
187+ tests := []string{
188+ "ctrl+shift+j+k",
189+ "<C->",
190+ "<C--d>",
191+ "<Bad>",
192+ "ctrl+",
193+ }
194+ for _, input := range tests {
195+ t.Run(input, func(t *testing.T) {
196+ _, err := ParseBinding(input)
197+ if err == nil {
198+ t.Fatalf("ParseBinding(%q) expected error", input)
199+ }
200+ if !errors.Is(err, ErrBadBinding) {
201+ t.Fatalf("ParseBinding(%q) error = %v, want %v", input, err, ErrBadBinding)
202+ }
203+ })
204+ }
205+}
206+
207+func TestParseBinding_PlusAlone(t *testing.T) {
208+ got, err := ParseBinding("+")
209+ if err != nil {
210+ t.Fatalf("ParseBinding error = %v", err)
211+ }
212+ want := Sequence{{Sym: "+"}}
213+ if !got.Equal(want) {
214+ t.Fatalf("ParseBinding = %v, want %v", got, want)
215+ }
216+}
217+
218+func TestParseBinding_LooksLikeChordButRaw(t *testing.T) {
219+ got, err := ParseBinding("]c")
220+ if err != nil {
221+ t.Fatalf("ParseBinding error = %v", err)
222+ }
223+ want := Sequence{{Sym: "]"}, {Sym: "c"}}
224+ if !got.Equal(want) {
225+ t.Fatalf("ParseBinding = %v, want %v", got, want)
226+ }
227+}
228+
229+func TestParseBinding_BareModifierLikeIsLiteral(t *testing.T) {
230+ // Modifiers are canonical only inside <...>; bare "C-d" is Shift+c, '-', d.
231+ tests := []struct {
232+ input string
233+ want Sequence
234+ }{
235+ {
236+ input: "C-d",
237+ want: Sequence{
238+ {Sym: "c", Mods: ModShift},
239+ {Sym: "-"},
240+ {Sym: "d"},
241+ },
242+ },
243+ {
244+ input: "S-j",
245+ want: Sequence{
246+ {Sym: "s", Mods: ModShift},
247+ {Sym: "-"},
248+ {Sym: "j"},
249+ },
250+ },
251+ {
252+ input: "A-k",
253+ want: Sequence{
254+ {Sym: "a", Mods: ModShift},
255+ {Sym: "-"},
256+ {Sym: "k"},
257+ },
258+ },
259+ }
260+ for _, tt := range tests {
261+ t.Run(tt.input, func(t *testing.T) {
262+ got, err := ParseBinding(tt.input)
263+ if err != nil {
264+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
265+ }
266+ if !got.Equal(tt.want) {
267+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
268+ }
269+ if got := got.String(); got != tt.input {
270+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.input)
271+ }
272+ })
273+ }
274+}
275+
276+func TestParseBinding_CtrlGtRoundTrip(t *testing.T) {
277+ tests := []struct {
278+ input string
279+ want Sequence
280+ wantString string
281+ }{
282+ {
283+ input: "ctrl+>",
284+ want: Sequence{{Sym: ">", Mods: ModCtrl}},
285+ wantString: "<C-gt>",
286+ },
287+ {
288+ input: "<C-gt>",
289+ want: Sequence{{Sym: ">", Mods: ModCtrl}},
290+ wantString: "<C-gt>",
291+ },
292+ {
293+ input: "<S-gt>",
294+ want: Sequence{{Sym: ">", Mods: ModShift}},
295+ wantString: "<S-gt>",
296+ },
297+ {
298+ input: "<A-gt>",
299+ want: Sequence{{Sym: ">", Mods: ModAlt}},
300+ wantString: "<A-gt>",
301+ },
302+ {
303+ input: ">",
304+ want: Sequence{{Sym: ">"}},
305+ wantString: "<gt>",
306+ },
307+ {
308+ input: "<gt>",
309+ want: Sequence{{Sym: ">"}},
310+ wantString: "<gt>",
311+ },
312+ }
313+ for _, tt := range tests {
314+ t.Run(tt.input, func(t *testing.T) {
315+ first, err := ParseBinding(tt.input)
316+ if err != nil {
317+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
318+ }
319+ if !first.Equal(tt.want) {
320+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
321+ }
322+ if got := first.String(); got != tt.wantString {
323+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
324+ }
325+ second, err := ParseBinding(first.String())
326+ if err != nil {
327+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
328+ }
329+ if !first.Equal(second) {
330+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
331+ }
332+ })
333+ }
334+}
335+
336+func TestParseBinding_ShiftCHyphenDRoundTrip(t *testing.T) {
337+ input := "<S-c>-d"
338+ want := Sequence{
339+ {Sym: "c", Mods: ModShift},
340+ {Sym: "-"},
341+ {Sym: "d"},
342+ }
343+ first, err := ParseBinding(input)
344+ if err != nil {
345+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
346+ }
347+ if !first.Equal(want) {
348+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
349+ }
350+ canonical := first.String()
351+ if canonical != "C-d" {
352+ t.Fatalf("String() = %q, want %q", canonical, "C-d")
353+ }
354+ second, err := ParseBinding(canonical)
355+ if err != nil {
356+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
357+ }
358+ if !first.Equal(second) {
359+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
360+ }
361+}
362+
363+func TestParseBinding_SpecialNamesInSequence(t *testing.T) {
364+ tests := []struct {
365+ input string
366+ want Sequence
367+ }{
368+ {input: "j<Esc>", want: Sequence{{Sym: "j"}, {Sym: "Esc"}}},
369+ {input: "<Space>j", want: Sequence{{Sym: "Space"}, {Sym: "j"}}},
370+ {input: "<CR>j", want: Sequence{{Sym: "CR"}, {Sym: "j"}}},
371+ {input: "<lt>j", want: Sequence{{Sym: "<"}, {Sym: "j"}}},
372+ {input: "<gt>j", want: Sequence{{Sym: ">"}, {Sym: "j"}}},
373+ {input: "<Plus>j", want: Sequence{{Sym: "+"}, {Sym: "j"}}},
374+ {input: "j<Tab>", want: Sequence{{Sym: "j"}, {Sym: "Tab"}}},
375+ {input: "<BackSpace>j", want: Sequence{{Sym: "BackSpace"}, {Sym: "j"}}},
376+ }
377+ for _, tt := range tests {
378+ t.Run(tt.input, func(t *testing.T) {
379+ got, err := ParseBinding(tt.input)
380+ if err != nil {
381+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
382+ }
383+ if !got.Equal(tt.want) {
384+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
385+ }
386+ })
387+ }
388+}
389+
390+func TestParseBinding_BareNamedPrefixIsLiteral(t *testing.T) {
391+ // Named keys are canonical only in <...>; bare "Spacej" is Shift+s + p + a + c + e + j.
392+ seq := Sequence{
393+ {Sym: "s", Mods: ModShift},
394+ {Sym: "p"},
395+ {Sym: "a"},
396+ {Sym: "c"},
397+ {Sym: "e"},
398+ {Sym: "j"},
399+ }
400+ if got := seq.String(); got != "Spacej" {
401+ t.Fatalf("seq.String() = %q, want %q", got, "Spacej")
402+ }
403+ got, err := ParseBinding("Spacej")
404+ if err != nil {
405+ t.Fatalf("ParseBinding(%q) error = %v", "Spacej", err)
406+ }
407+ if !got.Equal(seq) {
408+ t.Fatalf("ParseBinding(%q) = %v, want %v", "Spacej", got, seq)
409+ }
410+}
411+
412+func TestParseBinding_PrintableGlyphs(t *testing.T) {
413+ got, err := ParseBinding("!@#")
414+ if err != nil {
415+ t.Fatalf("ParseBinding error = %v", err)
416+ }
417+ want := Sequence{{Sym: "!"}, {Sym: "@"}, {Sym: "#"}}
418+ if !got.Equal(want) {
419+ t.Fatalf("ParseBinding = %v, want %v", got, want)
420+ }
421+}
422+
423+func TestParseBinding_MixedAngleSequences(t *testing.T) {
424+ tests := []struct {
425+ input string
426+ want Sequence
427+ }{
428+ {
429+ input: "<C-d>j",
430+ want: Sequence{{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
431+ },
432+ {
433+ input: "<Escape>j",
434+ want: Sequence{{Sym: "Esc"}, {Sym: "j"}},
435+ },
436+ }
437+ for _, tt := range tests {
438+ t.Run(tt.input, func(t *testing.T) {
439+ got, err := ParseBinding(tt.input)
440+ if err != nil {
441+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
442+ }
443+ if !got.Equal(tt.want) {
444+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
445+ }
446+ })
447+ }
448+}
449+
450+func TestParseBinding_CtrlHyphenRoundTrip(t *testing.T) {
451+ tests := []struct {
452+ input string
453+ want Sequence
454+ wantString string
455+ }{
456+ {
457+ input: "ctrl+-",
458+ want: Sequence{{Sym: "-", Mods: ModCtrl}},
459+ wantString: "<C-->",
460+ },
461+ {
462+ input: "<C-->",
463+ want: Sequence{{Sym: "-", Mods: ModCtrl}},
464+ wantString: "<C-->",
465+ },
466+ {
467+ input: "<C-S-->",
468+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift}},
469+ wantString: "<C-S-->",
470+ },
471+ {
472+ input: "<C-A-->",
473+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModAlt}},
474+ wantString: "<C-A-->",
475+ },
476+ {
477+ input: "<C-S-A-->",
478+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift | ModAlt}},
479+ wantString: "<C-S-A-->",
480+ },
481+ }
482+ for _, tt := range tests {
483+ t.Run(tt.input, func(t *testing.T) {
484+ first, err := ParseBinding(tt.input)
485+ if err != nil {
486+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
487+ }
488+ if !first.Equal(tt.want) {
489+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
490+ }
491+ if got := first.String(); got != tt.wantString {
492+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
493+ }
494+ second, err := ParseBinding(first.String())
495+ if err != nil {
496+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
497+ }
498+ if !first.Equal(second) {
499+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
500+ }
501+ })
502+ }
503+}
504+
505+func TestParseBinding_CtrlPlusRoundTrip(t *testing.T) {
506+ tests := []struct {
507+ input string
508+ want Sequence
509+ wantString string
510+ }{
511+ {
512+ input: "ctrl++",
513+ want: Sequence{{Sym: "+", Mods: ModCtrl}},
514+ wantString: "<C-+>",
515+ },
516+ {
517+ input: "<C-+>",
518+ want: Sequence{{Sym: "+", Mods: ModCtrl}},
519+ wantString: "<C-+>",
520+ },
521+ {
522+ input: "<C-S-+>",
523+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModShift}},
524+ wantString: "<C-S-+>",
525+ },
526+ {
527+ input: "<C-A-+>",
528+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModAlt}},
529+ wantString: "<C-A-+>",
530+ },
531+ {
532+ input: "<C-S-A-+>",
533+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModShift | ModAlt}},
534+ wantString: "<C-S-A-+>",
535+ },
536+ }
537+ for _, tt := range tests {
538+ t.Run(tt.input, func(t *testing.T) {
539+ first, err := ParseBinding(tt.input)
540+ if err != nil {
541+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
542+ }
543+ if !first.Equal(tt.want) {
544+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
545+ }
546+ if got := first.String(); got != tt.wantString {
547+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
548+ }
549+ second, err := ParseBinding(first.String())
550+ if err != nil {
551+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
552+ }
553+ if !first.Equal(second) {
554+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
555+ }
556+ })
557+ }
558+}
559+
560+func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {
561+ input := "<C-S-a>"
562+ first, err := ParseBinding(input)
563+ if err != nil {
564+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
565+ }
566+ want := Sequence{{Sym: "a", Mods: ModCtrl | ModShift}}
567+ if !first.Equal(want) {
568+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
569+ }
570+
571+ canonical := first.String()
572+ if canonical != "<C-S-a>" {
573+ t.Fatalf("String() = %q, want %q", canonical, "<C-S-a>")
574+ }
575+
576+ second, err := ParseBinding(canonical)
577+ if err != nil {
578+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
579+ }
580+ if !first.Equal(second) {
581+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
582+ }
583+}
584+
585+func TestParseBinding_RoundTrip(t *testing.T) {
586+ inputs := []string{
587+ "j",
588+ "shift+j",
589+ "ctrl++",
590+ "ctrl+-",
591+ "<C-->",
592+ "<C-S-->",
593+ "<C-A-->",
594+ "]]",
595+ "yah",
596+ "gO",
597+ "<C-d>",
598+ "<CR>",
599+ "<Esc>",
600+ "<Space>",
601+ "<Tab>",
602+ "<BackSpace>",
603+ "<Delete>",
604+ "<Left>",
605+ "<Right>",
606+ "<Up>",
607+ "<Down>",
608+ "<Home>",
609+ "<End>",
610+ "<PageUp>",
611+ "<PageDown>",
612+ "<C-Tab>",
613+ "<S-BackSpace>",
614+ "<A-Delete>",
615+ "<C-Left>",
616+ "<S-Home>",
617+ "<A-PageUp>",
618+ "<lt>",
619+ "<gt>",
620+ ">",
621+ "ctrl+>",
622+ "<C-gt>",
623+ "<C-d>j",
624+ "<Escape>j",
625+ "<C-S-a>",
626+ "<S-c>-d",
627+ "C-d",
628+ "S-j",
629+ "A-k",
630+ "Spacej",
631+ "Escj",
632+ "Tabx",
633+ "enter",
634+ "escape",
635+ "return",
636+ "esc",
637+ }
638+ for _, input := range inputs {
639+ t.Run(input, func(t *testing.T) {
640+ first, err := ParseBinding(input)
641+ if err != nil {
642+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
643+ }
644+ canonical := first.String()
645+ second, err := ParseBinding(canonical)
646+ if err != nil {
647+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", input, canonical, err)
648+ }
649+ if !first.Equal(second) {
650+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
651+ }
652+ })
653+ }
654+}
655+
656+// TestParseBinding_DeterministicRoundTripProperty asserts ParseBinding(seq.String()) == seq
657+// over a fixed representative set of printable, named, and modifier sequences.
658+// The corpus is deterministic (no flaky randomness).
659+func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
660+ corpus := []Sequence{
661+ {{Sym: "j"}},
662+ {{Sym: "j", Mods: ModShift}},
663+ {{Sym: "]"}, {Sym: "]"}},
664+ {{Sym: "y"}, {Sym: "a"}, {Sym: "h"}},
665+ {{Sym: "g"}, {Sym: "o", Mods: ModShift}},
666+ {{Sym: "d", Mods: ModCtrl}},
667+ {{Sym: "a", Mods: ModCtrl | ModShift}},
668+ {{Sym: "k", Mods: ModAlt}},
669+ {{Sym: "CR"}},
670+ {{Sym: "Esc"}},
671+ {{Sym: "Space"}},
672+ {{Sym: "Tab"}},
673+ {{Sym: "BackSpace"}},
674+ {{Sym: "Delete"}},
675+ {{Sym: "Left"}},
676+ {{Sym: "Right"}},
677+ {{Sym: "Up"}},
678+ {{Sym: "Down"}},
679+ {{Sym: "Home"}},
680+ {{Sym: "End"}},
681+ {{Sym: "PageUp"}},
682+ {{Sym: "PageDown"}},
683+ {{Sym: "Tab", Mods: ModCtrl}},
684+ {{Sym: "BackSpace", Mods: ModShift}},
685+ {{Sym: "Delete", Mods: ModAlt}},
686+ {{Sym: "<"}},
687+ {{Sym: ">"}},
688+ {{Sym: ">", Mods: ModCtrl}},
689+ {{Sym: "+"}},
690+ {{Sym: "+", Mods: ModCtrl}},
691+ {{Sym: "+", Mods: ModCtrl | ModShift}},
692+ {{Sym: "-", Mods: ModCtrl}},
693+ {{Sym: "-", Mods: ModCtrl | ModShift}},
694+ {{Sym: "-", Mods: ModCtrl | ModAlt}},
695+ {{Sym: "!"}},
696+ {{Sym: "@"}},
697+ {{Sym: "#"}},
698+ {{Sym: "Space"}, {Sym: "j"}},
699+ {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
700+ {{Sym: "Esc"}, {Sym: "j"}},
701+ // Literal printable run that formerly collided with greedy "Space" recognition.
702+ {
703+ {Sym: "s", Mods: ModShift},
704+ {Sym: "p"},
705+ {Sym: "a"},
706+ {Sym: "c"},
707+ {Sym: "e"},
708+ {Sym: "j"},
709+ },
710+ {
711+ {Sym: "e", Mods: ModShift},
712+ {Sym: "s"},
713+ {Sym: "c"},
714+ {Sym: "j"},
715+ },
716+ {
717+ {Sym: "t", Mods: ModShift},
718+ {Sym: "a"},
719+ {Sym: "b"},
720+ {Sym: "x"},
721+ },
722+ // Bare modifier-like text must stay literal (no C-/S-/A- stealing).
723+ {
724+ {Sym: "c", Mods: ModShift},
725+ {Sym: "-"},
726+ {Sym: "d"},
727+ },
728+ // Representative multi-byte printable runes (accented BMP + emoji).
729+ {{Sym: "é"}},
730+ {{Sym: "é", Mods: ModCtrl}},
731+ {{Sym: "ñ", Mods: ModAlt}},
732+ {{Sym: "😀"}},
733+ {{Sym: "😀", Mods: ModCtrl}},
734+ }
735+ for _, seq := range corpus {
736+ canonical := seq.String()
737+ t.Run(canonical, func(t *testing.T) {
738+ got, err := ParseBinding(canonical)
739+ if err != nil {
740+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
741+ }
742+ if !got.Equal(seq) {
743+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
744+ }
745+ })
746+ }
747+}
748+
749+// TestParseBinding_LegacyAtomCollisionRoundTrip ensures rune sequences whose
750+// naive Key.String concatenation would equal a legacy atom are emitted with
751+// enough angle-literal tokens to round-trip without becoming CR/Esc.
752+func TestParseBinding_LegacyAtomCollisionRoundTrip(t *testing.T) {
753+ tests := []struct {
754+ name string
755+ seq Sequence
756+ wantString string
757+ }{
758+ {
759+ name: "Shift+e n t e r",
760+ seq: Sequence{
761+ {Sym: "e", Mods: ModShift},
762+ {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
763+ },
764+ wantString: "<S-e>nter",
765+ },
766+ {
767+ name: "e n t e r",
768+ seq: Sequence{
769+ {Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
770+ },
771+ wantString: "<e>nter",
772+ },
773+ {
774+ name: "Shift+r e t u r n",
775+ seq: Sequence{
776+ {Sym: "r", Mods: ModShift},
777+ {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
778+ },
779+ wantString: "<S-r>eturn",
780+ },
781+ {
782+ name: "r e t u r n",
783+ seq: Sequence{
784+ {Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
785+ },
786+ wantString: "<r>eturn",
787+ },
788+ {
789+ name: "Shift+e s c",
790+ seq: Sequence{
791+ {Sym: "e", Mods: ModShift},
792+ {Sym: "s"}, {Sym: "c"},
793+ },
794+ wantString: "<S-e>sc",
795+ },
796+ {
797+ name: "e s c",
798+ seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
799+ wantString: "<e>sc",
800+ },
801+ {
802+ name: "Shift+e s c a p e",
803+ seq: Sequence{
804+ {Sym: "e", Mods: ModShift},
805+ {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
806+ },
807+ wantString: "<S-e>scape",
808+ },
809+ {
810+ name: "e s c a p e",
811+ seq: Sequence{
812+ {Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
813+ },
814+ wantString: "<e>scape",
815+ },
816+ }
817+ for _, tt := range tests {
818+ t.Run(tt.name, func(t *testing.T) {
819+ if got := tt.seq.String(); got != tt.wantString {
820+ t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
821+ }
822+ got, err := ParseBinding(tt.wantString)
823+ if err != nil {
824+ t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
825+ }
826+ if !got.Equal(tt.seq) {
827+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
828+ }
829+ if again := got.String(); again != tt.wantString {
830+ t.Fatalf("canonical unstable %q -> %q", tt.wantString, again)
831+ }
832+ })
833+ }
834+}
835+
836+// TestParseBinding_LegacyChordCollisionRoundTrip ensures a rune sequence that
837+// would spell a legacy chord via a literal '+' is emitted with <Plus> so it
838+// cannot reparse as that chord.
839+func TestParseBinding_LegacyChordCollisionRoundTrip(t *testing.T) {
840+ tests := []struct {
841+ name string
842+ seq Sequence
843+ wantString string
844+ }{
845+ {
846+ name: "ctrl + d runes",
847+ seq: Sequence{
848+ {Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"},
849+ {Sym: "+"},
850+ {Sym: "d"},
851+ },
852+ wantString: "ctrl<Plus>d",
853+ },
854+ {
855+ name: "Ctrl + d mixed case start",
856+ seq: Sequence{
857+ {Sym: "c", Mods: ModShift},
858+ {Sym: "t"}, {Sym: "r"}, {Sym: "l"},
859+ {Sym: "+"},
860+ {Sym: "d"},
861+ },
862+ wantString: "Ctrl<Plus>d",
863+ },
864+ {
865+ name: "control + j runes",
866+ seq: Sequence{
867+ {Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
868+ {Sym: "+"},
869+ {Sym: "j"},
870+ },
871+ wantString: "control<Plus>j",
872+ },
873+ {
874+ name: "shift + j runes",
875+ seq: Sequence{
876+ {Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
877+ {Sym: "+"},
878+ {Sym: "j"},
879+ },
880+ wantString: "shift<Plus>j",
881+ },
882+ {
883+ name: "alt + k runes",
884+ seq: Sequence{
885+ {Sym: "a"}, {Sym: "l"}, {Sym: "t"},
886+ {Sym: "+"},
887+ {Sym: "k"},
888+ },
889+ wantString: "alt<Plus>k",
890+ },
891+ }
892+ for _, tt := range tests {
893+ t.Run(tt.name, func(t *testing.T) {
894+ if got := tt.seq.String(); got != tt.wantString {
895+ t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
896+ }
897+ got, err := ParseBinding(tt.wantString)
898+ if err != nil {
899+ t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
900+ }
901+ if !got.Equal(tt.seq) {
902+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
903+ }
904+ })
905+ }
906+}
907+
908+// TestParseBinding_LegacyAtomsAndChordsStillAccepted preserves exact raw
909+// legacy config inputs as named atoms / chords.
910+func TestParseBinding_LegacyAtomsAndChordsStillAccepted(t *testing.T) {
911+ tests := []struct {
912+ input string
913+ want Sequence
914+ }{
915+ {input: "enter", want: Sequence{{Sym: "CR"}}},
916+ {input: "return", want: Sequence{{Sym: "CR"}}},
917+ {input: "Enter", want: Sequence{{Sym: "CR"}}},
918+ {input: "Return", want: Sequence{{Sym: "CR"}}},
919+ {input: "ENTER", want: Sequence{{Sym: "CR"}}},
920+ {input: "escape", want: Sequence{{Sym: "Esc"}}},
921+ {input: "esc", want: Sequence{{Sym: "Esc"}}},
922+ {input: "Escape", want: Sequence{{Sym: "Esc"}}},
923+ {input: "Esc", want: Sequence{{Sym: "Esc"}}},
924+ {input: "ESC", want: Sequence{{Sym: "Esc"}}},
925+ {input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
926+ {input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
927+ {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
928+ {input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
929+ }
930+ for _, tt := range tests {
931+ t.Run(tt.input, func(t *testing.T) {
932+ got, err := ParseBinding(tt.input)
933+ if err != nil {
934+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
935+ }
936+ if !got.Equal(tt.want) {
937+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
938+ }
939+ })
940+ }
941+}
942+
943+// TestParseBinding_OneCharAngleLiteral accepts <e>/<S-e> while rejecting <Nope>.
944+func TestParseBinding_OneCharAngleLiteral(t *testing.T) {
945+ got, err := ParseBinding("<e>")
946+ if err != nil {
947+ t.Fatalf("ParseBinding(<e>) error = %v", err)
948+ }
949+ if want := (Sequence{{Sym: "e"}}); !got.Equal(want) {
950+ t.Fatalf("ParseBinding(<e>) = %v, want %v", got, want)
951+ }
952+ got, err = ParseBinding("<S-e>")
953+ if err != nil {
954+ t.Fatalf("ParseBinding(<S-e>) error = %v", err)
955+ }
956+ if want := (Sequence{{Sym: "e", Mods: ModShift}}); !got.Equal(want) {
957+ t.Fatalf("ParseBinding(<S-e>) = %v, want %v", got, want)
958+ }
959+ if _, err := ParseBinding("<Nope>"); !errors.Is(err, ErrBadBinding) {
960+ t.Fatalf("ParseBinding(<Nope>) error = %v, want %v", err, ErrBadBinding)
961+ }
962+}
963+
964+// TestParseBinding_CtrlEAcuteRoundTrip covers the runtime Unicode canonical
965+// invariant: ctrl+é / <C-é> parse to Key{Sym:"é", ModCtrl} and String
966+// re-parses identically.
967+func TestParseBinding_CtrlEAcuteRoundTrip(t *testing.T) {
968+ want := Sequence{{Sym: "é", Mods: ModCtrl}}
969+ for _, input := range []string{"ctrl+é", "<C-é>"} {
970+ t.Run(input, func(t *testing.T) {
971+ got, err := ParseBinding(input)
972+ if err != nil {
973+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
974+ }
975+ if !got.Equal(want) {
976+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want)
977+ }
978+ canonical := got.String()
979+ if canonical != "<C-é>" {
980+ t.Fatalf("String() = %q, want %q", canonical, "<C-é>")
981+ }
982+ again, err := ParseBinding(canonical)
983+ if err != nil {
984+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
985+ }
986+ if !again.Equal(want) {
987+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want)
988+ }
989+ })
990+ }
991+}
992+
993+// TestParseBinding_ReplacementCharRoundTrip ensures valid printable U+FFFD is not
994+// rejected by the single-rune token path (r == utf8.RuneError is U+FFFD itself).
995+func TestParseBinding_ReplacementCharRoundTrip(t *testing.T) {
996+ const repl = "\uFFFD"
997+ want := Sequence{{Sym: repl, Mods: ModCtrl}}
998+ for _, input := range []string{"ctrl+" + repl, "<C-" + repl + ">"} {
999+ t.Run(input, func(t *testing.T) {
1000+ got, err := ParseBinding(input)
1001+ if err != nil {
1002+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
1003+ }
1004+ if !got.Equal(want) {
1005+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want)
1006+ }
1007+ canonical := got.String()
1008+ wantCanonical := "<C-" + repl + ">"
1009+ if canonical != wantCanonical {
1010+ t.Fatalf("String() = %q, want %q", canonical, wantCanonical)
1011+ }
1012+ again, err := ParseBinding(canonical)
1013+ if err != nil {
1014+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1015+ }
1016+ if !again.Equal(want) {
1017+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want)
1018+ }
1019+ })
1020+ }
1021+ modifiedWant := Sequence{{Sym: repl}}
1022+ got, err := ParseBinding("<" + repl + ">")
1023+ if err != nil {
1024+ t.Fatalf("ParseBinding angle literal error = %v", err)
1025+ }
1026+ if !got.Equal(modifiedWant) {
1027+ t.Fatalf("ParseBinding(<U+FFFD>) = %v, want %v", got, modifiedWant)
1028+ }
1029+}
1030+
1031+// TestParseBinding_LegacyCollisionModel systematically covers every exact
1032+// legacy atom alias, modifier+plus chord spellings, case variants, and
1033+// adjacent non-colliding sequences while retaining round-trip.
1034+func TestParseBinding_LegacyCollisionModel(t *testing.T) {
1035+ type atomCase struct {
1036+ alias string
1037+ seq Sequence
1038+ }
1039+ atoms := []atomCase{
1040+ {alias: "enter", seq: Sequence{{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
1041+ {alias: "Enter", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
1042+ {alias: "ENTER", seq: Sequence{
1043+ {Sym: "e", Mods: ModShift}, {Sym: "n", Mods: ModShift}, {Sym: "t", Mods: ModShift},
1044+ {Sym: "e", Mods: ModShift}, {Sym: "r", Mods: ModShift},
1045+ }},
1046+ {alias: "return", seq: Sequence{{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
1047+ {alias: "Return", seq: Sequence{{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
1048+ {alias: "escape", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
1049+ {alias: "Escape", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
1050+ {alias: "esc", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}}},
1051+ {alias: "Esc", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}}},
1052+ {alias: "ESC", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s", Mods: ModShift}, {Sym: "c", Mods: ModShift}}},
1053+ }
1054+ for _, tt := range atoms {
1055+ t.Run("atom/"+tt.alias, func(t *testing.T) {
1056+ canonical := tt.seq.String()
1057+ if strings.EqualFold(canonical, tt.alias) {
1058+ t.Fatalf("Sequence.String() still collides with legacy atom %q", tt.alias)
1059+ }
1060+ got, err := ParseBinding(canonical)
1061+ if err != nil {
1062+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1063+ }
1064+ if !got.Equal(tt.seq) {
1065+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
1066+ }
1067+ raw, err := ParseBinding(tt.alias)
1068+ if err != nil {
1069+ t.Fatalf("ParseBinding(%q) error = %v", tt.alias, err)
1070+ }
1071+ if raw.Equal(tt.seq) {
1072+ t.Fatalf("legacy atom %q must not equal rune sequence", tt.alias)
1073+ }
1074+ })
1075+ }
1076+
1077+ chordSpellings := []struct {
1078+ legacy string
1079+ seq Sequence
1080+ }{
1081+ {
1082+ legacy: "ctrl+d",
1083+ seq: Sequence{
1084+ {Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"},
1085+ },
1086+ },
1087+ {
1088+ legacy: "control+j",
1089+ seq: Sequence{
1090+ {Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
1091+ {Sym: "+"}, {Sym: "j"},
1092+ },
1093+ },
1094+ {
1095+ legacy: "shift+a",
1096+ seq: Sequence{
1097+ {Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
1098+ {Sym: "+"}, {Sym: "a"},
1099+ },
1100+ },
1101+ {
1102+ legacy: "alt+b",
1103+ seq: Sequence{
1104+ {Sym: "a"}, {Sym: "l"}, {Sym: "t"}, {Sym: "+"}, {Sym: "b"},
1105+ },
1106+ },
1107+ }
1108+ for _, tt := range chordSpellings {
1109+ t.Run("chord/"+tt.legacy, func(t *testing.T) {
1110+ canonical := tt.seq.String()
1111+ if canonical == tt.legacy {
1112+ t.Fatalf("Sequence.String() still equals legacy chord %q", tt.legacy)
1113+ }
1114+ got, err := ParseBinding(canonical)
1115+ if err != nil {
1116+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1117+ }
1118+ if !got.Equal(tt.seq) {
1119+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
1120+ }
1121+ chord, err := ParseBinding(tt.legacy)
1122+ if err != nil {
1123+ t.Fatalf("ParseBinding(%q) error = %v", tt.legacy, err)
1124+ }
1125+ if chord.Equal(tt.seq) {
1126+ t.Fatalf("legacy chord %q must not equal rune sequence", tt.legacy)
1127+ }
1128+ })
1129+ }
1130+
1131+ // Adjacent sequences that share prefixes but are not exact atoms.
1132+ adjacent := []Sequence{
1133+ {{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}, {Sym: "j"}},
1134+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
1135+ {{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}, {Sym: "j"}},
1136+ }
1137+ for _, seq := range adjacent {
1138+ canonical := seq.String()
1139+ t.Run("adjacent/"+canonical, func(t *testing.T) {
1140+ got, err := ParseBinding(canonical)
1141+ if err != nil {
1142+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1143+ }
1144+ if !got.Equal(seq) {
1145+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
1146+ }
1147+ })
1148+ }
1149+}
1150+
1151+// TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key
1152+// surface that Sequence.String can emit: letters × modifier combos, printable
1153+// ASCII punctuation in runtime-canonical modifier states, named symbols ×
1154+// modifier combos, and adjacent pairs that can spell modifier/named prefixes.
1155+func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) {
1156+ allMods := []Mods{
1157+ 0,
1158+ ModCtrl,
1159+ ModShift,
1160+ ModAlt,
1161+ ModCtrl | ModShift,
1162+ ModCtrl | ModAlt,
1163+ ModShift | ModAlt,
1164+ ModCtrl | ModShift | ModAlt,
1165+ }
1166+
1167+ var corpus []Sequence
1168+
1169+ for c := 'a'; c <= 'z'; c++ {
1170+ sym := string(c)
1171+ for _, mods := range allMods {
1172+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
1173+ }
1174+ }
1175+
1176+ punctuation := []string{
1177+ "!", `"`, "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/",
1178+ ":", ";", "<", "=", ">", "?", "@", "[", `\`, "]", "^", "_", "`", "{", "|", "}", "~",
1179+ }
1180+ for _, sym := range punctuation {
1181+ for _, mods := range allMods {
1182+ // Unmodified punctuation is always legal; modified forms are emitted
1183+ // as angle keys and must round-trip.
1184+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
1185+ }
1186+ }
1187+
1188+ named := []string{
1189+ "CR", "Esc", "Space", "Tab", "BackSpace", "Delete",
1190+ "Left", "Right", "Up", "Down", "Home", "End", "PageUp", "PageDown",
1191+ }
1192+ for _, sym := range named {
1193+ for _, mods := range allMods {
1194+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
1195+ }
1196+ }
1197+
1198+ // Representative adjacent pairs that can spell modifier/named prefixes,
1199+ // including exact legacy atom/chord collision spellings.
1200+ adjacentPairs := []Sequence{
1201+ {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "d"}},
1202+ {{Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "j"}},
1203+ {{Sym: "a", Mods: ModShift}, {Sym: "-"}, {Sym: "k"}},
1204+ {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "a"}},
1205+ {{Sym: "s", Mods: ModShift}, {Sym: "p"}, {Sym: "a"}, {Sym: "c"}, {Sym: "e"}, {Sym: "j"}},
1206+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
1207+ {{Sym: "t", Mods: ModShift}, {Sym: "a"}, {Sym: "b"}, {Sym: "x"}},
1208+ {{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}, {Sym: "x"}},
1209+ {{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1210+ {{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1211+ {{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
1212+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}},
1213+ {{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}},
1214+ {{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}},
1215+ {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
1216+ {{Sym: ">"}, {Sym: "j"}},
1217+ {{Sym: "<"}, {Sym: "j"}},
1218+ {{Sym: "g"}, {Sym: "o", Mods: ModShift}},
1219+ {{Sym: "]"}, {Sym: "]"}},
1220+ }
1221+ corpus = append(corpus, adjacentPairs...)
1222+
1223+ seen := make(map[string]struct{}, len(corpus))
1224+ for _, seq := range corpus {
1225+ canonical := seq.String()
1226+ if _, ok := seen[canonical]; ok {
1227+ continue
1228+ }
1229+ seen[canonical] = struct{}{}
1230+ t.Run(canonical, func(t *testing.T) {
1231+ got, err := ParseBinding(canonical)
1232+ if err != nil {
1233+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1234+ }
1235+ if !got.Equal(seq) {
1236+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
1237+ }
1238+ if again := got.String(); again != canonical {
1239+ t.Fatalf("canonical unstable %q -> %q", canonical, again)
1240+ }
1241+ })
1242+ }
1243+}
A internal/domain/vimkeys/trie.go +84 -0
@@ -0,0 +1,84 @@
1+package vimkeys
2+
3+import "errors"
4+
5+// Node describes the trie state at the end of a walked sequence.
6+type Node struct {
7+ Action string
8+ Exact bool
9+ HasChildren bool
10+}
11+
12+// ErrConflict is returned when inserting a duplicate binding.
13+var ErrConflict = errors.New("vimkeys: duplicate binding")
14+
15+const maxCount = 9999
16+
17+type trieNode struct {
18+ children map[Key]*trieNode
19+ action string
20+ hasAction bool
21+}
22+
23+// Trie stores Vim key sequence bindings.
24+type Trie struct {
25+ root *trieNode
26+ depth int
27+}
28+
29+// NewTrie creates an empty binding trie.
30+func NewTrie() *Trie {
31+ return &Trie{root: newTrieNode()}
32+}
33+
34+func newTrieNode() *trieNode {
35+ return &trieNode{children: make(map[Key]*trieNode)}
36+}
37+
38+// Insert adds a binding for seq. Duplicate sequences return ErrConflict.
39+// Nil or empty sequences return ErrEmptyBinding.
40+func (t *Trie) Insert(seq Sequence, action string) error {
41+ if len(seq) == 0 {
42+ return ErrEmptyBinding
43+ }
44+ node := t.root
45+ for _, key := range seq {
46+ child, ok := node.children[key]
47+ if !ok {
48+ child = newTrieNode()
49+ node.children[key] = child
50+ }
51+ node = child
52+ }
53+ if node.hasAction {
54+ return ErrConflict
55+ }
56+ node.hasAction = true
57+ node.action = action
58+ if depth := len(seq); depth > t.depth {
59+ t.depth = depth
60+ }
61+ return nil
62+}
63+
64+// Walk reports the trie node reached by seq and whether the path exists.
65+func (t *Trie) Walk(seq Sequence) (Node, bool) {
66+ node := t.root
67+ for _, key := range seq {
68+ child, ok := node.children[key]
69+ if !ok {
70+ return Node{}, false
71+ }
72+ node = child
73+ }
74+ return Node{
75+ Action: node.action,
76+ Exact: node.hasAction,
77+ HasChildren: len(node.children) > 0,
78+ }, true
79+}
80+
81+// MaxDepth returns the longest inserted sequence length.
82+func (t *Trie) MaxDepth() int {
83+ return t.depth
84+}
A internal/domain/vimkeys/trie_test.go +118 -0
@@ -0,0 +1,118 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestTrie_InsertWalkPrefixes(t *testing.T) {
9+ trie := NewTrie()
10+ bindings := []struct {
11+ seq Sequence
12+ action string
13+ }{
14+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
15+ {seq: Sequence{{Sym: "]"}, {Sym: "c"}}, action: "bracket-c"},
16+ {seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
17+ {seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
18+ }
19+ for _, binding := range bindings {
20+ if err := trie.Insert(binding.seq, binding.action); err != nil {
21+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
22+ }
23+ }
24+
25+ prefixCases := []struct {
26+ name string
27+ seq Sequence
28+ wantExact bool
29+ wantChildren bool
30+ wantAction string
31+ }{
32+ {name: "]] prefix ]", seq: Sequence{{Sym: "]"}}, wantChildren: true},
33+ {name: "]] full", seq: Sequence{{Sym: "]"}, {Sym: "]"}}, wantExact: true, wantAction: "double-bracket"},
34+ {name: "]c prefix ]", seq: Sequence{{Sym: "]"}}, wantChildren: true},
35+ {name: "]c full", seq: Sequence{{Sym: "]"}, {Sym: "c"}}, wantExact: true, wantAction: "bracket-c"},
36+ {name: "gO prefix g", seq: Sequence{{Sym: "g"}}, wantChildren: true},
37+ {name: "gO full", seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, wantExact: true, wantAction: "gO"},
38+ {name: "yah prefix y", seq: Sequence{{Sym: "y"}}, wantChildren: true},
39+ {name: "yah prefix ya", seq: Sequence{{Sym: "y"}, {Sym: "a"}}, wantChildren: true},
40+ {name: "yah full", seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, wantExact: true, wantAction: "yah"},
41+ }
42+ for _, tt := range prefixCases {
43+ t.Run(tt.name, func(t *testing.T) {
44+ node, ok := trie.Walk(tt.seq)
45+ if !ok {
46+ t.Fatalf("Walk(%v) ok = false, want true", tt.seq)
47+ }
48+ if node.Exact != tt.wantExact {
49+ t.Errorf("Walk(%v) Exact = %v, want %v", tt.seq, node.Exact, tt.wantExact)
50+ }
51+ if node.HasChildren != tt.wantChildren {
52+ t.Errorf("Walk(%v) HasChildren = %v, want %v", tt.seq, node.HasChildren, tt.wantChildren)
53+ }
54+ if tt.wantExact && node.Action != tt.wantAction {
55+ t.Errorf("Walk(%v) Action = %q, want %q", tt.seq, node.Action, tt.wantAction)
56+ }
57+ })
58+ }
59+}
60+
61+func TestTrie_InsertDuplicateReturnsConflict(t *testing.T) {
62+ trie := NewTrie()
63+ seq := Sequence{{Sym: "j"}}
64+ if err := trie.Insert(seq, "first"); err != nil {
65+ t.Fatalf("first Insert error = %v", err)
66+ }
67+ err := trie.Insert(seq, "second")
68+ if !errors.Is(err, ErrConflict) {
69+ t.Fatalf("duplicate Insert error = %v, want %v", err, ErrConflict)
70+ }
71+}
72+
73+func TestTrie_InsertEmptySequenceReturnsErrEmptyBinding(t *testing.T) {
74+ trie := NewTrie()
75+ for _, seq := range []Sequence{nil, {}} {
76+ err := trie.Insert(seq, "root-action")
77+ if !errors.Is(err, ErrEmptyBinding) {
78+ t.Fatalf("Insert(%v) error = %v, want %v", seq, err, ErrEmptyBinding)
79+ }
80+ }
81+ node, ok := trie.Walk(nil)
82+ if !ok {
83+ t.Fatal("Walk(nil) ok = false, want true for empty path")
84+ }
85+ if node.Exact || node.Action != "" {
86+ t.Fatalf("Walk(nil) after rejected empty Insert = %+v, want no root action", node)
87+ }
88+ if trie.MaxDepth() != 0 {
89+ t.Fatalf("MaxDepth() = %d after rejected empty Insert, want 0", trie.MaxDepth())
90+ }
91+}
92+
93+func TestTrie_MaxDepth(t *testing.T) {
94+ trie := NewTrie()
95+ if got := trie.MaxDepth(); got != 0 {
96+ t.Fatalf("empty MaxDepth() = %d, want 0", got)
97+ }
98+ if err := trie.Insert(Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, "yah"); err != nil {
99+ t.Fatalf("Insert(yah) error = %v", err)
100+ }
101+ if err := trie.Insert(Sequence{{Sym: "]"}, {Sym: "]"}}, "brackets"); err != nil {
102+ t.Fatalf("Insert(]]) error = %v", err)
103+ }
104+ if got := trie.MaxDepth(); got != 3 {
105+ t.Fatalf("MaxDepth() = %d, want 3", got)
106+ }
107+}
108+
109+func TestTrie_WalkMissingPath(t *testing.T) {
110+ trie := NewTrie()
111+ if err := trie.Insert(Sequence{{Sym: "j"}}, "down"); err != nil {
112+ t.Fatalf("Insert(j) error = %v", err)
113+ }
114+ _, ok := trie.Walk(Sequence{{Sym: "k"}})
115+ if ok {
116+ t.Fatal("Walk missing path ok = true, want false")
117+ }
118+}
A internal/infrastructure/cef/accessibility_capture.go +433 -0
@@ -0,0 +1,433 @@
1+package cef
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+ "sync"
10+ "sync/atomic"
11+ "syscall"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/logging"
16+ "github.com/bnema/dumber/internal/shared/syncdispatch"
17+)
18+
19+const (
20+ accessibilityCaptureQueueCapacity = 64
21+ accessibilityCaptureDirPerm = 0o700
22+ accessibilityCaptureFilePerm = 0o600
23+ accessibilityCaptureEnvName = "DUMBER_A11Y_CAPTURE"
24+ accessibilityCaptureEnvValue = "1"
25+ accessibilityCaptureKindTree = "tree"
26+ accessibilityCaptureKindLocation = "location"
27+
28+ // Per-WebView capture budgets for the durable .../a11y-capture/webview-%d
29+ // namespace. Sized to cover an ~80s measurement campaign with headroom for
30+ // frequent tree+location updates, while capping unbounded growth when the
31+ // same WebView-ID directory is reused across sessions. Existing strict
32+ // capture files count toward both budgets; writes reject before create
33+ // when either limit would be exceeded. Tests may inject smaller limits.
34+ accessibilityCaptureDefaultMaxFiles = 10_000
35+ accessibilityCaptureDefaultMaxBytes = 512 << 20 // 512 MiB
36+)
37+
38+type accessibilityStatsSnapshot struct {
39+ Count int
40+ TreeCount int
41+ LocationCount int
42+ TotalBytes int64
43+ MinBytes int
44+ MaxBytes int
45+ AverageBytes float64
46+ TotalSerializeNanos int64
47+ MaxSerializeNanos int64
48+ AverageSerializeNanos float64
49+}
50+
51+type accessibilityStats struct {
52+ mu sync.Mutex
53+ count int
54+ treeCount int
55+ locationCount int
56+ totalBytes int64
57+ minBytes int
58+ maxBytes int
59+ totalSerializeNanos int64
60+ maxSerializeNanos int64
61+}
62+
63+func (s *accessibilityStats) Observe(p accessibilityPayload) {
64+ if s == nil {
65+ return
66+ }
67+ s.mu.Lock()
68+ defer s.mu.Unlock()
69+ s.count++
70+ switch p.Kind {
71+ case accessibilityCaptureKindTree:
72+ s.treeCount++
73+ case accessibilityCaptureKindLocation:
74+ s.locationCount++
75+ }
76+ s.totalBytes += int64(p.Bytes)
77+ if s.count == 1 || p.Bytes < s.minBytes {
78+ s.minBytes = p.Bytes
79+ }
80+ if p.Bytes > s.maxBytes {
81+ s.maxBytes = p.Bytes
82+ }
83+ s.totalSerializeNanos += p.SerializeNanos
84+ if p.SerializeNanos > s.maxSerializeNanos {
85+ s.maxSerializeNanos = p.SerializeNanos
86+ }
87+}
88+
89+func (s *accessibilityStats) Snapshot() accessibilityStatsSnapshot {
90+ if s == nil {
91+ return accessibilityStatsSnapshot{}
92+ }
93+ s.mu.Lock()
94+ defer s.mu.Unlock()
95+ if s.count == 0 {
96+ return accessibilityStatsSnapshot{}
97+ }
98+ return accessibilityStatsSnapshot{
99+ Count: s.count,
100+ TreeCount: s.treeCount,
101+ LocationCount: s.locationCount,
102+ TotalBytes: s.totalBytes,
103+ MinBytes: s.minBytes,
104+ MaxBytes: s.maxBytes,
105+ AverageBytes: float64(s.totalBytes) / float64(s.count),
106+ TotalSerializeNanos: s.totalSerializeNanos,
107+ MaxSerializeNanos: s.maxSerializeNanos,
108+ AverageSerializeNanos: float64(s.totalSerializeNanos) / float64(s.count),
109+ }
110+}
111+
112+type accessibilityCaptureWorker struct {
113+ mu sync.RWMutex
114+ closed bool
115+ queue chan accessibilityPayload
116+ wg sync.WaitGroup
117+ consume func(accessibilityPayload)
118+ dropped atomic.Uint64
119+}
120+
121+func newAccessibilityCaptureWorker(capacity int, consume func(accessibilityPayload)) *accessibilityCaptureWorker {
122+ if capacity < 1 {
123+ capacity = 1
124+ }
125+ w := &accessibilityCaptureWorker{
126+ queue: make(chan accessibilityPayload, capacity),
127+ consume: consume,
128+ }
129+ w.wg.Add(1)
130+ go w.loop()
131+ return w
132+}
133+
134+func (w *accessibilityCaptureWorker) loop() {
135+ defer w.wg.Done()
136+ for p := range w.queue {
137+ if w.consume != nil {
138+ w.consume(p)
139+ }
140+ }
141+}
142+
143+func (w *accessibilityCaptureWorker) Submit(p accessibilityPayload) bool {
144+ if w == nil {
145+ return false
146+ }
147+ w.mu.RLock()
148+ defer w.mu.RUnlock()
149+ if w.closed {
150+ return false
151+ }
152+ select {
153+ case w.queue <- p:
154+ return true
155+ default:
156+ w.dropped.Add(1)
157+ return false
158+ }
159+}
160+
161+func (w *accessibilityCaptureWorker) Dropped() uint64 {
162+ if w == nil {
163+ return 0
164+ }
165+ return w.dropped.Load()
166+}
167+
168+func (w *accessibilityCaptureWorker) Close() {
169+ if w == nil {
170+ return
171+ }
172+ w.mu.Lock()
173+ if w.closed {
174+ w.mu.Unlock()
175+ return
176+ }
177+ w.closed = true
178+ close(w.queue)
179+ w.mu.Unlock()
180+ w.wg.Wait()
181+}
182+
183+type accessibilityCapture struct {
184+ mu sync.Mutex
185+ dir string
186+ seq uint64
187+ fileCount int
188+ totalBytes int64
189+ maxFiles int
190+ maxBytes int64
191+}
192+
193+func accessibilityCaptureEnabled() bool {
194+ return os.Getenv(accessibilityCaptureEnvName) == accessibilityCaptureEnvValue
195+}
196+
197+func accessibilityCaptureDir(webViewID port.WebViewID) (string, error) {
198+ logDir, err := config.GetLogDir()
199+ if err != nil {
200+ return "", err
201+ }
202+ return filepath.Join(logDir, "a11y-capture", fmt.Sprintf("webview-%d", webViewID)), nil
203+}
204+
205+func validateAccessibilityCaptureDir(dir string) error {
206+ if dir == "" || dir == "." {
207+ return fmt.Errorf("accessibility capture dir rejected: %q", dir)
208+ }
209+ for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
210+ if part == ".." {
211+ return fmt.Errorf("accessibility capture dir rejected: %q", dir)
212+ }
213+ }
214+ return nil
215+}
216+
217+// parseAccessibilityCaptureName accepts only strict numbered capture names
218+// produced by Write: NNNNNN-(tree|location).json with a non-zero sequence.
219+func parseAccessibilityCaptureName(name string) (seq uint64, ok bool) {
220+ if !strings.HasSuffix(name, ".json") {
221+ return 0, false
222+ }
223+ base := strings.TrimSuffix(name, ".json")
224+ seqPart, kind, cut := strings.Cut(base, "-")
225+ if !cut || len(seqPart) != 6 || !accessibilityCaptureKindAllowed(kind) {
226+ return 0, false
227+ }
228+ for _, r := range seqPart {
229+ if r < '0' || r > '9' {
230+ return 0, false
231+ }
232+ }
233+ n, err := strconv.ParseUint(seqPart, 10, 64)
234+ if err != nil || n == 0 {
235+ return 0, false
236+ }
237+ return n, true
238+}
239+
240+// scanAccessibilityCaptureDir accounts only strict regular capture files.
241+// Symlinks and non-regular entries are ignored (never followed). Gaps are
242+// allowed; the returned sequence is the maximum numbered name present.
243+func scanAccessibilityCaptureDir(dir string) (maxSeq uint64, fileCount int, totalBytes int64, err error) {
244+ entries, err := os.ReadDir(dir)
245+ if err != nil {
246+ return 0, 0, 0, fmt.Errorf("read accessibility capture dir: %w", err)
247+ }
248+ for _, entry := range entries {
249+ name := entry.Name()
250+ seq, ok := parseAccessibilityCaptureName(name)
251+ if !ok {
252+ continue
253+ }
254+ path := filepath.Join(dir, name)
255+ info, lstatErr := os.Lstat(path)
256+ if lstatErr != nil {
257+ return 0, 0, 0, fmt.Errorf("lstat accessibility capture %s: %w", name, lstatErr)
258+ }
259+ if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
260+ continue
261+ }
262+ fileCount++
263+ totalBytes += info.Size()
264+ if seq > maxSeq {
265+ maxSeq = seq
266+ }
267+ }
268+ return maxSeq, fileCount, totalBytes, nil
269+}
270+
271+func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
272+ if err := validateAccessibilityCaptureDir(dir); err != nil {
273+ return nil, err
274+ }
275+ if err := os.MkdirAll(dir, accessibilityCaptureDirPerm); err != nil {
276+ return nil, fmt.Errorf("mkdir accessibility capture dir: %w", err)
277+ }
278+ if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil {
279+ return nil, fmt.Errorf("chmod accessibility capture dir: %w", err)
280+ }
281+ maxSeq, fileCount, totalBytes, err := scanAccessibilityCaptureDir(dir)
282+ if err != nil {
283+ return nil, err
284+ }
285+ return &accessibilityCapture{
286+ dir: dir,
287+ seq: maxSeq,
288+ fileCount: fileCount,
289+ totalBytes: totalBytes,
290+ maxFiles: accessibilityCaptureDefaultMaxFiles,
291+ maxBytes: accessibilityCaptureDefaultMaxBytes,
292+ }, nil
293+}
294+
295+func accessibilityCaptureKindAllowed(kind string) bool {
296+ switch kind {
297+ case accessibilityCaptureKindTree, accessibilityCaptureKindLocation:
298+ return true
299+ default:
300+ return false
301+ }
302+}
303+
304+func (c *accessibilityCapture) Write(p accessibilityPayload) error {
305+ if c == nil || p.JSON == "" {
306+ return nil
307+ }
308+ if !accessibilityCaptureKindAllowed(p.Kind) {
309+ return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind)
310+ }
311+ payloadBytes := int64(len(p.JSON))
312+ c.mu.Lock()
313+ defer c.mu.Unlock()
314+ if c.fileCount >= c.maxFiles {
315+ return fmt.Errorf("accessibility capture file budget exceeded (%d files)", c.maxFiles)
316+ }
317+ if c.totalBytes+payloadBytes > c.maxBytes {
318+ return fmt.Errorf("accessibility capture byte budget exceeded (%d bytes)", c.maxBytes)
319+ }
320+ next := c.seq + 1
321+ name := fmt.Sprintf("%06d-%s.json", next, p.Kind)
322+ path := filepath.Join(c.dir, name)
323+ file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm)
324+ if err != nil {
325+ return err
326+ }
327+ c.seq = next
328+ c.fileCount++
329+ written, writeErr := file.WriteString(p.JSON)
330+ c.totalBytes += int64(written)
331+ chmodErr := file.Chmod(accessibilityCaptureFilePerm)
332+ closeErr := file.Close()
333+ if writeErr != nil {
334+ return writeErr
335+ }
336+ if chmodErr != nil {
337+ return chmodErr
338+ }
339+ return closeErr
340+}
341+
342+func (wv *WebView) enableAccessibilityCaptureIfRequested() error {
343+ if wv == nil || !accessibilityCaptureEnabled() {
344+ return nil
345+ }
346+ dir, err := accessibilityCaptureDir(wv.id)
347+ if err != nil {
348+ return err
349+ }
350+ capture, err := newAccessibilityCapture(dir)
351+ if err != nil {
352+ return err
353+ }
354+ wv.a11yCapture = capture
355+ wv.a11yWorker = newAccessibilityCaptureWorker(accessibilityCaptureQueueCapacity, wv.consumeAccessibilityPayload)
356+ logAccessibilityCaptureEnabled(wv.ctx, wv.id, dir)
357+ return nil
358+}
359+
360+func (wv *WebView) consumeAccessibilityPayload(p accessibilityPayload) {
361+ if wv == nil {
362+ return
363+ }
364+ wv.a11yStats.Observe(p)
365+ logAccessibilityUpdate(wv.ctx, wv.id, p)
366+ if wv.a11yCapture == nil {
367+ return
368+ }
369+ if err := wv.a11yCapture.Write(p); err != nil {
370+ logging.FromContext(wv.ctx).Warn().Err(err).
371+ Uint64("webview_id", uint64(wv.id)).
372+ Msg("cef: accessibility capture write failed")
373+ }
374+}
375+
376+// abortAccessibilityCaptureSetup closes a capture worker started during
377+// newWebView without emitting Destroy's summary. Safe to call when unset.
378+func (wv *WebView) abortAccessibilityCaptureSetup() {
379+ wv.finalizeAccessibilityCapture(false)
380+}
381+
382+// failNewWebViewAfterAccessibilityCapture centralizes cleanup for every
383+// newWebView error return after enableAccessibilityCaptureIfRequested may have
384+// started the worker. Closes the worker exactly once without Destroy summary.
385+func (wv *WebView) failNewWebViewAfterAccessibilityCapture(
386+ operation string,
387+ result syncdispatch.SyncDispatchResult,
388+) error {
389+ wv.abortAccessibilityCaptureSetup()
390+ wv.destroyViewBridgeOnGTKAsync()
391+ return errGTKSyncDispatchIncomplete(operation, result)
392+}
393+
394+// newWebViewInstallViewportSyncHooks is the viewport-hook install used by
395+// newWebView. Tests replace it to force incomplete GTK dispatch after capture
396+// init without a live display.
397+var newWebViewInstallViewportSyncHooks = func(wv *WebView) syncdispatch.SyncDispatchResult {
398+ return wv.installViewportSyncHooks()
399+}
400+
401+// installNewWebViewViewportHooksAfterCapture installs viewport sync hooks and
402+// aborts capture setup if GTK dispatch does not complete.
403+func (wv *WebView) installNewWebViewViewportHooksAfterCapture() error {
404+ result := newWebViewInstallViewportSyncHooks(wv)
405+ if result.Completed() {
406+ return nil
407+ }
408+ return wv.failNewWebViewAfterAccessibilityCapture("install CEF viewport sync hooks", result)
409+}
410+
411+func (wv *WebView) shutdownAccessibilityCapture() {
412+ wv.finalizeAccessibilityCapture(true)
413+}
414+
415+// finalizeAccessibilityCapture claims one-shot ownership of worker close.
416+// Pointers stay published so concurrent enqueueAccessibilityPayload reads are
417+// race-free; Submit observes the closed worker and returns false.
418+func (wv *WebView) finalizeAccessibilityCapture(emitSummary bool) {
419+ if wv == nil {
420+ return
421+ }
422+ if !wv.a11yCaptureFinalized.CompareAndSwap(false, true) {
423+ return
424+ }
425+ worker := wv.a11yWorker
426+ if worker == nil {
427+ return
428+ }
429+ worker.Close()
430+ if emitSummary {
431+ logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())
432+ }
433+}
A internal/infrastructure/cef/accessibility_capture_test.go +649 -0
@@ -0,0 +1,649 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/dumber/internal/application/port"
15+ "github.com/bnema/dumber/internal/logging"
16+ "github.com/bnema/dumber/internal/shared/syncdispatch"
17+ purecef "github.com/bnema/purego-cef/cef"
18+ "github.com/rs/zerolog"
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+func TestAccessibilityCapture_WritesNumberedJSONWithSecurePerms(t *testing.T) {
24+ dir := t.TempDir()
25+ capture, err := newAccessibilityCapture(dir)
26+ require.NoError(t, err)
27+
28+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"a":1}`}))
29+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"b":2}`}))
30+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
31+
32+ want := []string{"000001-tree.json", "000002-location.json", "000003-tree.json"}
33+ entries, err := os.ReadDir(dir)
34+ require.NoError(t, err)
35+ require.Len(t, entries, 3)
36+ for i, name := range want {
37+ assert.Equal(t, name, entries[i].Name())
38+ info, err := entries[i].Info()
39+ require.NoError(t, err)
40+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
41+ }
42+}
43+
44+func TestAccessibilityCapture_IgnoresEmptyPayload(t *testing.T) {
45+ dir := t.TempDir()
46+ capture, err := newAccessibilityCapture(dir)
47+ require.NoError(t, err)
48+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: ""}))
49+ entries, err := os.ReadDir(dir)
50+ require.NoError(t, err)
51+ assert.Empty(t, entries)
52+}
53+
54+func TestAccessibilityCapture_RejectsUnsafePaths(t *testing.T) {
55+ for _, dir := range []string{"", ".", "foo/../bar", "../escape", "a/../../b"} {
56+ _, err := newAccessibilityCapture(dir)
57+ require.Error(t, err, "dir=%q", dir)
58+ }
59+}
60+
61+func TestAccessibilityCapture_RejectsPathLikeAndUnknownKinds(t *testing.T) {
62+ dir := t.TempDir()
63+ capture, err := newAccessibilityCapture(dir)
64+ require.NoError(t, err)
65+
66+ for _, kind := range []string{
67+ "../escape",
68+ "tree/../../etc/passwd",
69+ "tree/foo",
70+ "/tmp/x",
71+ "unknown",
72+ "TREE",
73+ "",
74+ "tree\x00",
75+ } {
76+ writeErr := capture.Write(accessibilityPayload{Kind: kind, JSON: `{"x":1}`})
77+ require.Error(t, writeErr, "kind=%q", kind)
78+ }
79+
80+ entries, err := os.ReadDir(dir)
81+ require.NoError(t, err)
82+ assert.Empty(t, entries)
83+}
84+
85+func TestAccessibilityCapture_NeverOverwritesExistingNumberedFile(t *testing.T) {
86+ dir := t.TempDir()
87+ preexisting := filepath.Join(dir, "000001-tree.json")
88+ const original = `{"keep":true}`
89+ require.NoError(t, os.WriteFile(preexisting, []byte(original), 0o600))
90+
91+ capture, err := newAccessibilityCapture(dir)
92+ require.NoError(t, err)
93+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"append":true}`}))
94+
95+ got, err := os.ReadFile(preexisting)
96+ require.NoError(t, err)
97+ assert.Equal(t, original, string(got))
98+
99+ appended, err := os.ReadFile(filepath.Join(dir, "000002-tree.json"))
100+ require.NoError(t, err)
101+ assert.JSONEq(t, `{"append":true}`, string(appended))
102+
103+ entries, err := os.ReadDir(dir)
104+ require.NoError(t, err)
105+ require.Len(t, entries, 2)
106+}
107+
108+func TestAccessibilityCapture_ReusedDirInitializesMaxSequence(t *testing.T) {
109+ dir := t.TempDir()
110+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"a":1}`), 0o600))
111+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000003-location.json"), []byte(`{"b":2}`), 0o600))
112+ require.NoError(t, os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o600))
113+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000002-other.json"), []byte(`{}`), 0o600))
114+
115+ capture, err := newAccessibilityCapture(dir)
116+ require.NoError(t, err)
117+ require.Equal(t, uint64(3), capture.seq)
118+ require.Equal(t, 2, capture.fileCount)
119+ require.Equal(t, int64(len(`{"a":1}`)+len(`{"b":2}`)), capture.totalBytes)
120+
121+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
122+ _, err = os.Stat(filepath.Join(dir, "000004-tree.json"))
123+ require.NoError(t, err)
124+ _, err = os.Stat(filepath.Join(dir, "000002-tree.json"))
125+ require.Error(t, err)
126+}
127+
128+func TestAccessibilityCapture_BudgetsIncludeExistingAndRejectBeforeCreate(t *testing.T) {
129+ dir := t.TempDir()
130+ existing := []byte(`{"keep":true}`)
131+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), existing, 0o600))
132+
133+ capture, err := newAccessibilityCapture(dir)
134+ require.NoError(t, err)
135+ capture.maxFiles = 2
136+ capture.maxBytes = int64(len(existing) + 8)
137+
138+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"ok":1}`}))
139+ require.Equal(t, 2, capture.fileCount)
140+ require.FileExists(t, filepath.Join(dir, "000002-location.json"))
141+
142+ before, err := os.ReadDir(dir)
143+ require.NoError(t, err)
144+ require.Len(t, before, 2)
145+
146+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `x`}))
147+ afterFiles, err := os.ReadDir(dir)
148+ require.NoError(t, err)
149+ require.Len(t, afterFiles, 2, "file-budget reject must not create a file")
150+ require.Equal(t, uint64(2), capture.seq, "file-budget reject must not advance sequence")
151+
152+ capture.maxFiles = 10
153+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `too-large`}))
154+ afterBytes, err := os.ReadDir(dir)
155+ require.NoError(t, err)
156+ require.Len(t, afterBytes, 2, "byte-budget reject must not create a file")
157+ require.Equal(t, uint64(2), capture.seq, "byte-budget reject must not advance sequence")
158+}
159+
160+func TestAccessibilityCapture_ScanIgnoresSymlinkAndNonRegular(t *testing.T) {
161+ dir := t.TempDir()
162+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"ok":1}`), 0o600))
163+ require.NoError(t, os.Symlink(filepath.Join(dir, "000001-tree.json"), filepath.Join(dir, "000009-tree.json")))
164+ require.NoError(t, os.Mkdir(filepath.Join(dir, "000008-location.json"), 0o700))
165+
166+ capture, err := newAccessibilityCapture(dir)
167+ require.NoError(t, err)
168+ require.Equal(t, uint64(1), capture.seq)
169+ require.Equal(t, 1, capture.fileCount)
170+ require.Equal(t, int64(len(`{"ok":1}`)), capture.totalBytes)
171+
172+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"n":2}`}))
173+ require.FileExists(t, filepath.Join(dir, "000002-location.json"))
174+ info, err := os.Lstat(filepath.Join(dir, "000009-tree.json"))
175+ require.NoError(t, err)
176+ assert.NotZero(t, info.Mode()&os.ModeSymlink)
177+}
178+
179+func TestAccessibilityCapture_Namespace0700AndChmodExisting(t *testing.T) {
180+ root := t.TempDir()
181+ dir := filepath.Join(root, "webview-9")
182+ require.NoError(t, os.MkdirAll(dir, 0o755))
183+ info, err := os.Stat(dir)
184+ require.NoError(t, err)
185+ require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
186+
187+ capture, err := newAccessibilityCapture(dir)
188+ require.NoError(t, err)
189+ require.NotNil(t, capture)
190+
191+ info, err = os.Stat(dir)
192+ require.NoError(t, err)
193+ assert.Equal(t, os.FileMode(0o700), info.Mode().Perm())
194+}
195+
196+func TestAccessibilityCapture_ConcurrentCounterNoCollision(t *testing.T) {
197+ dir := t.TempDir()
198+ capture, err := newAccessibilityCapture(dir)
199+ require.NoError(t, err)
200+
201+ const n = 32
202+ var wg sync.WaitGroup
203+ var writeErrs atomic.Int64
204+ wg.Add(n)
205+ for i := 0; i < n; i++ {
206+ go func() {
207+ defer wg.Done()
208+ if writeErr := capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}); writeErr != nil {
209+ writeErrs.Add(1)
210+ }
211+ }()
212+ }
213+ wg.Wait()
214+ require.Equal(t, int64(0), writeErrs.Load())
215+
216+ entries, err := os.ReadDir(dir)
217+ require.NoError(t, err)
218+ require.Len(t, entries, n)
219+ seen := make(map[string]struct{}, n)
220+ for _, e := range entries {
221+ _, dup := seen[e.Name()]
222+ assert.False(t, dup, "duplicate %s", e.Name())
223+ seen[e.Name()] = struct{}{}
224+ }
225+}
226+
227+func TestAccessibilityCapture_TwoWebViewIDsIsolated(t *testing.T) {
228+ root := t.TempDir()
229+ dir1 := filepath.Join(root, "webview-1")
230+ dir2 := filepath.Join(root, "webview-2")
231+ c1, err := newAccessibilityCapture(dir1)
232+ require.NoError(t, err)
233+ c2, err := newAccessibilityCapture(dir2)
234+ require.NoError(t, err)
235+
236+ var wg sync.WaitGroup
237+ var writeErrs atomic.Int64
238+ wg.Add(2)
239+ go func() {
240+ defer wg.Done()
241+ if writeErr := c1.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":1}`}); writeErr != nil {
242+ writeErrs.Add(1)
243+ }
244+ }()
245+ go func() {
246+ defer wg.Done()
247+ if writeErr := c2.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":2}`}); writeErr != nil {
248+ writeErrs.Add(1)
249+ }
250+ }()
251+ wg.Wait()
252+ require.Equal(t, int64(0), writeErrs.Load())
253+
254+ b1, err := os.ReadFile(filepath.Join(dir1, "000001-tree.json"))
255+ require.NoError(t, err)
256+ b2, err := os.ReadFile(filepath.Join(dir2, "000001-tree.json"))
257+ require.NoError(t, err)
258+ assert.JSONEq(t, `{"id":1}`, string(b1))
259+ assert.JSONEq(t, `{"id":2}`, string(b2))
260+}
261+
262+func TestAccessibilityCaptureDir_UsesLogDirNamespace(t *testing.T) {
263+ home := t.TempDir()
264+ t.Setenv("ENV", "")
265+ t.Setenv("HOME", home)
266+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
267+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
268+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
269+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
270+
271+ dir, err := accessibilityCaptureDir(42)
272+ require.NoError(t, err)
273+ assert.Equal(t, filepath.Join(home, ".local", "state", "dumber", "logs", "a11y-capture", "webview-42"), dir)
274+}
275+
276+func TestAccessibilityCaptureEnabled_RequiresExactOne(t *testing.T) {
277+ t.Setenv("DUMBER_A11Y_CAPTURE", "")
278+ assert.False(t, accessibilityCaptureEnabled())
279+ t.Setenv("DUMBER_A11Y_CAPTURE", "0")
280+ assert.False(t, accessibilityCaptureEnabled())
281+ t.Setenv("DUMBER_A11Y_CAPTURE", "true")
282+ assert.False(t, accessibilityCaptureEnabled())
283+ t.Setenv("DUMBER_A11Y_CAPTURE", "1")
284+ assert.True(t, accessibilityCaptureEnabled())
285+}
286+
287+func TestAccessibilityCaptureWorker_DropsWhenFullThenCloseDrains(t *testing.T) {
288+ var (
289+ mu sync.Mutex
290+ got []accessibilityPayload
291+ )
292+ w := &accessibilityCaptureWorker{
293+ queue: make(chan accessibilityPayload, 2),
294+ consume: func(p accessibilityPayload) {
295+ mu.Lock()
296+ got = append(got, p)
297+ mu.Unlock()
298+ },
299+ }
300+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "1"}))
301+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "2"}))
302+ require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "3"}))
303+ assert.Equal(t, uint64(1), w.Dropped())
304+
305+ w.wg.Add(1)
306+ go w.loop()
307+ w.Close()
308+
309+ mu.Lock()
310+ defer mu.Unlock()
311+ require.Len(t, got, 2)
312+ assert.Equal(t, "1", got[0].JSON)
313+ assert.Equal(t, "2", got[1].JSON)
314+ require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "4"}))
315+}
316+
317+func TestAccessibilityCaptureWorker_NonBlockingUnderBackpressure(t *testing.T) {
318+ block := make(chan struct{})
319+ started := make(chan struct{})
320+ var consumed atomic.Int64
321+ w := newAccessibilityCaptureWorker(2, func(accessibilityPayload) {
322+ select {
323+ case <-started:
324+ default:
325+ close(started)
326+ }
327+ <-block
328+ consumed.Add(1)
329+ })
330+
331+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "a"}))
332+ <-started
333+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "b"}))
334+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "c"}))
335+
336+ done := make(chan struct{})
337+ go func() {
338+ defer close(done)
339+ assert.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "d"}))
340+ }()
341+ select {
342+ case <-done:
343+ case <-time.After(2 * time.Second):
344+ t.Fatal("Submit blocked under backpressure")
345+ }
346+ assert.Equal(t, uint64(1), w.Dropped())
347+
348+ close(block)
349+ w.Close()
350+ assert.Equal(t, int64(3), consumed.Load())
351+}
352+
353+func TestAccessibilityStats_ExactAverages(t *testing.T) {
354+ var stats accessibilityStats
355+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 1000, SerializeNanos: 2_000_000})
356+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 3000, SerializeNanos: 4_000_000})
357+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindLocation, Bytes: 100, SerializeNanos: 500_000})
358+
359+ snap := stats.Snapshot()
360+ assert.Equal(t, 3, snap.Count)
361+ assert.Equal(t, 2, snap.TreeCount)
362+ assert.Equal(t, 1, snap.LocationCount)
363+ assert.Equal(t, int64(4100), snap.TotalBytes)
364+ assert.Equal(t, 100, snap.MinBytes)
365+ assert.Equal(t, 3000, snap.MaxBytes)
366+ assert.InDelta(t, 1366.6666666666667, snap.AverageBytes, 1e-9)
367+ assert.Equal(t, int64(6_500_000), snap.TotalSerializeNanos)
368+ assert.InDelta(t, 2_166_666.6666666665, snap.AverageSerializeNanos, 1e-9)
369+ assert.Equal(t, int64(4_000_000), snap.MaxSerializeNanos)
370+}
371+
372+func TestAccessibilityStats_EmptySnapshotZero(t *testing.T) {
373+ var stats accessibilityStats
374+ snap := stats.Snapshot()
375+ assert.Equal(t, accessibilityStatsSnapshot{}, snap)
376+}
377+
378+func TestAccessibilityCapture_InactiveWithoutEnvZeroWork(t *testing.T) {
379+ t.Setenv("DUMBER_A11Y_CAPTURE", "")
380+ var output bytes.Buffer
381+ logger := zerolog.New(&output).Level(zerolog.DebugLevel)
382+ ctx := logging.WithContext(context.Background(), logger)
383+
384+ wv := &WebView{ctx: ctx, id: port.WebViewID(7), viewBridge: &Cef2gtkAdapter{}}
385+ require.Nil(t, wv.a11yWorker)
386+ require.Nil(t, wv.a11yCapture)
387+
388+ rh := newDumberRenderHandler(wv)
389+ require.NotNil(t, rh)
390+ handler, ok := rh.GetAccessibilityHandler().(*accessibilityHandler)
391+ require.True(t, ok)
392+ require.Nil(t, handler.sink)
393+
394+ writeCalls := 0
395+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
396+ writeCalls++
397+ return `{"x":1}`
398+ }
399+ handler.OnAccessibilityTreeChange(stubCEFValue{})
400+ handler.OnAccessibilityLocationChange(stubCEFValue{})
401+ assert.Equal(t, 0, writeCalls)
402+ assert.Equal(t, accessibilityStatsSnapshot{}, wv.a11yStats.Snapshot())
403+ assert.Empty(t, output.String())
404+ assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
405+}
406+
407+func TestAccessibilityCapture_ConsumeLogsAndWritesOffCallback(t *testing.T) {
408+ dir := t.TempDir()
409+ capture, err := newAccessibilityCapture(dir)
410+ require.NoError(t, err)
411+
412+ var output bytes.Buffer
413+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
414+ ctx := logging.WithContext(context.Background(), logger)
415+
416+ wv := &WebView{
417+ ctx: ctx,
418+ id: port.WebViewID(3),
419+ a11yCapture: capture,
420+ }
421+ wv.a11yWorker = newAccessibilityCaptureWorker(8, wv.consumeAccessibilityPayload)
422+
423+ require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
424+ Kind: accessibilityCaptureKindTree, JSON: `{"ok":true}`, Bytes: 11, SerializeNanos: 123,
425+ }))
426+ require.Eventually(t, func() bool {
427+ _, err := os.Stat(filepath.Join(dir, "000001-tree.json"))
428+ return err == nil
429+ }, 2*time.Second, 10*time.Millisecond)
430+ wv.a11yWorker.Close()
431+
432+ snap := wv.a11yStats.Snapshot()
433+ assert.Equal(t, 1, snap.Count)
434+ assert.Equal(t, 1, snap.TreeCount)
435+ assert.Equal(t, 11, snap.MaxBytes)
436+
437+ var found bool
438+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
439+ for dec.More() {
440+ var rec map[string]any
441+ require.NoError(t, dec.Decode(&rec))
442+ if rec["message"] == "cef: accessibility update" {
443+ found = true
444+ assert.InDelta(t, float64(3), rec["webview_id"], 0)
445+ assert.Equal(t, accessibilityCaptureKindTree, rec["kind"])
446+ assert.InDelta(t, float64(11), rec["bytes"], 0)
447+ assert.InDelta(t, float64(123), rec["serialize_ns"], 0)
448+ }
449+ }
450+ assert.True(t, found)
451+}
452+
453+func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) {
454+ dir := t.TempDir()
455+ capture, err := newAccessibilityCapture(dir)
456+ require.NoError(t, err)
457+
458+ var output bytes.Buffer
459+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
460+ ctx := logging.WithContext(context.Background(), logger)
461+
462+ wv := &WebView{
463+ ctx: ctx,
464+ id: port.WebViewID(5),
465+ a11yCapture: capture,
466+ }
467+ wv.a11yWorker = newAccessibilityCaptureWorker(4, wv.consumeAccessibilityPayload)
468+ require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
469+ Kind: accessibilityCaptureKindLocation, JSON: `{}`, Bytes: 2, SerializeNanos: 50,
470+ }))
471+ require.Eventually(t, func() bool {
472+ return wv.a11yStats.Snapshot().Count == 1
473+ }, 2*time.Second, 10*time.Millisecond)
474+
475+ worker := wv.a11yWorker
476+ wv.shutdownAccessibilityCapture()
477+
478+ require.True(t, wv.a11yCaptureFinalized.Load())
479+ require.Same(t, worker, wv.a11yWorker)
480+ require.NotNil(t, wv.a11yCapture)
481+
482+ var found bool
483+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
484+ for dec.More() {
485+ var rec map[string]any
486+ require.NoError(t, dec.Decode(&rec))
487+ if rec["message"] == "cef: accessibility summary" {
488+ found = true
489+ assert.InDelta(t, float64(5), rec["webview_id"], 0)
490+ assert.InDelta(t, float64(1), rec["count"], 0)
491+ assert.InDelta(t, float64(1), rec["location_count"], 0)
492+ assert.InDelta(t, float64(0), rec["dropped"], 0)
493+ }
494+ }
495+ assert.True(t, found)
496+ require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
497+}
498+
499+func TestInstallNewWebViewViewportHooksAfterCapture_FailureClosesWorkerOnce(t *testing.T) {
500+ home := t.TempDir()
501+ t.Setenv("HOME", home)
502+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
503+ t.Setenv("DUMBER_A11Y_CAPTURE", "1")
504+
505+ prev := newWebViewInstallViewportSyncHooks
506+ t.Cleanup(func() { newWebViewInstallViewportSyncHooks = prev })
507+ newWebViewInstallViewportSyncHooks = func(*WebView) syncdispatch.SyncDispatchResult {
508+ return syncdispatch.SyncDispatchResult{
509+ Label: "cef.install_viewport_sync_hooks",
510+ Status: syncdispatch.SyncDispatchTimedOut,
511+ }
512+ }
513+
514+ var output bytes.Buffer
515+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
516+ ctx := logging.WithContext(context.Background(), logger)
517+
518+ wv := &WebView{
519+ ctx: ctx,
520+ id: port.WebViewID(99),
521+ gtkSyncDispatch: func(fn func()) {
522+ if fn != nil {
523+ fn()
524+ }
525+ },
526+ gtkSyncIsOwner: func() bool { return true },
527+ viewBridge: &Cef2gtkAdapter{},
528+ }
529+ require.NoError(t, wv.enableAccessibilityCaptureIfRequested())
530+ worker := wv.a11yWorker
531+ require.NotNil(t, worker)
532+
533+ err := wv.installNewWebViewViewportHooksAfterCapture()
534+ require.Error(t, err)
535+ require.Contains(t, err.Error(), "install CEF viewport sync hooks")
536+ require.True(t, wv.a11yCaptureFinalized.Load())
537+ require.Same(t, worker, wv.a11yWorker, "published worker pointer must stay immutable")
538+ require.NotNil(t, wv.a11yCapture, "published capture pointer must stay immutable")
539+ require.True(t, worker.closed)
540+ require.False(t, worker.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
541+ require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
542+
543+ wv.shutdownAccessibilityCapture()
544+ wv.Destroy()
545+
546+ summaryCount := 0
547+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
548+ for dec.More() {
549+ var rec map[string]any
550+ require.NoError(t, dec.Decode(&rec))
551+ if rec["message"] == "cef: accessibility summary" {
552+ summaryCount++
553+ }
554+ }
555+ assert.Equal(t, 0, summaryCount, "failed init must not emit Destroy summary")
556+}
557+
558+func TestShutdownAccessibilityCapture_IdempotentAfterAbort(t *testing.T) {
559+ dir := t.TempDir()
560+ capture, err := newAccessibilityCapture(dir)
561+ require.NoError(t, err)
562+
563+ var output bytes.Buffer
564+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
565+ ctx := logging.WithContext(context.Background(), logger)
566+
567+ wv := &WebView{ctx: ctx, id: port.WebViewID(7), a11yCapture: capture}
568+ wv.a11yWorker = newAccessibilityCaptureWorker(2, wv.consumeAccessibilityPayload)
569+
570+ wv.abortAccessibilityCaptureSetup()
571+ require.True(t, wv.a11yCaptureFinalized.Load())
572+ require.NotNil(t, wv.a11yWorker)
573+ require.True(t, wv.a11yWorker.closed)
574+ require.NotNil(t, wv.a11yCapture)
575+ wv.shutdownAccessibilityCapture()
576+ wv.shutdownAccessibilityCapture()
577+
578+ summaryCount := 0
579+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
580+ for dec.More() {
581+ var rec map[string]any
582+ require.NoError(t, dec.Decode(&rec))
583+ if rec["message"] == "cef: accessibility summary" {
584+ summaryCount++
585+ }
586+ }
587+ assert.Equal(t, 0, summaryCount)
588+}
589+
590+func TestEnqueueAccessibilityPayload_ConcurrentWithShutdownRaceFree(t *testing.T) {
591+ dir := t.TempDir()
592+ capture, err := newAccessibilityCapture(dir)
593+ require.NoError(t, err)
594+
595+ var output bytes.Buffer
596+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
597+ ctx := logging.WithContext(context.Background(), logger)
598+
599+ wv := &WebView{ctx: ctx, id: port.WebViewID(11), a11yCapture: capture}
600+ wv.a11yWorker = newAccessibilityCaptureWorker(64, wv.consumeAccessibilityPayload)
601+ worker := wv.a11yWorker
602+
603+ const producers = 8
604+ const perProducer = 200
605+ var wg sync.WaitGroup
606+ start := make(chan struct{})
607+ for range producers {
608+ wg.Add(1)
609+ go func() {
610+ defer wg.Done()
611+ <-start
612+ for range perProducer {
613+ _ = wv.enqueueAccessibilityPayload(accessibilityPayload{
614+ Kind: accessibilityCaptureKindTree, JSON: `{}`, Bytes: 2, SerializeNanos: 1,
615+ })
616+ }
617+ }()
618+ }
619+ wg.Add(1)
620+ go func() {
621+ defer wg.Done()
622+ <-start
623+ wv.shutdownAccessibilityCapture()
624+ }()
625+ close(start)
626+ wg.Wait()
627+
628+ require.True(t, wv.a11yCaptureFinalized.Load())
629+ require.Same(t, worker, wv.a11yWorker)
630+ require.NotNil(t, wv.a11yCapture)
631+ require.True(t, worker.closed)
632+ assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
633+ Kind: accessibilityCaptureKindTree, JSON: `{}`,
634+ }))
635+
636+ wv.shutdownAccessibilityCapture()
637+ wv.abortAccessibilityCaptureSetup()
638+
639+ summaryCount := 0
640+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
641+ for dec.More() {
642+ var rec map[string]any
643+ require.NoError(t, dec.Decode(&rec))
644+ if rec["message"] == "cef: accessibility summary" {
645+ summaryCount++
646+ }
647+ }
648+ assert.Equal(t, 1, summaryCount, "successful shutdown must emit exactly one summary")
649+}
A internal/infrastructure/cef/accessibility_enable_test.go +174 -0
@@ -0,0 +1,174 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+type accessibilityStateHost struct {
16+ purecef.BrowserHost
17+
18+ mu sync.Mutex
19+ states []purecef.State
20+}
21+
22+func (h *accessibilityStateHost) SetAccessibilityState(state purecef.State) {
23+ h.mu.Lock()
24+ defer h.mu.Unlock()
25+ h.states = append(h.states, state)
26+}
27+
28+func (h *accessibilityStateHost) recordedStates() []purecef.State {
29+ h.mu.Lock()
30+ defer h.mu.Unlock()
31+ out := make([]purecef.State, len(h.states))
32+ copy(out, h.states)
33+ return out
34+}
35+
36+func TestWebView_EnableAccessibility_NoOpWhenDestroyedOrHostNil(t *testing.T) {
37+ t.Parallel()
38+
39+ destroyed := &WebView{}
40+ destroyed.destroyed.Store(true)
41+ host := &accessibilityStateHost{}
42+ destroyed.host = host
43+ destroyed.EnableAccessibility()
44+ assert.Empty(t, host.recordedStates())
45+ assert.False(t, destroyed.a11yRequested.Load())
46+
47+ noHost := &WebView{}
48+ noHost.EnableAccessibility()
49+ assert.False(t, noHost.a11yEnabled.Load())
50+ assert.True(t, noHost.a11yRequested.Load(), "early request must persist while host is nil")
51+}
52+
53+func TestWebView_EnableAccessibility_IdempotentOncePerWebView(t *testing.T) {
54+ t.Parallel()
55+
56+ host := &accessibilityStateHost{}
57+ wv := &WebView{host: host}
58+
59+ wv.EnableAccessibility()
60+ wv.EnableAccessibility()
61+ wv.EnableAccessibility()
62+
63+ states := host.recordedStates()
64+ require.Len(t, states, 1)
65+ assert.Equal(t, purecef.StateStateEnabled, states[0])
66+ assert.True(t, wv.a11yEnabled.Load())
67+}
68+
69+func TestWebView_EnableAccessibility_IndependentPerWebView(t *testing.T) {
70+ t.Parallel()
71+
72+ hostA := &accessibilityStateHost{}
73+ hostB := &accessibilityStateHost{}
74+ wvA := &WebView{host: hostA}
75+ wvB := &WebView{host: hostB}
76+
77+ wvA.EnableAccessibility()
78+ wvA.EnableAccessibility()
79+ wvB.EnableAccessibility()
80+
81+ require.Len(t, hostA.recordedStates(), 1)
82+ require.Len(t, hostB.recordedStates(), 1)
83+ assert.Equal(t, purecef.StateStateEnabled, hostA.recordedStates()[0])
84+ assert.Equal(t, purecef.StateStateEnabled, hostB.recordedStates()[0])
85+}
86+
87+func TestWebView_EnableAccessibility_PersistsUntilHostAttachment(t *testing.T) {
88+ t.Parallel()
89+
90+ wv := &WebView{}
91+ wv.EnableAccessibility()
92+ assert.True(t, wv.a11yRequested.Load())
93+ assert.False(t, wv.a11yEnabled.Load())
94+
95+ host := &accessibilityStateHost{}
96+ wv.host = host
97+ wv.applyAccessibilityIfReady()
98+
99+ require.Len(t, host.recordedStates(), 1)
100+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
101+ assert.True(t, wv.a11yEnabled.Load())
102+}
103+
104+func TestWebView_EnableAccessibility_ConcurrentCallsEnableOnce(t *testing.T) {
105+ t.Parallel()
106+
107+ host := &accessibilityStateHost{}
108+ wv := &WebView{host: host}
109+
110+ var wg sync.WaitGroup
111+ for range 32 {
112+ wg.Add(1)
113+ go func() {
114+ defer wg.Done()
115+ wv.EnableAccessibility()
116+ }()
117+ }
118+ wg.Wait()
119+
120+ require.Len(t, host.recordedStates(), 1)
121+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
122+}
123+
124+func TestWebView_EnableAccessibility_RequestThenDestroyBeforeAttachmentNeverEnables(t *testing.T) {
125+ t.Parallel()
126+
127+ wv := &WebView{}
128+ wv.EnableAccessibility()
129+ assert.True(t, wv.a11yRequested.Load())
130+
131+ wv.Destroy()
132+
133+ host := &accessibilityStateHost{}
134+ wv.host = host
135+ wv.applyAccessibilityIfReady()
136+ wv.EnableAccessibility()
137+
138+ assert.Empty(t, host.recordedStates())
139+ assert.False(t, wv.a11yEnabled.Load())
140+}
141+
142+func TestFinishAfterCreated_AppliesPendingAccessibilityOnce(t *testing.T) {
143+ t.Parallel()
144+
145+ wv := &WebView{ctx: context.Background()}
146+ wv.EnableAccessibility()
147+ assert.False(t, wv.a11yEnabled.Load())
148+
149+ host := &accessibilityStateHost{}
150+ wv.host = host
151+ h := &handlerSet{wv: wv}
152+ h.finishAfterCreated(nil, host, afterCreatedState{})
153+ h.finishAfterCreated(nil, host, afterCreatedState{})
154+
155+ require.Len(t, host.recordedStates(), 1)
156+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
157+}
158+
159+func TestFinishAfterCreated_SkipsWhenNoPendingRequest(t *testing.T) {
160+ t.Parallel()
161+
162+ host := &accessibilityStateHost{}
163+ wv := &WebView{ctx: context.Background(), host: host}
164+ h := &handlerSet{wv: wv}
165+ h.finishAfterCreated(nil, host, afterCreatedState{})
166+
167+ assert.Empty(t, host.recordedStates())
168+ assert.False(t, wv.a11yEnabled.Load())
169+}
170+
171+func TestWebView_ImplementsAccessibilityEnabler(t *testing.T) {
172+ t.Parallel()
173+ var _ port.AccessibilityEnabler = (*WebView)(nil)
174+}
A internal/infrastructure/cef/accessibility_handler.go +62 -0
@@ -0,0 +1,62 @@
1+package cef
2+
3+import (
4+ "time"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+)
8+
9+// accessibilityPayload is the UI-thread serialization result handed to a
10+// non-blocking sink. Capture, stats, and I/O happen off the CEF UI thread.
11+type accessibilityPayload struct {
12+ Kind string
13+ JSON string
14+ Bytes int
15+ SerializeNanos int64
16+}
17+
18+type accessibilityHandler struct {
19+ sink func(accessibilityPayload) bool
20+ writeJSON func(purecef.Value, purecef.JsonWriterOptions) string
21+}
22+
23+var _ purecef.AccessibilityHandler = (*accessibilityHandler)(nil)
24+
25+func newAccessibilityHandler(sink func(accessibilityPayload) bool) purecef.AccessibilityHandler {
26+ return &accessibilityHandler{
27+ sink: sink,
28+ writeJSON: purecef.WriteJson,
29+ }
30+}
31+
32+func (h *accessibilityHandler) OnAccessibilityTreeChange(value purecef.Value) {
33+ h.emit(accessibilityCaptureKindTree, value)
34+}
35+
36+func (h *accessibilityHandler) OnAccessibilityLocationChange(value purecef.Value) {
37+ h.emit(accessibilityCaptureKindLocation, value)
38+}
39+
40+func (h *accessibilityHandler) emit(kind string, value purecef.Value) {
41+ if h == nil || h.sink == nil {
42+ return
43+ }
44+ if value == nil {
45+ return
46+ }
47+ started := time.Now()
48+ jsonText := h.writeJSON(value, purecef.JsonWriterOptionsJsonWriterDefault)
49+ h.sink(accessibilityPayload{
50+ Kind: kind,
51+ JSON: jsonText,
52+ Bytes: len(jsonText),
53+ SerializeNanos: time.Since(started).Nanoseconds(),
54+ })
55+}
56+
57+func (wv *WebView) enqueueAccessibilityPayload(p accessibilityPayload) bool {
58+ if wv == nil || wv.a11yWorker == nil {
59+ return false
60+ }
61+ return wv.a11yWorker.Submit(p)
62+}
A internal/infrastructure/cef/accessibility_handler_test.go +153 -0
@@ -0,0 +1,153 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type stubCEFValue struct{}
13+
14+func (stubCEFValue) IsValid() bool { return true }
15+func (stubCEFValue) IsOwned() bool { return false }
16+func (stubCEFValue) IsReadOnly() bool { return true }
17+func (stubCEFValue) IsSame(purecef.Value) bool { return false }
18+func (stubCEFValue) IsEqual(purecef.Value) bool { return false }
19+func (stubCEFValue) Copy() purecef.Value { return stubCEFValue{} }
20+func (stubCEFValue) GetType() purecef.ValueType { return 0 }
21+func (stubCEFValue) GetBool() int32 { return 0 }
22+func (stubCEFValue) GetInt() int32 { return 0 }
23+func (stubCEFValue) GetDouble() float64 { return 0 }
24+func (stubCEFValue) GetString() string { return "" }
25+func (stubCEFValue) GetBinary() purecef.BinaryValue { return nil }
26+func (stubCEFValue) GetDictionary() purecef.DictionaryValue { return nil }
27+func (stubCEFValue) GetList() purecef.ListValue { return nil }
28+func (stubCEFValue) SetNull() int32 { return 0 }
29+func (stubCEFValue) SetBool(int32) int32 { return 0 }
30+func (stubCEFValue) SetInt(int32) int32 { return 0 }
31+func (stubCEFValue) SetDouble(float64) int32 { return 0 }
32+func (stubCEFValue) SetString(string) int32 { return 0 }
33+func (stubCEFValue) SetBinary(purecef.BinaryValue) int32 { return 0 }
34+func (stubCEFValue) SetDictionary(purecef.DictionaryValue) int32 { return 0 }
35+func (stubCEFValue) SetList(purecef.ListValue) int32 { return 0 }
36+
37+func TestRenderHandlerExposesAccessibilityAtWrapTime(t *testing.T) {
38+ h := &dumberRenderHandler{a11y: newAccessibilityHandler(nil)}
39+ if h.GetAccessibilityHandler() == nil {
40+ t.Fatal("CEF would omit the accessibility override at wrap time")
41+ }
42+}
43+
44+func TestRenderHandlerExposesAccessibilityWhenMainExists(t *testing.T) {
45+ // Must fail open (nil) when a11y was not installed, even if main exists —
46+ // never fall back to h.main.GetAccessibilityHandler().
47+ mainHandler := cefmocks.NewMockRenderHandler(t)
48+ h := &dumberRenderHandler{main: mainHandler, a11y: nil}
49+ assert.Nil(t, h.GetAccessibilityHandler())
50+ mainHandler.AssertNotCalled(t, "GetAccessibilityHandler")
51+
52+ h.a11y = newAccessibilityHandler(nil)
53+ require.NotNil(t, h.GetAccessibilityHandler())
54+ mainHandler.AssertNotCalled(t, "GetAccessibilityHandler")
55+}
56+
57+func TestNewDumberRenderHandlerInstallsNonNilAccessibilityHandler(t *testing.T) {
58+ require.Nil(t, newDumberRenderHandler(nil))
59+
60+ wv := &WebView{viewBridge: &Cef2gtkAdapter{}}
61+ rh := newDumberRenderHandler(wv)
62+ require.NotNil(t, rh)
63+ require.NotNil(t, rh.GetAccessibilityHandler())
64+
65+ handler, ok := rh.GetAccessibilityHandler().(*accessibilityHandler)
66+ require.True(t, ok)
67+ assert.Nil(t, handler.sink)
68+}
69+
70+func TestAccessibilityHandler_NilSinkSkipsWriteJSON(t *testing.T) {
71+ calls := 0
72+ handler, ok := newAccessibilityHandler(nil).(*accessibilityHandler)
73+ require.True(t, ok)
74+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
75+ calls++
76+ return `{"unexpected":true}`
77+ }
78+
79+ handler.OnAccessibilityTreeChange(stubCEFValue{})
80+ handler.OnAccessibilityLocationChange(stubCEFValue{})
81+ assert.Equal(t, 0, calls)
82+}
83+
84+func TestAccessibilityHandler_NilValueIgnored(t *testing.T) {
85+ calls := 0
86+ var got []accessibilityPayload
87+ handler, ok := newAccessibilityHandler(func(p accessibilityPayload) bool {
88+ got = append(got, p)
89+ return true
90+ }).(*accessibilityHandler)
91+ require.True(t, ok)
92+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
93+ calls++
94+ return `{}`
95+ }
96+
97+ handler.OnAccessibilityTreeChange(nil)
98+ handler.OnAccessibilityLocationChange(nil)
99+ assert.Equal(t, 0, calls)
100+ assert.Empty(t, got)
101+}
102+
103+func TestAccessibilityHandler_TreeAndLocationWriteJSONOnce(t *testing.T) {
104+ var writeCalls []string
105+ var got []accessibilityPayload
106+ handler, ok := newAccessibilityHandler(func(p accessibilityPayload) bool {
107+ got = append(got, p)
108+ return true
109+ }).(*accessibilityHandler)
110+ require.True(t, ok)
111+ handler.writeJSON = func(value purecef.Value, options purecef.JsonWriterOptions) string {
112+ require.Equal(t, purecef.JsonWriterOptionsJsonWriterDefault, options)
113+ require.NotNil(t, value)
114+ writeCalls = append(writeCalls, "write")
115+ return `{"ok":true}`
116+ }
117+
118+ handler.OnAccessibilityTreeChange(stubCEFValue{})
119+ handler.OnAccessibilityLocationChange(stubCEFValue{})
120+
121+ require.Len(t, writeCalls, 2)
122+ require.Len(t, got, 2)
123+ assert.Equal(t, accessibilityCaptureKindTree, got[0].Kind)
124+ assert.Equal(t, `{"ok":true}`, got[0].JSON)
125+ assert.Equal(t, len(`{"ok":true}`), got[0].Bytes)
126+ assert.GreaterOrEqual(t, got[0].SerializeNanos, int64(0))
127+ assert.Equal(t, accessibilityCaptureKindLocation, got[1].Kind)
128+ assert.Equal(t, `{"ok":true}`, got[1].JSON)
129+ assert.Equal(t, len(`{"ok":true}`), got[1].Bytes)
130+ assert.GreaterOrEqual(t, got[1].SerializeNanos, int64(0))
131+}
132+
133+func TestAccessibilityHandler_ProductionNoCaptureZeroWriteJSON(t *testing.T) {
134+ calls := 0
135+
136+ // Factory/production path without DUMBER_A11Y_CAPTURE=1: worker stays nil,
137+ // sink is truly nil, WriteJson must not run.
138+ wv := &WebView{viewBridge: &Cef2gtkAdapter{}}
139+ rh := newDumberRenderHandler(wv)
140+ require.NotNil(t, rh)
141+
142+ handler, ok := rh.GetAccessibilityHandler().(*accessibilityHandler)
143+ require.True(t, ok)
144+ require.Nil(t, handler.sink)
145+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
146+ calls++
147+ return `{}`
148+ }
149+
150+ handler.OnAccessibilityTreeChange(stubCEFValue{})
151+ handler.OnAccessibilityLocationChange(stubCEFValue{})
152+ assert.Equal(t, 0, calls)
153+}
A internal/infrastructure/cef/accessibility_trace.go +41 -0
@@ -0,0 +1,41 @@
1+package cef
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+func logAccessibilityCaptureEnabled(ctx context.Context, webViewID port.WebViewID, captureDir string) {
11+ logging.FromContext(ctx).Info().
12+ Uint64("webview_id", uint64(webViewID)).
13+ Str("capture_dir", captureDir).
14+ Msg("cef: accessibility capture enabled")
15+}
16+
17+func logAccessibilityUpdate(ctx context.Context, webViewID port.WebViewID, p accessibilityPayload) {
18+ logging.FromContext(ctx).Info().
19+ Uint64("webview_id", uint64(webViewID)).
20+ Str("kind", p.Kind).
21+ Int("bytes", p.Bytes).
22+ Int64("serialize_ns", p.SerializeNanos).
23+ Msg("cef: accessibility update")
24+}
25+
26+func logAccessibilitySummary(ctx context.Context, webViewID port.WebViewID, snap accessibilityStatsSnapshot, dropped uint64) {
27+ logging.FromContext(ctx).Info().
28+ Uint64("webview_id", uint64(webViewID)).
29+ Int("count", snap.Count).
30+ Int("tree_count", snap.TreeCount).
31+ Int("location_count", snap.LocationCount).
32+ Int64("total_bytes", snap.TotalBytes).
33+ Int("min_bytes", snap.MinBytes).
34+ Int("max_bytes", snap.MaxBytes).
35+ Float64("average_bytes", snap.AverageBytes).
36+ Int64("total_serialize_ns", snap.TotalSerializeNanos).
37+ Float64("average_serialize_ns", snap.AverageSerializeNanos).
38+ Int64("max_serialize_ns", snap.MaxSerializeNanos).
39+ Uint64("dropped", dropped).
40+ Msg("cef: accessibility summary")
41+}
A internal/infrastructure/cef/accessibility_trace_test.go +28 -0
@@ -0,0 +1,28 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/rs/zerolog"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestLogAccessibilityCaptureEnabled_Fields(t *testing.T) {
17+ var output bytes.Buffer
18+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
19+ ctx := logging.WithContext(context.Background(), logger)
20+
21+ logAccessibilityCaptureEnabled(ctx, port.WebViewID(9), "/tmp/a11y-capture/webview-9")
22+
23+ var rec map[string]any
24+ require.NoError(t, json.Unmarshal(output.Bytes(), &rec))
25+ assert.Equal(t, "cef: accessibility capture enabled", rec["message"])
26+ assert.InDelta(t, float64(9), rec["webview_id"], 0)
27+ assert.Equal(t, "/tmp/a11y-capture/webview-9", rec["capture_dir"])
28+}
M internal/infrastructure/cef/app.go +5 -5
@@ -280,16 +280,16 @@ type dumberApp struct {
280280 func newDumberApp(engine *Engine) purecef.App {
281281 app := &dumberApp{engine: engine}
282282 app.bph = &dumberBPH{engine: engine}
283- app.rph = newPopupOpenerRenderProcessHandler()
283+ app.rph = newComposedRenderProcessHandler()
284284 return app
285285 }
286286
287287 // NewSubprocessApp returns a lightweight raw App implementation for CEF
288-// subprocesses re-executed from the main dumber binary. Keep the legacy
289-// renderer bridge disabled, but allow the minimal popup-opener render handler
290-// needed to install synthetic opener state before page scripts run.
288+// subprocesses re-executed from the main dumber binary. Keep the legacy V8
289+// renderer bridge disabled while composing editable-focus reporting with the
290+// popup-opener callbacks needed before page scripts run.
291291 func NewSubprocessApp() purecef.App {
292- return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()}
292+ return &subprocessApp{rph: newComposedRenderProcessHandler()}
293293 }
294294
295295 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
M internal/infrastructure/cef/factory.go +10 -4
@@ -156,6 +156,14 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
156156 wv.handlers = handlers
157157 wv.findCtrl = newFindController()
158158
159+ // Capture worker must exist before NewClient so the lazily wrapped render
160+ // handler can install a real sink when DUMBER_A11Y_CAPTURE=1.
161+ if err := wv.enableAccessibilityCaptureIfRequested(); err != nil {
162+ logging.FromContext(ctx).Warn().Err(err).
163+ Uint64("webview_id", uint64(id)).
164+ Msg("cef: accessibility capture setup failed")
165+ }
166+
159167 // Build a CEF client backed by our handlerSet.
160168 // Store on WebView to prevent GC collection before CEF AddRef's it.
161169 wv.client = purecef.NewClient(wv.handlers)
@@ -164,10 +172,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
164172 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
165173 }
166174 }
167- viewportHooksResult := wv.installViewportSyncHooks()
168- if !viewportHooksResult.Completed() {
169- wv.destroyViewBridgeOnGTKAsync()
170- return nil, errGTKSyncDispatchIncomplete("install CEF viewport sync hooks", viewportHooksResult)
175+ if err := wv.installNewWebViewViewportHooksAfterCapture(); err != nil {
176+ return nil, err
171177 }
172178 wv.startRenderStallWatchdog()
173179 return wv, nil
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.matches(":disabled") && !element.readOnly && !hiddenInputTypes.has(element.type.toLowerCase());
50+ }
51+ if (element instanceof HTMLTextAreaElement) {
52+ return !element.matches(":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.matches(":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.matches(\":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+}
M internal/infrastructure/cef/popup_opener_renderer.go +25 -0
@@ -50,6 +50,31 @@ func decodePopupOpenerRenderMetadata(extraInfo purecef.DictionaryValue) (popupOp
5050 return metadata, true
5151 }
5252
53+// composedRenderProcessHandler keeps the popup-opener handler as the primary
54+// CEF renderer handler and adds only editable-focus reporting from the legacy
55+// renderer bridge. Embedding preserves every popup callback without enabling
56+// the bridge's V8 extension in OnWebKitInitialized.
57+type composedRenderProcessHandler struct {
58+ *popupOpenerRenderProcessHandler
59+ editableFocusHandler *rendererBridgeProcessHandler
60+}
61+
62+func newComposedRenderProcessHandler() *composedRenderProcessHandler {
63+ return &composedRenderProcessHandler{
64+ popupOpenerRenderProcessHandler: newPopupOpenerRenderProcessHandler(),
65+ editableFocusHandler: &rendererBridgeProcessHandler{},
66+ }
67+}
68+
69+func (h *composedRenderProcessHandler) OnFocusedNodeChanged(
70+ browser purecef.Browser,
71+ frame purecef.Frame,
72+ node purecef.Domnode,
73+) {
74+ h.popupOpenerRenderProcessHandler.OnFocusedNodeChanged(browser, frame, node)
75+ h.editableFocusHandler.OnFocusedNodeChanged(browser, frame, node)
76+}
77+
5378 type popupOpenerRenderProcessHandler struct {
5479 mu sync.RWMutex
5580 byBrowserID map[int32]popupOpenerRenderMetadata
M internal/infrastructure/cef/render_handler_adapter.go +11 -2
@@ -19,6 +19,7 @@ type dumberRenderHandler struct {
1919 main purecef.RenderHandler
2020 popup purecef.RenderHandler
2121 popupSurface popupSurface
22+ a11y purecef.AccessibilityHandler
2223 }
2324
2425 var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
@@ -48,6 +49,14 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
4849 return nil
4950 }
5051 h := &dumberRenderHandler{wv: wv}
52+ // Install a non-nil AccessibilityHandler before CEF wraps the render
53+ // handler. Outside capture the sink is truly nil so callbacks return before
54+ // WriteJson; capture workers (Task 2) wire enqueueAccessibilityPayload.
55+ var sink func(accessibilityPayload) bool
56+ if wv.a11yWorker != nil {
57+ sink = wv.enqueueAccessibilityPayload
58+ }
59+ h.a11y = newAccessibilityHandler(sink)
5160
5261 var unsupportedPaintOnce sync.Once
5362 hooks := startupPresentationHooks(activeStartupTrace())
@@ -110,10 +119,10 @@ func (h *dumberRenderHandler) renderTargetForPaint(elementType purecef.PaintElem
110119 }
111120
112121 func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler {
113- if h == nil || h.main == nil {
122+ if h == nil {
114123 return nil
115124 }
116- return h.main.GetAccessibilityHandler()
125+ return h.a11y
117126 }
118127
119128 func (h *dumberRenderHandler) GetRootScreenRect(browser purecef.Browser, rect *purecef.Rect) int32 {
M internal/infrastructure/cef/renderer_bridge_test.go +95 -0
@@ -267,3 +267,98 @@ func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(
267267
268268 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
269269 }
270+
271+type runtimeEditableDomnode struct {
272+ stubEditableDomnode
273+ tagName string
274+}
275+
276+func (n runtimeEditableDomnode) GetElementTagName() string { return n.tagName }
277+
278+func TestRuntimeRenderProcessHandlers_ReportEditableFocusedNodes(t *testing.T) {
279+ oldFactory := newRendererBridgeProcessMessage
280+ t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
281+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
282+ return newTestBridgeProcessMessage(name, true)
283+ }
284+
285+ apps := []struct {
286+ name string
287+ new func() purecef.App
288+ }{
289+ {name: "main process", new: func() purecef.App { return newDumberApp(&Engine{}) }},
290+ {name: "subprocess", new: NewSubprocessApp},
291+ }
292+ nodes := []struct {
293+ name string
294+ node purecef.Domnode
295+ }{
296+ {name: "input", node: runtimeEditableDomnode{stubEditableDomnode: stubEditableDomnode{editable: true}, tagName: "INPUT"}},
297+ {name: "textarea", node: runtimeEditableDomnode{stubEditableDomnode: stubEditableDomnode{editable: true}, tagName: "TEXTAREA"}},
298+ {name: "contenteditable", node: runtimeEditableDomnode{stubEditableDomnode: stubEditableDomnode{editable: true}, tagName: "DIV"}},
299+ }
300+
301+ for _, appCase := range apps {
302+ t.Run(appCase.name, func(t *testing.T) {
303+ for _, nodeCase := range nodes {
304+ t.Run(nodeCase.name, func(t *testing.T) {
305+ frame := cefmocks.NewMockFrame(t)
306+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).
307+ Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
308+ action, editable, ok := decodeRendererBridgeProcessMessage(message)
309+ require.True(t, ok)
310+ require.Equal(t, rendererBridgeActionEditableFocusChanged, action)
311+ require.Equal(t, "1", editable)
312+ }).Once()
313+
314+ handler := appCase.new().GetRenderProcessHandler()
315+ require.NotNil(t, handler)
316+ handler.OnFocusedNodeChanged(nil, frame, nodeCase.node)
317+ })
318+ }
319+ })
320+ }
321+}
322+
323+func TestRuntimeRenderProcessHandlers_PreservePopupOpenerCallbacks(t *testing.T) {
324+ apps := []struct {
325+ name string
326+ new func() purecef.App
327+ }{
328+ {name: "main process", new: func() purecef.App { return newDumberApp(&Engine{}) }},
329+ {name: "subprocess", new: NewSubprocessApp},
330+ }
331+
332+ for _, appCase := range apps {
333+ t.Run(appCase.name, func(t *testing.T) {
334+ browser := cefmocks.NewMockBrowser(t)
335+ browser.EXPECT().GetIdentifier().Return(int32(42)).Times(4)
336+ ctx := &testPopupOpenerV8Context{valid: true}
337+ handler := appCase.new().GetRenderProcessHandler()
338+ require.NotNil(t, handler)
339+ require.Nil(t, handler.GetLoadHandler())
340+
341+ handler.OnBrowserCreated(browser, testPopupOpenerDictionaryValue{
342+ strings: map[string]string{
343+ popupOpenerExtraInfoParentURIKey: "https://example.com/login",
344+ popupOpenerExtraInfoBridgeNonceKey: "bridge-nonce",
345+ },
346+ bools: map[string]int32{popupOpenerExtraInfoEnabledKey: 1},
347+ })
348+ handler.OnContextCreated(browser, stubFrame{main: true, url: "https://example.com/popup"}, ctx)
349+
350+ require.Equal(t, 1, ctx.enterCalls)
351+ require.Equal(t, 1, ctx.exitCalls)
352+ require.Contains(t, ctx.evalCode, "popup-opener-navigate")
353+ require.Contains(t, ctx.evalCode, "popup-opener-post-message")
354+ require.Equal(t, popupOpenerRenderScriptURL, ctx.evalURL)
355+ require.Zero(t, handler.OnProcessMessageReceived(nil, nil, purecef.ProcessIDPidBrowser, nil))
356+
357+ handler.OnBrowserDestroyed(browser)
358+ releasedCtx := &testPopupOpenerV8Context{valid: true}
359+ handler.OnContextCreated(browser, stubFrame{main: true, url: "https://example.com/popup"}, releasedCtx)
360+ require.Zero(t, releasedCtx.enterCalls)
361+ require.Empty(t, releasedCtx.evalCode)
362+ })
363+ }
364+}
A internal/infrastructure/cef/semantic_navigation.go +151 -0
@@ -0,0 +1,151 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+)
12+
13+// NavigateSemantic performs the initial live Vim structural navigation path.
14+// It deliberately uses only stable HTML semantics and a static script: CEF's
15+// raw accessibility payload is not yet a documented application contract.
16+func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error {
17+ if wv == nil || wv.destroyed.Load() {
18+ return errDestroyed
19+ }
20+ if request.Target != dto.SemanticNavigationTargetHeading {
21+ return fmt.Errorf("unsupported semantic navigation target: %d", request.Target)
22+ }
23+ if request.Direction != dto.SemanticNavigationForward && request.Direction != dto.SemanticNavigationBackward {
24+ return fmt.Errorf("unsupported semantic navigation direction: %d", request.Direction)
25+ }
26+
27+ count := request.Count
28+ if count < 1 {
29+ count = 1
30+ }
31+ wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor, wv.vimHeadingHighlightNamespace()))
32+ return nil
33+}
34+
35+// ClearSemanticNavigationHighlight removes the visual target left by the most
36+// recent Vim semantic navigation in this WebView.
37+func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {
38+ if wv == nil || wv.destroyed.Load() {
39+ return errDestroyed
40+ }
41+ return wv.scheduleJavaScript(clearHeadingNavigationHighlightScript(wv.vimHeadingHighlightNamespace()))
42+}
43+
44+func (wv *WebView) vimHeadingHighlightNamespace() string {
45+ return fmt.Sprintf("dumber-vim-heading-%p", wv)
46+}
47+
48+// scheduleJavaScript makes CEF UI-thread scheduling failures observable to
49+// callers that need to guarantee a transient UI state is cleared.
50+func (wv *WebView) scheduleJavaScript(script string) error {
51+ if wv == nil || wv.destroyed.Load() {
52+ return errDestroyed
53+ }
54+ wv.mu.RLock()
55+ browser := wv.browser
56+ wv.mu.RUnlock()
57+ if browser == nil {
58+ return fmt.Errorf("schedule JavaScript: browser unavailable")
59+ }
60+ if wv.engine == nil {
61+ frame := browser.GetMainFrame()
62+ if frame == nil {
63+ return fmt.Errorf("schedule JavaScript: main frame unavailable")
64+ }
65+ frame.ExecuteJavaScript(script, "", 0)
66+ return nil
67+ }
68+
69+ task := cefNewTask(cefTaskFunc(func() {
70+ wv.executeJavaScriptNow(script)
71+ }))
72+ if task == nil {
73+ return fmt.Errorf("schedule JavaScript: create CEF task")
74+ }
75+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
76+ return fmt.Errorf("schedule JavaScript: post CEF task: result %d", result)
77+ }
78+ return nil
79+}
80+
81+func headingNavigationScript(direction, count int, highlightColor, namespace string) string {
82+ if highlightColor == "" {
83+ highlightColor = "#fbbf24"
84+ }
85+ return `(() => {
86+ const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
87+ const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
88+ const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
89+ const highlightColor = ` + strconv.Quote(highlightColor) + `;
90+ const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
91+ .filter((heading) => {
92+ if (heading.getClientRects().length === 0) return false;
93+ if (heading.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false;
94+ const style = window.getComputedStyle(heading);
95+ return style.visibility !== "hidden" && style.visibility !== "collapse";
96+ });
97+ if (headings.length === 0) return;
98+
99+ const prior = window[stateKey];
100+ let index = headings.indexOf(prior);
101+ if (index < 0) {
102+ const firstAtOrAfterViewportTop = headings.findIndex(
103+ (heading) => heading.getBoundingClientRect().top >= 0,
104+ );
105+ if (` + strconv.Itoa(direction) + ` > 0) {
106+ index = firstAtOrAfterViewportTop - 1;
107+ if (index < -1) index = -1;
108+ } else {
109+ index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length;
110+ }
111+ }
112+
113+ const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `);
114+ if (next < 0 || next >= headings.length) return;
115+ const target = headings[next];
116+
117+ const previous = document.querySelector("[" + targetAttribute + "]");
118+ if (previous && previous !== target) previous.removeAttribute(targetAttribute);
119+ let style = document.querySelector("style[" + styleAttribute + "]");
120+ if (!style) {
121+ // This visual cue is best-effort: strict page CSP can reject a DOM style
122+ // element, but target selection and scrolling must stay independent of it.
123+ style = document.createElement("style");
124+ style.setAttribute(styleAttribute, "");
125+ document.documentElement.appendChild(style);
126+ }
127+ // Refresh the outline on every navigation so an in-session accent change is
128+ // reflected even when the target style element already exists.
129+ style.textContent = "[" + targetAttribute + "]" +
130+ " { outline: 3px solid " + highlightColor +
131+ " !important; outline-offset: 5px !important; border-radius: 3px !important; }";
132+ window[stateKey] = target;
133+ target.setAttribute(targetAttribute, "");
134+ target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
135+})();`
136+}
137+
138+func clearHeadingNavigationHighlightScript(namespace string) string {
139+ return `(() => {
140+ const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
141+ const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
142+ const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
143+ document.querySelectorAll("[" + targetAttribute + "]").forEach((target) => {
144+ target.removeAttribute(targetAttribute);
145+ });
146+ document.querySelectorAll("style[" + styleAttribute + "]").forEach((style) => {
147+ style.remove();
148+ });
149+ delete window[stateKey];
150+})();`
151+}
A internal/infrastructure/cef/semantic_navigation_test.go +114 -0
@@ -0,0 +1,114 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/dumber/internal/application/dto"
14+)
15+
16+func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testing.T) {
17+ browser := cefmocks.NewMockBrowser(t)
18+ frame := cefmocks.NewMockFrame(t)
19+ browser.EXPECT().GetMainFrame().Return(frame).Once()
20+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
21+ "document.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")",
22+ "heading.closest(\"[hidden],[inert],[aria-hidden=\\\"true\\\"]\")",
23+ "window.getComputedStyle(heading)",
24+ "firstAtOrAfterViewportTop",
25+ "if (next < 0 || next >= headings.length) return;",
26+ "previous && previous !== target",
27+ "scrollIntoView",
28+ "window[stateKey] = target",
29+ "const highlightColor = \"#22c55e\"",
30+ "const targetAttribute = \"data-dumber-vim-heading-",
31+ "let style = document.querySelector(\"style[\" + styleAttribute + \"]\")",
32+ "style.textContent = \"[\" + targetAttribute + \"]\"",
33+ "1 * 3",
34+ )), "", int32(0)).Once()
35+
36+ wv := &WebView{browser: browser}
37+ err := wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{
38+ Target: dto.SemanticNavigationTargetHeading,
39+ Direction: dto.SemanticNavigationForward,
40+ Count: 3,
41+ HighlightColor: "#22c55e",
42+ })
43+ require.NoError(t, err)
44+}
45+
46+func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) {
47+ browser := cefmocks.NewMockBrowser(t)
48+ frame := cefmocks.NewMockFrame(t)
49+ browser.EXPECT().GetMainFrame().Return(frame).Once()
50+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
51+ "const targetAttribute = \"data-dumber-vim-heading-",
52+ "document.querySelectorAll(\"[\" + targetAttribute + \"]\")",
53+ "target.removeAttribute(targetAttribute)",
54+ "style[\" + styleAttribute + \"]",
55+ "delete window[stateKey]",
56+ )), "", int32(0)).Once()
57+
58+ wv := &WebView{browser: browser}
59+ require.NoError(t, wv.ClearSemanticNavigationHighlight(context.Background()))
60+}
61+
62+func TestWebViewClearSemanticNavigationHighlightReportsSchedulingFailure(t *testing.T) {
63+ oldNewTask, oldPostTask := cefNewTask, cefPostTask
64+ defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
65+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
66+ cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 }
67+
68+ wv := &WebView{engine: &Engine{}, browser: cefmocks.NewMockBrowser(t)}
69+ require.Error(t, wv.ClearSemanticNavigationHighlight(context.Background()))
70+}
71+
72+func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) {
73+ forward := headingNavigationScript(1, 1, "", "test")
74+ backward := headingNavigationScript(-1, 1, "#4ade80", "test")
75+ assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1")
76+ assert.Contains(t, forward, "if (index < -1) index = -1;")
77+ assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length")
78+ assert.Contains(t, backward, "-1 * 1")
79+ assert.NotContains(t, backward, "window.innerHeight")
80+ assert.Contains(t, forward, "const highlightColor = \"#fbbf24\"")
81+ assert.Contains(t, backward, "const highlightColor = \"#4ade80\"")
82+
83+ wv := &WebView{}
84+ err := wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{
85+ Target: dto.SemanticNavigationTargetHeading,
86+ Direction: dto.SemanticNavigationForward,
87+ Count: 0,
88+ })
89+ require.NoError(t, err)
90+
91+ err = wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{
92+ Target: dto.SemanticNavigationTargetHeading + 1,
93+ Direction: dto.SemanticNavigationForward,
94+ })
95+ require.Error(t, err)
96+
97+ err = wv.NavigateSemantic(context.Background(), dto.SemanticNavigationRequest{
98+ Target: dto.SemanticNavigationTargetHeading,
99+ Direction: 0,
100+ })
101+ require.Error(t, err)
102+}
103+
104+func mockStringContaining(t *testing.T, fragments ...string) func(string) bool {
105+ t.Helper()
106+ return func(script string) bool {
107+ for _, fragment := range fragments {
108+ if !assert.Contains(t, script, fragment) {
109+ return false
110+ }
111+ }
112+ return true
113+ }
114+}
M internal/infrastructure/cef/webview.go +277 -10
@@ -21,6 +21,7 @@ import (
2121 "github.com/bnema/dumber/internal/application/dto"
2222 "github.com/bnema/dumber/internal/application/port"
2323 "github.com/bnema/dumber/internal/domain/entity"
24+ "github.com/bnema/dumber/internal/infrastructure/webutil"
2425 "github.com/bnema/dumber/internal/logging"
2526 "github.com/bnema/dumber/internal/shared/syncdispatch"
2627 )
@@ -34,6 +35,9 @@ var (
3435 _ port.PopupOpenerCapable = (*WebView)(nil)
3536 _ port.ViewportSyncCapable = (*WebView)(nil)
3637 _ port.OAuthCallbackCapable = (*WebView)(nil)
38+ _ port.PageScrollable = (*WebView)(nil)
39+ _ port.PageScrollCanceler = (*WebView)(nil)
40+ _ port.AccessibilityEnabler = (*WebView)(nil)
3741 )
3842
3943 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -69,8 +73,24 @@ var cefLoadWatchdogDelays = []time.Duration{
6973 const (
7074 cefGTKSyncDispatchTimeout = 2 * time.Second
7175 cefGTKSyncDispatchSlowThreshold = 250 * time.Millisecond
76+ maxPendingPageScroll = 3200
7277 )
7378
79+type pageScrollQueue struct {
80+ mu sync.Mutex
81+ commitMu sync.Mutex
82+ tapDX int
83+ tapDY int
84+ heldDX int
85+ heldDY int
86+ flushPending bool
87+ generation uint64
88+
89+ // beforeExecute is a deterministic test seam for the drain-to-commit
90+ // boundary. Production leaves it nil.
91+ beforeExecute func()
92+}
93+
7494 var (
7595 cefNewTask = purecef.NewTask
7696 cefNewStringVisitor = purecef.NewStringVisitor
@@ -131,6 +151,21 @@ type WebView struct {
131151 adaptiveFrameRatePollID uint
132152 lastAdaptiveFrameRate int32
133153
154+ pageScrollQueue pageScrollQueue
155+
156+ // a11yWorker receives serialized accessibility payloads off the CEF UI
157+ // thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1.
158+ // After publication, a11yWorker/a11yCapture stay immutable; teardown uses
159+ // a11yCaptureFinalized so abort/shutdown close the worker once without
160+ // racing CEF callback sinks that still read these pointers.
161+ a11yWorker *accessibilityCaptureWorker
162+ a11yCapture *accessibilityCapture
163+ a11yCaptureFinalized atomic.Bool
164+ a11yStats accessibilityStats
165+ // a11yRequested records EnableAccessibility before the browser host exists.
166+ a11yRequested atomic.Bool
167+ a11yEnabled atomic.Bool
168+
134169 // beginFrameTick drives CEF external BeginFrame requests while the GTK
135170 // widget is visible. Access is guarded by mu.
136171 beginFrameTick *gtk.TickCallback
@@ -1201,6 +1236,8 @@ func (wv *WebView) Destroy() {
12011236 if !wv.destroyed.CompareAndSwap(false, true) {
12021237 return
12031238 }
1239+ wv.shutdownAccessibilityCapture()
1240+ wv.resetPageScrollQueue()
12041241 wv.syntheticPopupMu.Lock()
12051242 wv.syntheticPopups = nil
12061243 wv.syntheticPopupMu.Unlock()
@@ -1423,23 +1460,28 @@ func (wv *WebView) setEditableFocus(editable bool) {
14231460 wv.mu.Lock()
14241461 previous := wv.focusedEditable
14251462 wv.focusedEditable = editable
1463+ cb := wv.callbacks
1464+ var timer stoppableTimer
14261465 if editable {
14271466 wv.selectionDebounceSeq++
1428- timer := wv.selectionDebounceTimer
1467+ timer = wv.selectionDebounceTimer
14291468 wv.selectionDebounceTimer = nil
1430- wv.mu.Unlock()
1431- if timer != nil {
1432- timer.Stop()
1433- }
1434- if previous != editable && wv.ctx != nil {
1435- logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
1436- }
1437- return
14381469 }
14391470 wv.mu.Unlock()
1440- if previous != editable && wv.ctx != nil {
1471+ if timer != nil {
1472+ timer.Stop()
1473+ }
1474+ if previous == editable {
1475+ return
1476+ }
1477+ if wv.ctx != nil {
14411478 logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
14421479 }
1480+ if cb != nil && cb.OnEditableFocusChanged != nil {
1481+ wv.runOnGTK(func() {
1482+ cb.OnEditableFocusChanged(editable)
1483+ })
1484+ }
14431485 }
14441486
14451487 func (wv *WebView) cancelSelectionDebounce() {
@@ -2368,6 +2410,231 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
23682410 return pc
23692411 }
23702412
2413+// pageScrollWheelEvent builds the CEF mouse wheel event used for keyboard-driven
2414+// Vim Mode scrolling. It targets the center of the current OSR view so the
2415+// scroll goes through Chromium's compositor/input pipeline instead of a series
2416+// of synthetic key taps.
2417+func (wv *WebView) pageScrollWheelEvent() purecef.MouseEvent {
2418+ width, height := int32(1), int32(1)
2419+ if wv != nil && wv.viewBridge != nil {
2420+ width, height = wv.viewBridge.Size()
2421+ }
2422+ scale := normalizeScale(wv.osrBackingScaleFactor())
2423+ return purecef.MouseEvent{
2424+ X: int32(math.Floor(float64(width) * scale / 2)),
2425+ Y: int32(math.Floor(float64(height) * scale / 2)),
2426+ Modifiers: uint32(purecef.EventFlagsEventflagPrecisionScrollingDelta),
2427+ }
2428+}
2429+
2430+func pageScrollWheelDeltas(request port.PageScrollRequest) (deltaX, deltaY int32) {
2431+ return int32(-request.FallbackDX), int32(-request.FallbackDY)
2432+}
2433+
2434+// ScrollPage resolves a DOM scroll target when the browser is ready so an
2435+// exhausted nested scroller can hand off automatically to its ancestor or the
2436+// document. Native precision-wheel input remains the pre-frame fallback.
2437+func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
2438+ if wv == nil {
2439+ return errors.New("cef: webview is nil")
2440+ }
2441+ if err := ctx.Err(); err != nil {
2442+ return err
2443+ }
2444+ if err := request.Validate(); err != nil {
2445+ return err
2446+ }
2447+ if wv.destroyed.Load() {
2448+ return errDestroyed
2449+ }
2450+
2451+ wv.mu.RLock()
2452+ host := wv.host
2453+ browser := wv.browser
2454+ wv.mu.RUnlock()
2455+ if browser != nil {
2456+ wv.enqueuePageScroll(request)
2457+ return nil
2458+ }
2459+ if host != nil {
2460+ deltaX, deltaY := pageScrollWheelDeltas(request)
2461+ if deltaX != 0 || deltaY != 0 {
2462+ event := wv.pageScrollWheelEvent()
2463+ host.SendMouseWheelEvent(&event, deltaX, deltaY)
2464+ }
2465+ return nil
2466+ }
2467+
2468+ wv.RunJavaScript(ctx, webutil.BuildPageScrollByJS(request.FallbackDX, request.FallbackDY))
2469+ return nil
2470+}
2471+
2472+func (wv *WebView) enqueuePageScroll(request port.PageScrollRequest) {
2473+ q := &wv.pageScrollQueue
2474+ q.mu.Lock()
2475+ if request.Continuous {
2476+ q.heldDX = saturatingPageScrollAdd(q.heldDX, request.FallbackDX)
2477+ q.heldDY = saturatingPageScrollAdd(q.heldDY, request.FallbackDY)
2478+ } else {
2479+ q.tapDX = saturatingPageScrollAdd(q.tapDX, request.FallbackDX)
2480+ q.tapDY = saturatingPageScrollAdd(q.tapDY, request.FallbackDY)
2481+ }
2482+ q.mu.Unlock()
2483+ wv.ensurePageScrollFlush()
2484+}
2485+
2486+func (wv *WebView) ensurePageScrollFlush() {
2487+ q := &wv.pageScrollQueue
2488+ q.mu.Lock()
2489+ if q.flushPending || (q.tapDX == 0 && q.tapDY == 0 && q.heldDX == 0 && q.heldDY == 0) {
2490+ q.mu.Unlock()
2491+ return
2492+ }
2493+ q.flushPending = true
2494+ q.mu.Unlock()
2495+
2496+ if wv.engine == nil {
2497+ wv.drainPageScrollSynchronously()
2498+ return
2499+ }
2500+ task := cefNewTask(cefTaskFunc(wv.flushPageScroll))
2501+ if task == nil || cefPostTask(purecef.ThreadIDTidUi, task) != 1 {
2502+ // Schedule failed: drop held (cancel semantics) and synchronously drain
2503+ // any queued tap so it cannot remain stranded with flushPending cleared.
2504+ q.mu.Lock()
2505+ q.heldDX = 0
2506+ q.heldDY = 0
2507+ q.mu.Unlock()
2508+ wv.drainPageScrollSynchronously()
2509+ }
2510+}
2511+
2512+func (wv *WebView) drainPageScrollSynchronously() {
2513+ q := &wv.pageScrollQueue
2514+ for {
2515+ wv.drainPageScrollOnce()
2516+ q.mu.Lock()
2517+ hasWork := q.tapDX != 0 || q.tapDY != 0 || q.heldDX != 0 || q.heldDY != 0
2518+ if !hasWork {
2519+ q.flushPending = false
2520+ q.mu.Unlock()
2521+ return
2522+ }
2523+ q.mu.Unlock()
2524+ }
2525+}
2526+
2527+func (wv *WebView) flushPageScroll() {
2528+ wv.drainPageScrollOnce()
2529+
2530+ q := &wv.pageScrollQueue
2531+ q.mu.Lock()
2532+ q.flushPending = false
2533+ hasWork := q.tapDX != 0 || q.tapDY != 0 || q.heldDX != 0 || q.heldDY != 0
2534+ q.mu.Unlock()
2535+ if hasWork {
2536+ wv.ensurePageScrollFlush()
2537+ }
2538+}
2539+
2540+func (wv *WebView) drainPageScrollOnce() {
2541+ q := &wv.pageScrollQueue
2542+ q.mu.Lock()
2543+ tapDX, tapDY := q.tapDX, q.tapDY
2544+ heldDX, heldDY := q.heldDX, q.heldDY
2545+ heldGeneration := q.generation
2546+ beforeExecute := q.beforeExecute
2547+ if tapDX == 0 && tapDY == 0 && heldDX == 0 && heldDY == 0 {
2548+ q.mu.Unlock()
2549+ return
2550+ }
2551+ q.tapDX, q.tapDY, q.heldDX, q.heldDY = 0, 0, 0, 0
2552+ q.mu.Unlock()
2553+
2554+ if beforeExecute != nil {
2555+ beforeExecute()
2556+ }
2557+
2558+ // commitMu makes the generation check and the start of ExecuteJavaScript
2559+ // linearizable with CancelPageScroll. The queue mutex is released before
2560+ // taking it and before CEF execution, so JavaScript callbacks may enqueue a
2561+ // successor without deadlocking; flushPending still bounds that to one task.
2562+ q.commitMu.Lock()
2563+ q.mu.Lock()
2564+ if heldGeneration != q.generation {
2565+ heldDX, heldDY = 0, 0
2566+ }
2567+ q.mu.Unlock()
2568+ if dx, dy := tapDX+heldDX, tapDY+heldDY; (dx != 0 || dy != 0) && !wv.destroyed.Load() {
2569+ wv.executeJavaScriptNow(webutil.BuildPageScrollByJS(dx, dy))
2570+ }
2571+ q.commitMu.Unlock()
2572+}
2573+
2574+// CancelPageScroll invalidates all accepted but unconsumed held-key deltas.
2575+// Tap deltas remain queued so a quick press always retains its 80/320 px step.
2576+// Returning is also a barrier: an old held batch cannot start afterward.
2577+func (wv *WebView) CancelPageScroll(_ context.Context) {
2578+ q := &wv.pageScrollQueue
2579+ q.commitMu.Lock()
2580+ q.mu.Lock()
2581+ q.generation++
2582+ q.heldDX = 0
2583+ q.heldDY = 0
2584+ q.mu.Unlock()
2585+ q.commitMu.Unlock()
2586+}
2587+
2588+// EnableAccessibility records a request to enable CEF accessibility and applies
2589+// it once the browser host exists. Destroyed views never enable. Calls before
2590+// host attachment remain pending until OnAfterCreated applies them.
2591+func (wv *WebView) EnableAccessibility() {
2592+ if wv == nil || wv.destroyed.Load() {
2593+ return
2594+ }
2595+ wv.a11yRequested.Store(true)
2596+ wv.applyAccessibilityIfReady()
2597+}
2598+
2599+// applyAccessibilityIfReady performs exactly one SetAccessibilityState after a
2600+// pending request and a live host are both present.
2601+func (wv *WebView) applyAccessibilityIfReady() {
2602+ if wv == nil || wv.destroyed.Load() || !wv.a11yRequested.Load() || wv.a11yEnabled.Load() {
2603+ return
2604+ }
2605+ wv.mu.RLock()
2606+ host := wv.host
2607+ wv.mu.RUnlock()
2608+ if host == nil || !wv.a11yEnabled.CompareAndSwap(false, true) {
2609+ return
2610+ }
2611+ host.SetAccessibilityState(purecef.StateStateEnabled)
2612+ if wv.ctx != nil {
2613+ logging.FromContext(wv.ctx).Debug().
2614+ Uint64("webview_id", uint64(wv.id)).
2615+ Msg("cef: accessibility enabled")
2616+ }
2617+}
2618+
2619+func (wv *WebView) resetPageScrollQueue() {
2620+ q := &wv.pageScrollQueue
2621+ q.mu.Lock()
2622+ q.generation++
2623+ q.tapDX, q.tapDY, q.heldDX, q.heldDY = 0, 0, 0, 0
2624+ q.mu.Unlock()
2625+}
2626+
2627+func saturatingPageScrollAdd(current, delta int) int {
2628+ result := current + delta
2629+ if result > maxPendingPageScroll {
2630+ return maxPendingPageScroll
2631+ }
2632+ if result < -maxPendingPageScroll {
2633+ return -maxPendingPageScroll
2634+ }
2635+ return result
2636+}
2637+
23712638 // beginAudioStreamStart invalidates any prior stream before creating a new
23722639 // one. A stream created for the returned generation may only be published if
23732640 // that generation is still current.
M internal/infrastructure/cef/webview_handlers.go +2 -0
@@ -472,6 +472,7 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
472472 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
473473 Msg("cef: OnLoadStart")
474474 }
475+ h.wv.setEditableFocus(false)
475476 h.wv.updateURI(frame.GetURL())
476477 h.wv.mu.RLock()
477478 cb := h.wv.callbacks
@@ -954,6 +955,7 @@ func (h *handlerSet) finishAfterCreated(
954955 }
955956 h.wv.scheduleStartAdaptiveFrameRatePolling()
956957 h.wv.scheduleStartBeginFrameLoop()
958+ h.wv.applyAccessibilityIfReady()
957959 h.wv.fireReadyToShow()
958960 }
959961
M internal/infrastructure/cef/webview_handlers_test.go +34 -0
@@ -544,6 +544,40 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
544544 require.Equal(t, "free selection", recorder.selection.Text)
545545 }
546546
547+func TestSetEditableFocus_InvokesCallbackOnTransitions(t *testing.T) {
548+ var states []bool
549+ wv := &WebView{ctx: context.Background()}
550+ wv.SetCallbacks(&port.WebViewCallbacks{
551+ OnEditableFocusChanged: func(editable bool) {
552+ states = append(states, editable)
553+ },
554+ })
555+
556+ wv.setEditableFocus(true)
557+ wv.setEditableFocus(true)
558+ wv.setEditableFocus(false)
559+
560+ require.Equal(t, []bool{true, false}, states)
561+}
562+
563+func TestOnLoadStart_ClearsEditableFocus(t *testing.T) {
564+ var states []bool
565+ wv := &WebView{ctx: context.Background(), focusedEditable: true}
566+ wv.SetCallbacks(&port.WebViewCallbacks{
567+ OnEditableFocusChanged: func(editable bool) {
568+ states = append(states, editable)
569+ },
570+ })
571+ frame := cefmocks.NewMockFrame(t)
572+ frame.EXPECT().IsMain().Return(true).Once()
573+ frame.EXPECT().GetURL().Return("https://example.com").Maybe()
574+
575+ (&handlerSet{wv: wv}).OnLoadStart(nil, frame, 0)
576+
577+ require.Equal(t, []bool{false}, states)
578+ require.False(t, wv.focusedEditable)
579+}
580+
547581 func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) {
548582 called := false
549583 wv := &WebView{closeCallbacks: []func(){func() { called = true }}}
A internal/infrastructure/cef/webview_scroll_test.go +526 -0
@@ -0,0 +1,526 @@
1+package cef
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os/exec"
8+ "strings"
9+ "sync"
10+ "testing"
11+
12+ purecef "github.com/bnema/purego-cef/cef"
13+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
14+ "github.com/stretchr/testify/mock"
15+
16+ "github.com/bnema/dumber/internal/application/port"
17+)
18+
19+func TestPageScrollWheelDeltas_UseFallbackDeltasWithCEFSign(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ req port.PageScrollRequest
23+ wantDeltaX int32
24+ wantDeltaY int32
25+ }{
26+ {"left", port.PageScrollRequest{FallbackDX: -80}, 80, 0},
27+ {"right", port.PageScrollRequest{FallbackDX: 80}, -80, 0},
28+ {"up", port.PageScrollRequest{FallbackDY: -80}, 0, 80},
29+ {"down", port.PageScrollRequest{FallbackDY: 80}, 0, -80},
30+ {"up fast", port.PageScrollRequest{FallbackDY: -320}, 0, 320},
31+ {"down fast", port.PageScrollRequest{FallbackDY: 320}, 0, -320},
32+ }
33+
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ gotDeltaX, gotDeltaY := pageScrollWheelDeltas(tt.req)
37+ if gotDeltaX != tt.wantDeltaX || gotDeltaY != tt.wantDeltaY {
38+ t.Fatalf("pageScrollWheelDeltas() = (%d, %d), want (%d, %d)", gotDeltaX, gotDeltaY, tt.wantDeltaX, tt.wantDeltaY)
39+ }
40+ })
41+ }
42+}
43+
44+// scrollRecorderHost wraps purecef.BrowserHost and records mouse wheel events,
45+// allowing tests to verify that Vim Mode uses Chromium's native scroll path.
46+type scrollRecorderHost struct {
47+ purecef.BrowserHost // embedded nil — only SendMouseWheelEvent is called in these tests
48+
49+ mu sync.Mutex
50+ events []recordedWheelEvent
51+}
52+
53+type recordedWheelEvent struct {
54+ event purecef.MouseEvent
55+ deltaX, deltaY int32
56+}
57+
58+func (h *scrollRecorderHost) SendMouseWheelEvent(event *purecef.MouseEvent, deltaX, deltaY int32) {
59+ h.mu.Lock()
60+ defer h.mu.Unlock()
61+ // Record a copy so the test can inspect it after the call returns.
62+ h.events = append(h.events, recordedWheelEvent{event: *event, deltaX: deltaX, deltaY: deltaY})
63+}
64+
65+func (h *scrollRecorderHost) recordedEvents() []recordedWheelEvent {
66+ h.mu.Lock()
67+ defer h.mu.Unlock()
68+ out := make([]recordedWheelEvent, len(h.events))
69+ copy(out, h.events)
70+ return out
71+}
72+
73+func (h *scrollRecorderHost) resetRecords() {
74+ h.mu.Lock()
75+ defer h.mu.Unlock()
76+ h.events = nil
77+}
78+
79+func TestScrollPage_NativeWheelPath_SendsPrecisionWheelEvent(t *testing.T) {
80+ host := &scrollRecorderHost{}
81+ wv := &WebView{host: host}
82+
83+ req := port.PageScrollRequest{
84+ Command: port.PageScrollCommandDown,
85+ FallbackDX: 0,
86+ FallbackDY: 80,
87+ }
88+
89+ err := wv.ScrollPage(context.Background(), req)
90+ if err != nil {
91+ t.Fatalf("ScrollPage() unexpected error: %v", err)
92+ }
93+
94+ events := host.recordedEvents()
95+ if len(events) != 1 {
96+ t.Fatalf("expected 1 wheel event, got %d", len(events))
97+ }
98+ if events[0].deltaX != 0 || events[0].deltaY != -80 {
99+ t.Fatalf("wheel delta = (%d, %d), want (0, -80)", events[0].deltaX, events[0].deltaY)
100+ }
101+ if events[0].event.Modifiers&uint32(purecef.EventFlagsEventflagPrecisionScrollingDelta) == 0 {
102+ t.Fatal("wheel event should set EVENTFLAG_PRECISION_SCROLLING_DELTA")
103+ }
104+}
105+
106+func TestScrollPage_NativeWheelPath_AllCommandsUseFallbackDeltas(t *testing.T) {
107+ host := &scrollRecorderHost{}
108+ wv := &WebView{host: host}
109+
110+ commands := []struct {
111+ name string
112+ req port.PageScrollRequest
113+ wantDeltaX int32
114+ wantDeltaY int32
115+ }{
116+ {"PageScrollLeft", port.PageScrollRequest{Command: port.PageScrollCommandLeft, FallbackDX: -80}, 80, 0},
117+ {"PageScrollRight", port.PageScrollRequest{Command: port.PageScrollCommandRight, FallbackDX: 80}, -80, 0},
118+ {"PageScrollUp", port.PageScrollRequest{Command: port.PageScrollCommandUp, FallbackDY: -80}, 0, 80},
119+ {"PageScrollDown", port.PageScrollRequest{Command: port.PageScrollCommandDown, FallbackDY: 80}, 0, -80},
120+ {"PageScrollUpFast", port.PageScrollRequest{Command: port.PageScrollCommandUpFast, FallbackDY: -320}, 0, 320},
121+ {"PageScrollDownFast", port.PageScrollRequest{Command: port.PageScrollCommandDownFast, FallbackDY: 320}, 0, -320},
122+ }
123+
124+ for _, tt := range commands {
125+ t.Run(tt.name, func(t *testing.T) {
126+ host.resetRecords()
127+
128+ if err := wv.ScrollPage(context.Background(), tt.req); err != nil {
129+ t.Fatalf("ScrollPage() error: %v", err)
130+ }
131+
132+ events := host.recordedEvents()
133+ if len(events) != 1 {
134+ t.Fatalf("expected 1 wheel event, got %d", len(events))
135+ }
136+ if events[0].deltaX != tt.wantDeltaX || events[0].deltaY != tt.wantDeltaY {
137+ t.Fatalf("wheel delta = (%d, %d), want (%d, %d)", events[0].deltaX, events[0].deltaY, tt.wantDeltaX, tt.wantDeltaY)
138+ }
139+ })
140+ }
141+}
142+
143+func TestScrollPage_ReadyBrowser_HorizontalDirectionsUseDOMSigns(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ dx int
147+ marker string
148+ }{
149+ {name: "h scrolls left", dx: -80, marker: "var dx=-80,dy=0"},
150+ {name: "l scrolls right", dx: 80, marker: "var dx=80,dy=0"},
151+ }
152+
153+ for _, tt := range tests {
154+ t.Run(tt.name, func(t *testing.T) {
155+ host := &scrollRecorderHost{}
156+ browser := cefmocks.NewMockBrowser(t)
157+ frame := cefmocks.NewMockFrame(t)
158+ browser.EXPECT().GetMainFrame().Return(frame).Once()
159+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
160+ return strings.Contains(script, tt.marker)
161+ }), "", int32(0)).Once()
162+
163+ wv := &WebView{host: host, browser: browser}
164+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: tt.dx}); err != nil {
165+ t.Fatalf("ScrollPage() error: %v", err)
166+ }
167+ if events := host.recordedEvents(); len(events) != 0 {
168+ t.Fatalf("ready browser should use DOM target resolution, got %d wheel events", len(events))
169+ }
170+ })
171+ }
172+}
173+
174+func TestScrollPage_ReadyBrowser_NestedBoundaryHandsOffToDocument(t *testing.T) {
175+ node, err := exec.LookPath("node")
176+ if err != nil {
177+ t.Skip("node is required to execute the generated vim-scroll helper")
178+ }
179+
180+ host := &scrollRecorderHost{}
181+ browser := cefmocks.NewMockBrowser(t)
182+ frame := cefmocks.NewMockFrame(t)
183+ var pageScrollScript string
184+ browser.EXPECT().GetMainFrame().Return(frame).Once()
185+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
186+ pageScrollScript = script
187+ return true
188+ }), "", int32(0)).Once()
189+
190+ wv := &WebView{host: host, browser: browser}
191+ if scrollErr := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); scrollErr != nil {
192+ t.Fatalf("ScrollPage() error: %v", scrollErr)
193+ }
194+ if events := host.recordedEvents(); len(events) != 0 {
195+ t.Fatalf("ready browser should resolve a scrollable DOM target instead of latching a wheel event, got %d wheel events", len(events))
196+ }
197+
198+ // The centered nested scroller can consume the first tick only. The next
199+ // tick must skip its exhausted boundary and resume on the document scroller.
200+ harness := fmt.Sprintf(`
201+const body={parentElement:null};
202+const root={scrollTop:0,scrollLeft:0,scrollHeight:3000,clientHeight:500,scrollWidth:800,clientWidth:800,parentElement:null,style:{overflowY:'auto',overflowX:'auto'}};
203+const inner={scrollTop:120,scrollLeft:0,scrollHeight:300,clientHeight:100,scrollWidth:200,clientWidth:200,parentElement:body,style:{overflowY:'auto',overflowX:'auto'}};
204+body.parentElement=root;
205+global.document={activeElement:body,body:body,documentElement:root,scrollingElement:root,elementFromPoint:()=>inner};
206+global.window={innerWidth:800,innerHeight:600,getComputedStyle:(el)=>el.style||{},scrollX:0,scrollY:0,scrollBy:()=>{},scrollTo:()=>{}};
207+const tick=%q;
208+eval(tick);
209+eval(tick);
210+process.stdout.write(inner.scrollTop+','+root.scrollTop);
211+`, pageScrollScript)
212+ output, err := exec.Command(node, "-e", harness).CombinedOutput()
213+ if err != nil {
214+ t.Fatalf("execute generated vim-scroll helper: %v\n%s", err, output)
215+ }
216+ if got := strings.TrimSpace(string(output)); got != "200,80" {
217+ t.Fatalf("nested/document scroll offsets = %s, want 200,80", got)
218+ }
219+}
220+
221+func TestScrollPage_NativeWheelPath_ZeroDeltaDoesNotSendEvent(t *testing.T) {
222+ host := &scrollRecorderHost{}
223+ wv := &WebView{host: host}
224+
225+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{}); err != nil {
226+ t.Fatalf("ScrollPage() error: %v", err)
227+ }
228+ if events := host.recordedEvents(); len(events) != 0 {
229+ t.Fatalf("expected no wheel events for zero delta, got %d", len(events))
230+ }
231+}
232+
233+func TestScrollPage_NativePath_FallbackToJSWhenHostIsNil(t *testing.T) {
234+ // With no host set, ScrollPage should still succeed via the JS fallback.
235+ wv := &WebView{}
236+
237+ req := port.PageScrollRequest{
238+ Command: port.PageScrollCommandDown,
239+ FallbackDX: 0,
240+ FallbackDY: 80,
241+ }
242+
243+ // Should not panic and should succeed (JS fallback is fire-and-forget
244+ // via RunJavaScript, which is safe with a nil browser).
245+ err := wv.ScrollPage(context.Background(), req)
246+ if err != nil {
247+ t.Fatalf("ScrollPage() with nil host should not return error: %v", err)
248+ }
249+}
250+
251+func TestScrollPage_CoalescesHeldLoadIntoOneBoundedCEFTask(t *testing.T) {
252+ oldNewTask, oldPostTask := cefNewTask, cefPostTask
253+ defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
254+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
255+ var scheduled purecef.Task
256+ posts := 0
257+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
258+ if threadID != purecef.ThreadIDTidUi {
259+ t.Fatalf("thread=%v, want CEF UI", threadID)
260+ }
261+ posts++
262+ scheduled = task
263+ return 1
264+ }
265+
266+ browser := cefmocks.NewMockBrowser(t)
267+ frame := cefmocks.NewMockFrame(t)
268+ browser.EXPECT().GetMainFrame().Return(frame).Once()
269+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
270+ return strings.Contains(script, "var dx=0,dy=3200")
271+ }), "", int32(0)).Once()
272+ wv := &WebView{engine: &Engine{}, browser: browser}
273+
274+ for range 10_000 {
275+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
276+ t.Fatal(err)
277+ }
278+ }
279+ if posts != 1 {
280+ t.Fatalf("CEF flush posts=%d, want exactly 1 under load", posts)
281+ }
282+ if scheduled == nil {
283+ t.Fatal("missing scheduled flush")
284+ }
285+ scheduled.Execute()
286+ if posts != 1 {
287+ t.Fatalf("unexpected replay after drain: posts=%d", posts)
288+ }
289+}
290+
291+func TestCancelPageScroll_DropsHeldDeltaButPreservesTap(t *testing.T) {
292+ oldNewTask, oldPostTask := cefNewTask, cefPostTask
293+ defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
294+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
295+ var scheduled purecef.Task
296+ posts := 0
297+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
298+ posts++
299+ scheduled = task
300+ return 1
301+ }
302+
303+ browser := cefmocks.NewMockBrowser(t)
304+ frame := cefmocks.NewMockFrame(t)
305+ browser.EXPECT().GetMainFrame().Return(frame).Once()
306+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
307+ return strings.Contains(script, "var dx=0,dy=80") && !strings.Contains(script, "requestAnimationFrame")
308+ }), "", int32(0)).Once()
309+ wv := &WebView{engine: &Engine{}, browser: browser}
310+
311+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
312+ t.Fatal(err)
313+ }
314+ for range 50 {
315+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
316+ t.Fatal(err)
317+ }
318+ }
319+ wv.CancelPageScroll(context.Background())
320+ if posts != 1 {
321+ t.Fatalf("posts before release=%d, want 1", posts)
322+ }
323+ scheduled.Execute()
324+ if posts != 1 {
325+ t.Fatalf("release must not schedule a trailing replay: posts=%d", posts)
326+ }
327+}
328+
329+func TestDrainPageScrollSynchronously_DrainsTapEnqueuedDuringExecution(t *testing.T) {
330+ browser := cefmocks.NewMockBrowser(t)
331+ frame := cefmocks.NewMockFrame(t)
332+ browser.EXPECT().GetMainFrame().Return(frame).Twice()
333+
334+ wv := &WebView{browser: browser}
335+ calls := 0
336+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(_, _ string, _ int32) {
337+ calls++
338+ if calls == 1 {
339+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
340+ t.Errorf("enqueue tap during execution: %v", err)
341+ }
342+ }
343+ }).Twice()
344+
345+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
346+ t.Fatal(err)
347+ }
348+ if calls != 2 {
349+ t.Fatalf("ExecuteJavaScript calls=%d, want 2", calls)
350+ }
351+
352+ q := &wv.pageScrollQueue
353+ q.mu.Lock()
354+ defer q.mu.Unlock()
355+ if q.flushPending || q.tapDX != 0 || q.tapDY != 0 || q.heldDX != 0 || q.heldDY != 0 {
356+ t.Fatalf("queue not drained: pending=%t tap=(%d,%d) held=(%d,%d)", q.flushPending, q.tapDX, q.tapDY, q.heldDX, q.heldDY)
357+ }
358+}
359+
360+func TestCancelPageScroll_AfterDrainDropsOldHeldAndPreservesTapAndNextGesture(t *testing.T) {
361+ oldNewTask, oldPostTask := cefNewTask, cefPostTask
362+ defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
363+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
364+ var scheduled []purecef.Task
365+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
366+ scheduled = append(scheduled, task)
367+ return 1
368+ }
369+
370+ drained := make(chan struct{})
371+ resume := make(chan struct{})
372+ var pauseOnce sync.Once
373+ beforeExecute := func() {
374+ pauseOnce.Do(func() {
375+ close(drained)
376+ <-resume
377+ })
378+ }
379+
380+ browser := cefmocks.NewMockBrowser(t)
381+ frame := cefmocks.NewMockFrame(t)
382+ browser.EXPECT().GetMainFrame().Return(frame).Twice()
383+ var scripts []string
384+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(script, _ string, _ int32) {
385+ scripts = append(scripts, script)
386+ }).Twice()
387+ wv := &WebView{engine: &Engine{}, browser: browser}
388+ wv.pageScrollQueue.beforeExecute = beforeExecute
389+
390+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
391+ t.Fatal(err)
392+ }
393+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
394+ t.Fatal(err)
395+ }
396+ if len(scheduled) != 1 {
397+ t.Fatalf("initial pending tasks=%d, want 1", len(scheduled))
398+ }
399+
400+ flushDone := make(chan struct{})
401+ go func() {
402+ defer close(flushDone)
403+ scheduled[0].Execute()
404+ }()
405+ <-drained
406+
407+ // Cancel returns while the old batch is drained but has not committed to
408+ // ExecuteJavaScript. Its held component must therefore be invalidated.
409+ wv.CancelPageScroll(context.Background())
410+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: 80}); err != nil {
411+ t.Fatal(err)
412+ }
413+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: 80, Continuous: true}); err != nil {
414+ t.Fatal(err)
415+ }
416+ if len(scheduled) != 1 {
417+ t.Fatalf("tasks while old flush is paused=%d, want 1", len(scheduled))
418+ }
419+
420+ close(resume)
421+ <-flushDone
422+ if len(scripts) != 1 || !strings.Contains(scripts[0], "var dx=0,dy=80") {
423+ t.Fatalf("old generation scripts=%v, want the preserved 80px tap without held delta", scripts)
424+ }
425+ if len(scheduled) != 2 {
426+ t.Fatalf("tasks after old flush=%d, want one successor for the new gesture", len(scheduled))
427+ }
428+ scheduled[1].Execute()
429+ if len(scripts) != 2 || !strings.Contains(scripts[1], "var dx=160,dy=0") {
430+ t.Fatalf("scripts after new gesture=%v, want a fresh 160px horizontal batch", scripts)
431+ }
432+ if len(scheduled) != 2 {
433+ t.Fatalf("trailing tasks=%d, want 2 total", len(scheduled))
434+ }
435+}
436+
437+func TestScrollPage_AllowsAtMostOneSuccessorAfterInFlightFlush(t *testing.T) {
438+ oldNewTask, oldPostTask := cefNewTask, cefPostTask
439+ defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
440+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
441+ var scheduled []purecef.Task
442+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
443+ scheduled = append(scheduled, task)
444+ return 1
445+ }
446+
447+ browser := cefmocks.NewMockBrowser(t)
448+ frame := cefmocks.NewMockFrame(t)
449+ browser.EXPECT().GetMainFrame().Return(frame).Twice()
450+ var wv *WebView
451+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(string, string, int32) {
452+ for range 100 {
453+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
454+ t.Fatal(err)
455+ }
456+ }
457+ if len(scheduled) != 1 {
458+ t.Fatalf("tasks while flush in flight=%d, want 1", len(scheduled))
459+ }
460+ }).Once()
461+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Once()
462+ wv = &WebView{engine: &Engine{}, browser: browser}
463+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
464+ t.Fatal(err)
465+ }
466+ if len(scheduled) != 1 {
467+ t.Fatalf("initial pending tasks=%d, want 1", len(scheduled))
468+ }
469+
470+ scheduled[0].Execute()
471+ if len(scheduled) != 2 {
472+ t.Fatalf("successor tasks=%d, want one successor", len(scheduled))
473+ }
474+ scheduled[1].Execute()
475+ if len(scheduled) != 2 {
476+ t.Fatalf("prolonged replay tasks=%d, want 2 total", len(scheduled))
477+ }
478+}
479+
480+func TestEnsurePageScrollFlush_PostTaskFailureDrainsTapAndDropsHeld(t *testing.T) {
481+ oldNewTask, oldPostTask := cefNewTask, cefPostTask
482+ defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
483+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
484+ cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 { return 0 }
485+
486+ browser := cefmocks.NewMockBrowser(t)
487+ frame := cefmocks.NewMockFrame(t)
488+ browser.EXPECT().GetMainFrame().Return(frame).Once()
489+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
490+ return strings.Contains(script, "var dx=0,dy=80") && !strings.Contains(script, "dy=160")
491+ }), "", int32(0)).Once()
492+ wv := &WebView{engine: &Engine{}, browser: browser}
493+
494+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
495+ t.Fatal(err)
496+ }
497+ if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
498+ t.Fatal(err)
499+ }
500+
501+ q := &wv.pageScrollQueue
502+ q.mu.Lock()
503+ tapDX, tapDY := q.tapDX, q.tapDY
504+ heldDX, heldDY := q.heldDX, q.heldDY
505+ pending := q.flushPending
506+ q.mu.Unlock()
507+ if tapDX != 0 || tapDY != 0 {
508+ t.Fatalf("tap deltas stranded after PostTask failure: tap=(%d,%d)", tapDX, tapDY)
509+ }
510+ if heldDX != 0 || heldDY != 0 {
511+ t.Fatalf("held deltas must keep cancel-on-schedule-failure semantics: held=(%d,%d)", heldDX, heldDY)
512+ }
513+ if pending {
514+ t.Fatal("flushPending must clear after PostTask failure drain")
515+ }
516+}
517+
518+func TestScrollPage_Destroyed_ReturnsError(t *testing.T) {
519+ wv := &WebView{}
520+ wv.destroyed.Store(true)
521+
522+ err := wv.ScrollPage(context.Background(), port.PageScrollRequest{})
523+ if !errors.Is(err, errDestroyed) {
524+ t.Fatalf("ScrollPage() on destroyed webview: got %v, want %v", err, errDestroyed)
525+ }
526+}
M internal/infrastructure/config/defaults.go +49 -13
@@ -38,18 +38,23 @@ const (
3838 defaultOmniboxAutoOpenOnNewPane = false
3939
4040 // Workspace defaults
41- defaultPaneActivationShortcut = "ctrl+p"
42- defaultPaneTimeoutMilliseconds = 3000
43- defaultTabActivationShortcut = "ctrl+t"
44- defaultTabTimeoutMilliseconds = 3000
45- defaultResizeActivationShortcut = "ctrl+n"
46- defaultResizeTimeoutMilliseconds = 3000
47- defaultResizeStepPercent = 5.0
48- defaultResizeMinPanePercent = 10.0
49- defaultTabBarPosition = "bottom"
50- defaultPopupPlacement = "right"
51- defaultFloatingPaneWidthPct = 0.82
52- defaultFloatingPaneHeightPct = 0.72
41+ defaultPaneActivationShortcut = "ctrl+p"
42+ defaultPaneTimeoutMilliseconds = 3000
43+ defaultTabActivationShortcut = "ctrl+t"
44+ defaultTabTimeoutMilliseconds = 3000
45+ defaultResizeActivationShortcut = "ctrl+n"
46+ defaultResizeTimeoutMilliseconds = 3000
47+ defaultPageActivationShortcut = "ctrl+y"
48+ defaultPageTimeoutMilliseconds = 0
49+ defaultPageSequenceTimeoutMilliseconds = 500
50+ defaultResizeStepPercent = 5.0
51+ defaultResizeMinPanePercent = 10.0
52+ defaultTabBarPosition = "bottom"
53+ defaultPopupPlacement = "right"
54+ defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed
55+ defaultExternalLinkPlacement = ExternalLinkPlacementRight
56+ defaultFloatingPaneWidthPct = 0.82
57+ defaultFloatingPaneHeightPct = 0.72
5358
5459 // Session defaults
5560 defaultSessionActivationShortcut = "ctrl+o"
@@ -300,6 +305,33 @@ func DefaultConfig() *Config {
300305 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
301306 },
302307 },
308+ VimMode: VimModeConfig{
309+ ActivationShortcut: defaultPageActivationShortcut,
310+ TimeoutMilliseconds: defaultPageTimeoutMilliseconds,
311+ SequenceTimeoutMilliseconds: defaultPageSequenceTimeoutMilliseconds,
312+ Actions: map[string]ActionBinding{
313+ "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"},
314+ "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"},
315+ "vim-scroll-up": {Keys: []string{"k"}, Desc: "Scroll page up"},
316+ "vim-scroll-right": {Keys: []string{"l"}, Desc: "Scroll page right"},
317+ "vim-scroll-down-fast": {Keys: []string{"shift+j"}, Desc: "Scroll page down fast"},
318+ "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"},
319+ "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},
320+ "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
321+ "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322+ "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},
323+ "focus-input": {Keys: []string{"gi"}, Desc: "Focus next page input"},
324+ "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"},
325+ "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"},
326+ "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"},
327+ "image-next": {Keys: []string{"]i"}, Desc: "Jump to next image"},
328+ "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"},
329+ "outline": {Keys: []string{"gO"}, Desc: "Show page outline"},
330+ "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"},
331+ "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"},
332+ "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"},
333+ },
334+ },
303335 ResizeMode: ResizeModeConfig{
304336 ActivationShortcut: defaultResizeActivationShortcut,
305337 TimeoutMilliseconds: defaultResizeTimeoutMilliseconds,
@@ -324,7 +356,7 @@ func DefaultConfig() *Config {
324356 Actions: map[string]ActionBinding{
325357 "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
326358 "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
327- "toggle-favorites-systemview": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
359+ "toggle-favorites-sidebar": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
328360 "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},
329361 "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
330362 "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
@@ -341,6 +373,10 @@ func DefaultConfig() *Config {
341373 HeightPct: defaultFloatingPaneHeightPct,
342374 Profiles: map[string]FloatingPaneProfile{},
343375 },
376+ ExternalLinks: ExternalLinksConfig{
377+ Behavior: defaultExternalLinkBehavior,
378+ Placement: defaultExternalLinkPlacement,
379+ },
344380 TabBarPosition: defaultTabBarPosition,
345381 HideTabBarWhenSingleTab: true,
346382 BrowsingContexts: browsingContextDefaults,
M internal/infrastructure/config/defaults_test.go +3 -1
@@ -13,6 +13,8 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
1313 assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)
1414 assert.False(t, cfg.Logging.CaptureGTKLogs)
1515 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
16+ assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)
17+ assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)
1618
1719 // Engine defaults (replaces old Performance/Privacy sections)
1820 assert.Equal(t, EngineTypeCEF, cfg.Engine.Type)
@@ -41,7 +43,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
4143
4244 // System view shortcuts are first-class global actions.
4345 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
44- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{"ctrl+b"})
46+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-sidebar", []string{"ctrl+b"})
4547 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-current-page-favorite", []string{"ctrl+d"})
4648 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
4749
M internal/infrastructure/config/keybindings_gateway.go +23 -0
@@ -32,6 +32,7 @@ func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.Keybindin
3232 g.buildGlobalGroup(cfg, defaults),
3333 g.buildPaneModeGroup(cfg, defaults),
3434 g.buildTabModeGroup(cfg, defaults),
35+ g.buildVimModeGroup(cfg, defaults),
3536 g.buildResizeModeGroup(cfg, defaults),
3637 g.buildSessionModeGroup(cfg, defaults),
3738 }
@@ -48,6 +49,7 @@ func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.Ke
4849 g.buildGlobalGroup(defaults, defaults),
4950 g.buildPaneModeGroup(defaults, defaults),
5051 g.buildTabModeGroup(defaults, defaults),
52+ g.buildVimModeGroup(defaults, defaults),
5153 g.buildResizeModeGroup(defaults, defaults),
5254 g.buildSessionModeGroup(defaults, defaults),
5355 }
@@ -108,6 +110,7 @@ func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error {
108110
109111 cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions
110112 cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions
113+ cfg.Workspace.VimMode.Actions = defaults.Workspace.VimMode.Actions
111114 cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions
112115 cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts
113116 cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions
@@ -144,6 +147,16 @@ func (g *KeybindingsGateway) buildTabModeGroup(cfg, defaults *Config) port.Keybi
144147 }
145148 }
146149
150+// buildVimModeGroup builds the vim mode group.
151+func (g *KeybindingsGateway) buildVimModeGroup(cfg, defaults *Config) port.KeybindingGroup {
152+ return port.KeybindingGroup{
153+ Mode: "vim",
154+ DisplayName: "Vim Mode",
155+ Bindings: g.buildModeBindings(cfg.Workspace.VimMode.Actions, defaults.Workspace.VimMode.Actions),
156+ Activation: cfg.Workspace.VimMode.ActivationShortcut,
157+ }
158+}
159+
147160 // buildResizeModeGroup builds the resize mode group.
148161 func (g *KeybindingsGateway) buildResizeModeGroup(cfg, defaults *Config) port.KeybindingGroup {
149162 return port.KeybindingGroup{
@@ -208,6 +221,11 @@ func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingR
208221 existing.Keys = req.Keys
209222 cfg.Workspace.TabMode.Actions[req.Action] = existing
210223 }
224+ case "vim":
225+ if existing, ok := cfg.Workspace.VimMode.Actions[req.Action]; ok {
226+ existing.Keys = req.Keys
227+ cfg.Workspace.VimMode.Actions[req.Action] = existing
228+ }
211229 case "resize":
212230 if existing, ok := cfg.Workspace.ResizeMode.Actions[req.Action]; ok {
213231 existing.Keys = req.Keys
@@ -236,6 +254,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string)
236254 if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok {
237255 return binding.Keys
238256 }
257+ case "vim":
258+ if binding, ok := defaults.Workspace.VimMode.Actions[action]; ok {
259+ return binding.Keys
260+ }
239261 case "resize":
240262 if binding, ok := defaults.Workspace.ResizeMode.Actions[action]; ok {
241263 return binding.Keys
@@ -269,6 +291,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction
269291 addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)
270292 addBindings("pane", cfg.Workspace.PaneMode.Actions)
271293 addBindings("tab", cfg.Workspace.TabMode.Actions)
294+ addBindings("vim", cfg.Workspace.VimMode.Actions)
272295 addBindings("resize", cfg.Workspace.ResizeMode.Actions)
273296 addBindings("session", cfg.Session.SessionMode.Actions)
274297
A internal/infrastructure/config/keybindings_gateway_test.go +208 -0
@@ -0,0 +1,208 @@
1+package config
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// newTestKeybindingsManager creates a Manager with a temp config dir.
15+func newTestKeybindingsManager(t *testing.T) *Manager {
16+ t.Helper()
17+ configHome := t.TempDir()
18+
19+ t.Setenv("XDG_CONFIG_HOME", configHome)
20+ t.Setenv("XDG_DATA_HOME", filepath.Join(configHome, "data"))
21+ t.Setenv("XDG_STATE_HOME", filepath.Join(configHome, "state"))
22+ t.Setenv("XDG_CACHE_HOME", filepath.Join(configHome, "cache"))
23+
24+ mgr, err := NewManager()
25+ require.NoError(t, err)
26+ require.NoError(t, mgr.Load())
27+ return mgr
28+}
29+
30+// inMemoryManager creates a lightweight Manager without file I/O for tests
31+// that only query config data without saving.
32+func inMemoryManager() *Manager {
33+ return &Manager{
34+ config: DefaultConfig(),
35+ mu: sync.RWMutex{},
36+ }
37+}
38+
39+func TestKeybindingsGateway_VimModeIncludedInGetKeybindings(t *testing.T) {
40+ gw := NewKeybindingsGateway(inMemoryManager())
41+ cfg, err := gw.GetKeybindings(context.Background())
42+ require.NoError(t, err)
43+
44+ var pageGroup *port.KeybindingGroup
45+ for i, g := range cfg.Groups {
46+ if g.Mode == "vim" {
47+ pageGroup = &cfg.Groups[i]
48+ break
49+ }
50+ }
51+ require.NotNil(t, pageGroup, "vim mode group must be present in GetKeybindings output")
52+ assert.Equal(t, "Vim Mode", pageGroup.DisplayName)
53+ assert.Equal(t, "ctrl+y", pageGroup.Activation)
54+
55+ // Verify all expected actions are present.
56+ expectedActions := map[string]struct{}{
57+ "vim-scroll-left": {},
58+ "vim-scroll-down": {},
59+ "vim-scroll-up": {},
60+ "vim-scroll-right": {},
61+ "vim-scroll-down-fast": {},
62+ "vim-scroll-up-fast": {},
63+ "confirm": {},
64+ "cancel": {},
65+ }
66+
67+ for _, entry := range pageGroup.Bindings {
68+ delete(expectedActions, entry.Action)
69+ assert.NotEmpty(t, entry.Keys, "action %q has empty keys", entry.Action)
70+ assert.False(t, entry.IsCustom, "default vim mode config should not have IsCustom=true")
71+ }
72+ assert.Empty(t, expectedActions, "vim mode group missing actions: %v", expectedActions)
73+
74+ // Must be placed after global, pane, tab groups (consistent ordering).
75+ groups := cfg.Groups
76+ foundIdx := -1
77+ for i, g := range groups {
78+ if g.Mode == "vim" {
79+ foundIdx = i
80+ break
81+ }
82+ }
83+ assert.Greater(t, foundIdx, 2, "vim mode group should be after pane/tab groups")
84+}
85+
86+func TestKeybindingsGateway_VimModeIncludedInGetDefaultKeybindings(t *testing.T) {
87+ gw := NewKeybindingsGateway(inMemoryManager())
88+ cfg, err := gw.GetDefaultKeybindings(context.Background())
89+ require.NoError(t, err)
90+
91+ var pageGroup *port.KeybindingGroup
92+ for i, g := range cfg.Groups {
93+ if g.Mode == "vim" {
94+ pageGroup = &cfg.Groups[i]
95+ break
96+ }
97+ }
98+ require.NotNil(t, pageGroup, "vim mode group must be present in GetDefaultKeybindings output")
99+ assert.Equal(t, "Vim Mode", pageGroup.DisplayName)
100+ assert.Equal(t, "ctrl+y", pageGroup.Activation)
101+
102+ // Verify default keys match actual keys.
103+ for _, entry := range pageGroup.Bindings {
104+ assert.Equal(t, entry.DefaultKeys, entry.Keys,
105+ "default vim mode action %q should have keys == default_keys", entry.Action)
106+ }
107+}
108+
109+func TestKeybindingsGateway_VimModeSetKeybindingUpdatesFile(t *testing.T) {
110+ mgr := newTestKeybindingsManager(t)
111+ gw := NewKeybindingsGateway(mgr)
112+
113+ err := gw.SetKeybinding(context.Background(), port.SetKeybindingRequest{
114+ Mode: "vim",
115+ Action: "vim-scroll-left",
116+ Keys: []string{"a"},
117+ })
118+ require.NoError(t, err)
119+
120+ // Reload and assert on the parsed Vim-mode configuration rather than raw TOML text.
121+ reloaded, reloadErr := NewManager()
122+ require.NoError(t, reloadErr)
123+ require.NoError(t, reloaded.Load())
124+ assert.Equal(t, []string{"a"}, reloaded.Get().Workspace.VimMode.Actions["vim-scroll-left"].Keys)
125+}
126+
127+func TestKeybindingsGateway_VimModeResetKeybinding(t *testing.T) {
128+ mgr := newTestKeybindingsManager(t)
129+ gw := NewKeybindingsGateway(mgr)
130+
131+ // Set a keybinding.
132+ require.NoError(t, gw.SetKeybinding(context.Background(), port.SetKeybindingRequest{
133+ Mode: "vim",
134+ Action: "vim-scroll-left",
135+ Keys: []string{"a"},
136+ }))
137+
138+ // Reset it.
139+ err := gw.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{
140+ Mode: "vim",
141+ Action: "vim-scroll-left",
142+ })
143+ require.NoError(t, err)
144+
145+ // Reload and assert on the parsed default binding.
146+ reloaded, reloadErr := NewManager()
147+ require.NoError(t, reloadErr)
148+ require.NoError(t, reloaded.Load())
149+ assert.Equal(t, []string{"h"}, reloaded.Get().Workspace.VimMode.Actions["vim-scroll-left"].Keys)
150+}
151+
152+func TestKeybindingsGateway_VimModeIsPartOfResetAll(t *testing.T) {
153+ mgr := newTestKeybindingsManager(t)
154+ gw := NewKeybindingsGateway(mgr)
155+
156+ // Change a vim mode binding.
157+ require.NoError(t, gw.SetKeybinding(context.Background(), port.SetKeybindingRequest{
158+ Mode: "vim",
159+ Action: "vim-scroll-left",
160+ Keys: []string{"a"},
161+ }))
162+
163+ // Change another action.
164+ require.NoError(t, gw.SetKeybinding(context.Background(), port.SetKeybindingRequest{
165+ Mode: "vim",
166+ Action: "vim-scroll-down",
167+ Keys: []string{"b"},
168+ }))
169+
170+ // Reset all.
171+ err := gw.ResetAllKeybindings(context.Background())
172+ require.NoError(t, err)
173+
174+ // Reload and assert on the parsed default bindings.
175+ reloaded, reloadErr := NewManager()
176+ require.NoError(t, reloadErr)
177+ require.NoError(t, reloaded.Load())
178+ assert.Equal(t, []string{"h"}, reloaded.Get().Workspace.VimMode.Actions["vim-scroll-left"].Keys, "vim-scroll-left should be restored to default")
179+ assert.Equal(t, []string{"j"}, reloaded.Get().Workspace.VimMode.Actions["vim-scroll-down"].Keys, "vim-scroll-down should be restored to default")
180+}
181+
182+func TestKeybindingsGateway_VimModeConflictsWithinSelf(t *testing.T) {
183+ gw := NewKeybindingsGateway(inMemoryManager())
184+ conflicts, err := gw.CheckConflicts(context.Background(), "vim", "custom", []string{"h"})
185+ require.NoError(t, err)
186+ require.NotEmpty(t, conflicts, "should detect conflict with vim-scroll-left which uses 'h'")
187+
188+ // The conflict should reference the same mode.
189+ assert.Equal(t, "vim-scroll-left", conflicts[0].ConflictingAction)
190+ assert.Equal(t, "vim", conflicts[0].ConflictingMode)
191+}
192+
193+func TestKeybindingsGateway_VimModeConflictsWithOtherModes(t *testing.T) {
194+ gw := NewKeybindingsGateway(inMemoryManager())
195+ // "enter" is used by vim mode's confirm - but also by other modes like pane_mode.
196+ conflicts, err := gw.CheckConflicts(context.Background(), "vim", "custom", []string{"enter"})
197+ require.NoError(t, err)
198+
199+ // There should be conflicts since "enter" is used in other modes too.
200+ assert.NotEmpty(t, conflicts, "should detect cross-mode conflict for 'enter'")
201+}
202+
203+func TestKeybindingsGateway_VimModeNoConflictForUnusedKey(t *testing.T) {
204+ gw := NewKeybindingsGateway(inMemoryManager())
205+ conflicts, err := gw.CheckConflicts(context.Background(), "vim", "custom", []string{"ctrl+9"})
206+ require.NoError(t, err)
207+ assert.Empty(t, conflicts, "no conflict expected for unused key")
208+}
M internal/infrastructure/config/loader.go +22 -4
@@ -175,10 +175,6 @@ func (m *Manager) transformLegacyConfig() {
175175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176176 }
177177
178- // Merge newly added default actions into in-memory config so existing persisted
179- // action maps keep working before the user writes a migrated config to disk.
180- configMigrator.mergeMissingDefaultActions(rawConfig)
181-
182178 // Apply transformed config back to viper
183179 for key, value := range rawConfig {
184180 m.viper.Set(key, value)
@@ -378,6 +374,7 @@ func ensureDatabasePath(config *Config) error {
378374 }
379375
380376 func normalizeConfig(config *Config) {
377+ normalizeExternalLinks(config)
381378 normalizeAppearance(config)
382379 normalizeMedia(config)
383380 normalizeEngineConfig(config)
@@ -390,6 +387,20 @@ func normalizeBrowsingContexts(config *Config) {
390387 config.Workspace.Popups = config.Workspace.BrowsingContexts
391388 }
392389
390+func normalizeExternalLinks(config *Config) {
391+ switch config.Workspace.ExternalLinks.Behavior {
392+ case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked:
393+ default:
394+ config.Workspace.ExternalLinks.Behavior = defaultExternalLinkBehavior
395+ }
396+
397+ switch config.Workspace.ExternalLinks.Placement {
398+ case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom:
399+ default:
400+ config.Workspace.ExternalLinks.Placement = defaultExternalLinkPlacement
401+ }
402+}
403+
393404 func normalizeEngineConfig(config *Config) {
394405 // Normalize GSK renderer (engine.webkit)
395406 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
@@ -695,6 +706,11 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
695706 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
696707 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
697708 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
709+ m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut)
710+ m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds)
711+ m.viper.SetDefault("workspace.vim_mode.sequence_timeout_ms", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds)
712+ m.viper.SetDefault("workspace.vim_mode.preload_accessibility", defaults.Workspace.VimMode.PreloadAccessibility)
713+ m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions)
698714 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
699715 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
700716 m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
@@ -704,6 +720,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
704720 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
705721 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
706722 m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
723+ m.viper.SetDefault("workspace.external_links.behavior", string(defaults.Workspace.ExternalLinks.Behavior))
724+ m.viper.SetDefault("workspace.external_links.placement", string(defaults.Workspace.ExternalLinks.Placement))
707725 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
708726 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
709727 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
M internal/infrastructure/config/loader_actions_test.go +59 -4
@@ -36,7 +36,7 @@ touchpad_navigation_max_vertical_ratio = 2.0
3636 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
3737 }
3838
39-func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {
39+func TestManagerLoad_DoesNotMergeMissingPaneModeActionsInMemory(t *testing.T) {
4040 home := t.TempDir()
4141 t.Setenv("HOME", home)
4242 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
@@ -64,10 +64,65 @@ desc = "Custom stack pane"
6464 cfg := mgr.Get()
6565 require.NotNil(t, cfg)
6666
67- eject, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68- require.True(t, ok)
69- assert.Equal(t, []string{"w"}, eject.Keys)
67+ _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68+ assert.False(t, ok)
7069
7170 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
7271 assert.Equal(t, "Custom stack pane", stack.Desc)
7372 }
73+
74+func TestManagerLoad_DoesNotMigrateLegacyFavoritesShortcut(t *testing.T) {
75+ home := t.TempDir()
76+ t.Setenv("HOME", home)
77+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
78+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
79+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
80+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
81+
82+ configFile, err := GetConfigFile()
83+ require.NoError(t, err)
84+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
85+ require.NoError(t, os.WriteFile(configFile, []byte(`
86+[workspace.shortcuts.actions.toggle-favorites-systemview]
87+keys = []
88+desc = "Custom favorites shortcut"
89+`), 0o644))
90+
91+ mgr, err := NewManager()
92+ require.NoError(t, err)
93+ require.NoError(t, mgr.Load())
94+
95+ favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
96+ assert.Empty(t, favorites.Keys)
97+ assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
98+}
99+
100+func TestManagerLoad_DoesNotMigrateLegacyFavoritesShortcutDefault(t *testing.T) {
101+ home := t.TempDir()
102+ t.Setenv("HOME", home)
103+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
104+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
105+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
106+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
107+
108+ configFile, err := GetConfigFile()
109+ require.NoError(t, err)
110+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
111+ require.NoError(t, os.WriteFile(configFile, []byte(`
112+[workspace.shortcuts.actions.toggle-favorites-systemview]
113+keys = []
114+desc = "Toggle Favorites in right split"
115+`), 0o644))
116+
117+ mgr, err := NewManager()
118+ require.NoError(t, err)
119+ require.NoError(t, mgr.Load())
120+
121+ cfg := mgr.Get()
122+ require.NotNil(t, cfg)
123+ favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
124+ assert.Empty(t, favorites.Keys)
125+ assert.Equal(t, "Toggle Favorites in right split", favorites.Desc)
126+ _, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
127+ assert.False(t, ok)
128+}
M internal/infrastructure/config/loader_test.go +11 -0
@@ -39,6 +39,17 @@ func TestSetEngineDefaults(t *testing.T) {
3939 assert.Empty(t, mgr.viper.GetString("runtime.prefix"))
4040 }
4141
42+func TestNormalizeExternalLinks(t *testing.T) {
43+ cfg := DefaultConfig()
44+ cfg.Workspace.ExternalLinks.Behavior = "invalid"
45+ cfg.Workspace.ExternalLinks.Placement = "invalid"
46+
47+ normalizeConfig(cfg)
48+
49+ assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)
50+ assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)
51+}
52+
4253 func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {
4354 cfg := DefaultConfig()
4455 cfg.Engine.CookiePolicy = CookiePolicy("INVALID")
M internal/infrastructure/config/migrate.go +228 -13
@@ -25,11 +25,6 @@ type Migrator struct {
2525 defaultConfig *Config
2626 }
2727
28-// configMigrator reuses the default-action metadata needed during config load.
29-// It is immutable after initialization; per-command migration still constructs
30-// explicit Migrator instances when callers need independent state.
31-var configMigrator = NewMigrator()
32-
3328 // NewMigrator creates a new Migrator instance.
3429 func NewMigrator() *Migrator {
3530 v := viper.New()
@@ -58,17 +53,22 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
5853 return nil, nil
5954 }
6055
61- // Get user-defined keys from the TOML file
62- userKeys, err := m.getUserConfigKeys(configFile)
56+ userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
6357 if err != nil {
6458 return nil, fmt.Errorf("failed to parse user config: %w", err)
6559 }
6660
61+ userKeys := make(map[string]bool, len(userKeysWithValues))
62+ for key := range userKeysWithValues {
63+ userKeys[key] = true
64+ }
65+
6766 // Get all default keys
6867 defaultKeys := m.getAllDefaultKeys()
6968
7069 // Find missing keys (in defaults but not in user config)
7170 missingKeys := m.findMissingKeys(defaultKeys, userKeys)
71+ missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
7272
7373 if len(missingKeys) == 0 {
7474 return nil, nil
@@ -119,13 +119,17 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
119119 userKeySet[k] = true
120120 }
121121
122- legacyBrowsingContextRenames := m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues)
122+ legacyRenames := append(
123+ m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues),
124+ m.detectLegacyFavoritesSidebarRename(userKeysWithValues)...,
125+ )
126+ legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...)
123127 var changes []port.KeyChange
124- changes = append(changes, legacyBrowsingContextRenames...)
128+ changes = append(changes, legacyRenames...)
125129
126- handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyBrowsingContextRenames))
127- handledLegacyMissingKeys := make(map[string]bool, len(legacyBrowsingContextRenames))
128- for _, change := range legacyBrowsingContextRenames {
130+ handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyRenames))
131+ handledLegacyMissingKeys := make(map[string]bool, len(legacyRenames))
132+ for _, change := range legacyRenames {
129133 handledLegacyDeprecatedKeys[change.OldKey] = true
130134 handledLegacyMissingKeys[change.NewKey] = true
131135 }
@@ -299,6 +303,7 @@ func (m *Migrator) defaultActionMaps() []defaultActionMap {
299303 {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
300304 {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
301305 {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
306+ {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},
302307 {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},
303308 {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},
304309 }
@@ -416,6 +421,7 @@ func (m *Migrator) Migrate() ([]string, error) {
416421 transformer.TransformLegacyActions(rawConfig)
417422 transformer.TransformLegacyEngineConfig(rawConfig)
418423 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
424+ m.migrateLegacyVimMode(rawConfig)
419425
420426 // Build sets of keys to remove and renames to apply
421427 keysToRemove := make(map[string]bool)
@@ -443,10 +449,11 @@ func (m *Migrator) Migrate() ([]string, error) {
443449 }
444450 }
445451
446- // Remove deprecated and renamed keys from the raw config
452+ // Remove deprecated and renamed keys from the raw config.
447453 for key := range keysToRemove {
448454 m.deleteNestedKey(rawConfig, key)
449455 }
456+ m.upgradeLegacyFavoritesSidebarDefault(rawConfig)
450457 m.mergeMissingDefaultActions(rawConfig)
451458
452459 // Create a new Viper instance with defaults for added keys
@@ -722,6 +729,31 @@ func (m *Migrator) getRawUserConfigKeysWithValues(configFile string) (map[string
722729 return result, nil
723730 }
724731
732+func (m *Migrator) detectLegacyFavoritesSidebarRename(userKeys map[string]any) []port.KeyChange {
733+ const (
734+ actionsKey = "workspace.shortcuts.actions"
735+ legacyName = "toggle-favorites-systemview"
736+ newName = "toggle-favorites-sidebar"
737+ )
738+
739+ actionsValue, ok := userKeys[actionsKey]
740+ if !ok {
741+ return nil
742+ }
743+ actions, ok := m.toStringAnyMap(actionsValue)
744+ if !ok || actions[legacyName] == nil || actions[newName] != nil {
745+ return nil
746+ }
747+
748+ return []port.KeyChange{{
749+ Type: port.KeyChangeRenamed,
750+ OldKey: actionsKey + "." + legacyName,
751+ NewKey: actionsKey + "." + newName,
752+ OldValue: m.formatValue(actions[legacyName]),
753+ NewValue: m.formatValue(m.defaultConfig.Workspace.Shortcuts.Actions[newName]),
754+ }}
755+}
756+
725757 func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange {
726758 type renamePair struct {
727759 oldKey string
@@ -758,6 +790,104 @@ func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any
758790 return changes
759791 }
760792
793+func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange {
794+ changes := make([]port.KeyChange, 0, 3)
795+ for oldKey, oldValue := range rawUserKeys {
796+ newKey, ok := legacyVimModeKey(oldKey)
797+ if !ok {
798+ continue
799+ }
800+ if _, hasNew := rawUserKeys[newKey]; hasNew {
801+ continue
802+ }
803+ changes = append(changes, port.KeyChange{
804+ Type: port.KeyChangeRenamed,
805+ OldKey: oldKey,
806+ NewKey: newKey,
807+ OldValue: m.formatValue(oldValue),
808+ NewValue: m.formatValue(m.defaultValueForKey(newKey)),
809+ })
810+ }
811+ return changes
812+}
813+
814+func (m *Migrator) migrateLegacyVimMode(rawConfig map[string]any) {
815+ workspace, ok := m.toStringAnyMap(rawConfig["workspace"])
816+ if !ok {
817+ return
818+ }
819+ rawConfig["workspace"] = workspace
820+ legacyMode, ok := m.toStringAnyMap(workspace["page_mode"])
821+ if !ok {
822+ return
823+ }
824+ workspace["page_mode"] = legacyMode
825+ vimMode, ok := m.toStringAnyMap(workspace["vim_mode"])
826+ if !ok {
827+ vimMode = make(map[string]any)
828+ workspace["vim_mode"] = vimMode
829+ } else {
830+ workspace["vim_mode"] = vimMode
831+ }
832+ if legacyActions, ok := m.toStringAnyMap(legacyMode["actions"]); ok {
833+ legacyMode["actions"] = legacyActions
834+ }
835+ if vimActions, ok := m.toStringAnyMap(vimMode["actions"]); ok {
836+ vimMode["actions"] = vimActions
837+ }
838+ for key, value := range legacyMode {
839+ if key == "actions" {
840+ continue
841+ }
842+ if _, exists := vimMode[key]; !exists {
843+ vimMode[key] = value
844+ }
845+ }
846+ legacyActions, _ := m.toStringAnyMap(legacyMode["actions"])
847+ vimActions, _ := m.toStringAnyMap(vimMode["actions"])
848+ if vimActions == nil {
849+ vimActions = make(map[string]any)
850+ vimMode["actions"] = vimActions
851+ }
852+ for oldAction, value := range legacyActions {
853+ newKey, ok := legacyVimModeKey("workspace.page_mode.actions." + oldAction)
854+ if !ok {
855+ continue
856+ }
857+ newAction := strings.TrimPrefix(newKey, "workspace.vim_mode.actions.")
858+ if _, exists := vimActions[newAction]; !exists {
859+ vimActions[newAction] = value
860+ }
861+ }
862+ delete(workspace, "page_mode")
863+}
864+
865+// legacyVimModeKey converts one legacy Page Mode config key to its canonical
866+// Vim Mode key, including action-name normalization.
867+func legacyVimModeKey(oldKey string) (string, bool) {
868+ const oldPrefix = "workspace.page_mode"
869+ const newPrefix = "workspace.vim_mode"
870+
871+ if !strings.HasPrefix(oldKey, oldPrefix+".") {
872+ return "", false
873+ }
874+ newKey := newPrefix + strings.TrimPrefix(oldKey, oldPrefix)
875+ const actionsPrefix = newPrefix + ".actions."
876+ if strings.HasPrefix(newKey, actionsPrefix) {
877+ return actionsPrefix + legacyVimActionName(strings.TrimPrefix(newKey, actionsPrefix)), true
878+ }
879+ return newKey, true
880+}
881+
882+func legacyVimActionName(action string) string {
883+ for _, prefix := range []string{"page-scroll-", "page_scroll_"} {
884+ if strings.HasPrefix(action, prefix) {
885+ return "vim-scroll-" + strings.ReplaceAll(strings.TrimPrefix(action, prefix), "_", "-")
886+ }
887+ }
888+ return action
889+}
890+
761891 // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
762892 func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
763893 for k, v := range data {
@@ -894,6 +1024,91 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
8941024 }
8951025 }
8961026
1027+func (m *Migrator) upgradeLegacyFavoritesSidebarDefault(rawConfig map[string]any) {
1028+ const (
1029+ actionsKey = "workspace.shortcuts.actions"
1030+ actionName = "toggle-favorites-sidebar"
1031+ )
1032+
1033+ actionsValue := m.getNestedValue(rawConfig, actionsKey)
1034+ actions, ok := m.toStringAnyMap(actionsValue)
1035+ if !ok {
1036+ return
1037+ }
1038+ binding, ok := m.actionBindingFromAny(actions[actionName])
1039+ if !ok || !reflect.DeepEqual(binding, entity.ActionBinding{Keys: []string{}, Desc: "Toggle Favorites in right split"}) {
1040+ return
1041+ }
1042+ actions[actionName] = m.defaultConfig.Workspace.Shortcuts.Actions[actionName]
1043+ m.setNestedValue(rawConfig, actionsKey, actions)
1044+}
1045+
1046+func (m *Migrator) actionBindingFromAny(value any) (entity.ActionBinding, bool) {
1047+ switch v := value.(type) {
1048+ case entity.ActionBinding:
1049+ return v, true
1050+ case map[string]any:
1051+ return actionBindingFromMap(v)
1052+ default:
1053+ mapping, ok := m.toStringAnyMap(value)
1054+ if !ok {
1055+ return entity.ActionBinding{}, false
1056+ }
1057+ return actionBindingFromMap(mapping)
1058+ }
1059+}
1060+
1061+func actionBindingFromMap(m map[string]any) (entity.ActionBinding, bool) {
1062+ var binding entity.ActionBinding
1063+ if keysValue, exists := m["keys"]; exists {
1064+ keys, ok := stringSliceFromAny(keysValue)
1065+ if !ok {
1066+ return entity.ActionBinding{}, false
1067+ }
1068+ binding.Keys = keys
1069+ }
1070+ if descValue, exists := m["desc"]; exists {
1071+ desc, ok := descValue.(string)
1072+ if !ok {
1073+ return entity.ActionBinding{}, false
1074+ }
1075+ binding.Desc = desc
1076+ }
1077+ return binding, true
1078+}
1079+
1080+func stringSliceFromAny(value any) ([]string, bool) {
1081+ switch v := value.(type) {
1082+ case []string:
1083+ return append([]string(nil), v...), true
1084+ case []any:
1085+ out := make([]string, 0, len(v))
1086+ for _, item := range v {
1087+ s, ok := item.(string)
1088+ if !ok {
1089+ return nil, false
1090+ }
1091+ out = append(out, s)
1092+ }
1093+ return out, true
1094+ default:
1095+ rv := reflect.ValueOf(value)
1096+ if rv.Kind() != reflect.Slice {
1097+ return nil, false
1098+ }
1099+ out := make([]string, 0, rv.Len())
1100+ for i := 0; i < rv.Len(); i++ {
1101+ item := rv.Index(i).Interface()
1102+ s, ok := item.(string)
1103+ if !ok {
1104+ return nil, false
1105+ }
1106+ out = append(out, s)
1107+ }
1108+ return out, true
1109+ }
1110+}
1111+
8971112 func appendUniqueKeys(base, extra []string) []string {
8981113 if len(extra) == 0 {
8991114 return base
M internal/infrastructure/config/migrate_test.go +258 -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
@@ -1175,6 +1210,118 @@ func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
11751210 assert.NotContains(t, string(migrated), "[workspace.popups]")
11761211 }
11771212
1213+func TestMigrator_Migrate_RenamesLegacyPageModeToVimMode(t *testing.T) {
1214+ home := t.TempDir()
1215+ t.Setenv("HOME", home)
1216+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1217+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1218+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1219+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1220+
1221+ configFile, err := GetConfigFile()
1222+ require.NoError(t, err)
1223+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1224+ require.NoError(t, os.WriteFile(configFile, []byte(`
1225+[workspace.page_mode]
1226+activation_shortcut = "ctrl+alt+y"
1227+timeout_ms = 250
1228+
1229+[workspace.page_mode.actions.page-scroll-down]
1230+keys = ["n"]
1231+desc = "Scroll down"
1232+
1233+[workspace.page_mode.actions.page_scroll_up]
1234+keys = ["u"]
1235+desc = "Scroll up"
1236+
1237+[workspace.page_mode.actions.page_scroll_down_fast]
1238+keys = ["d"]
1239+desc = "Scroll down fast"
1240+`), 0o644))
1241+
1242+ migrator := NewMigrator()
1243+ applied, err := migrator.Migrate()
1244+ require.NoError(t, err)
1245+ assert.Contains(t, applied, "workspace.page_mode.activation_shortcut -> workspace.vim_mode.activation_shortcut")
1246+ assert.Contains(t, applied, "workspace.page_mode.timeout_ms -> workspace.vim_mode.timeout_ms")
1247+ assert.Contains(t, applied, "workspace.page_mode.actions -> workspace.vim_mode.actions")
1248+
1249+ migrated, err := os.ReadFile(configFile)
1250+ require.NoError(t, err)
1251+ assert.Contains(t, string(migrated), "[workspace.vim_mode]")
1252+ assert.Contains(t, string(migrated), "activation_shortcut = 'ctrl+alt+y'")
1253+ assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]")
1254+ assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]")
1255+ assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down-fast]")
1256+ assert.Contains(t, string(migrated), "keys = ['u']")
1257+ assert.NotContains(t, string(migrated), "page_mode")
1258+ assert.NotContains(t, string(migrated), "page-scroll-down")
1259+}
1260+
1261+func TestMigrator_DetectChanges_CanonicalizesLegacyVimActionNames(t *testing.T) {
1262+ home := t.TempDir()
1263+ t.Setenv("HOME", home)
1264+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1265+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1266+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1267+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1268+
1269+ configFile, err := GetConfigFile()
1270+ require.NoError(t, err)
1271+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1272+ require.NoError(t, os.WriteFile(configFile, []byte(`
1273+[workspace.page_mode.actions.page_scroll_down_fast]
1274+keys = ["d"]
1275+desc = "Scroll down fast"
1276+`), 0o644))
1277+
1278+ changes, err := NewMigrator().DetectChanges()
1279+ require.NoError(t, err)
1280+
1281+ foundRename := false
1282+ for _, change := range changes {
1283+ if change.Type == port.KeyChangeRenamed &&
1284+ change.OldKey == "workspace.page_mode.actions" &&
1285+ change.NewKey == "workspace.vim_mode.actions" {
1286+ foundRename = true
1287+ }
1288+ }
1289+ assert.True(t, foundRename, "DetectChanges must report the canonical Vim Mode actions key")
1290+ assert.Equal(t, "vim-scroll-down-fast", legacyVimActionName("page_scroll_down_fast"))
1291+}
1292+
1293+func TestMigrator_Migrate_MergesLegacyPageModeActionsIntoExistingVimMode(t *testing.T) {
1294+ home := t.TempDir()
1295+ t.Setenv("HOME", home)
1296+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1297+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1298+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1299+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1300+
1301+ configFile, err := GetConfigFile()
1302+ require.NoError(t, err)
1303+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1304+ require.NoError(t, os.WriteFile(configFile, []byte(`
1305+[workspace.page_mode.actions.page-scroll-down]
1306+keys = ["n"]
1307+
1308+[workspace.page_mode.actions.page-scroll-up]
1309+keys = ["u"]
1310+
1311+[workspace.vim_mode.actions.vim-scroll-down]
1312+keys = ["j"]
1313+`), 0o644))
1314+
1315+ _, err = NewMigrator().Migrate()
1316+ require.NoError(t, err)
1317+
1318+ migrated, err := os.ReadFile(configFile)
1319+ require.NoError(t, err)
1320+ assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]\n keys = ['j']")
1321+ assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]\n keys = ['u']")
1322+ assert.NotContains(t, string(migrated), "page_mode")
1323+}
1324+
11781325 func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
11791326 home := t.TempDir()
11801327 t.Setenv("HOME", home)
@@ -1282,3 +1429,114 @@ section:
12821429 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
12831430 })
12841431 }
1432+
1433+func TestMigrator_Migrate_RenamesCustomFavoritesSidebarShortcut(t *testing.T) {
1434+ home := t.TempDir()
1435+ t.Setenv("HOME", home)
1436+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1437+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1438+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1439+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1440+
1441+ configFile, err := GetConfigFile()
1442+ require.NoError(t, err)
1443+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1444+ require.NoError(t, os.WriteFile(configFile, []byte(`
1445+[workspace.shortcuts.actions.toggle-favorites-systemview]
1446+keys = []
1447+desc = "Custom favorites shortcut"
1448+`), 0o644))
1449+
1450+ migrator := NewMigrator()
1451+ result, err := migrator.CheckMigration()
1452+ require.NoError(t, err)
1453+ require.NotNil(t, result)
1454+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1455+
1456+ changes, err := migrator.DetectChanges()
1457+ require.NoError(t, err)
1458+ assertContainsChange(t, changes, port.KeyChangeRenamed,
1459+ "workspace.shortcuts.actions.toggle-favorites-systemview",
1460+ "workspace.shortcuts.actions.toggle-favorites-sidebar")
1461+
1462+ applied, err := migrator.Migrate()
1463+ require.NoError(t, err)
1464+ assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1465+
1466+ mgr, err := NewManager()
1467+ require.NoError(t, err)
1468+ require.NoError(t, mgr.Load())
1469+ _, ok := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1470+ assert.False(t, ok)
1471+ favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
1472+ assert.Empty(t, favorites.Keys)
1473+ assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
1474+}
1475+
1476+func TestMigrator_Migrate_UpgradesLegacyFavoritesSidebarDefault(t *testing.T) {
1477+ home := t.TempDir()
1478+ t.Setenv("HOME", home)
1479+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1480+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1481+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1482+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1483+
1484+ configFile, err := GetConfigFile()
1485+ require.NoError(t, err)
1486+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1487+ require.NoError(t, os.WriteFile(configFile, []byte(`
1488+[workspace.shortcuts.actions.toggle-favorites-systemview]
1489+keys = []
1490+desc = "Toggle Favorites in right split"
1491+`), 0o644))
1492+
1493+ migrator := NewMigrator()
1494+ result, err := migrator.CheckMigration()
1495+ require.NoError(t, err)
1496+ require.NotNil(t, result)
1497+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1498+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-current-page-favorite")
1499+
1500+ changes, err := migrator.DetectChanges()
1501+ require.NoError(t, err)
1502+ assertContainsChange(t, changes, port.KeyChangeRenamed,
1503+ "workspace.shortcuts.actions.toggle-favorites-systemview",
1504+ "workspace.shortcuts.actions.toggle-favorites-sidebar")
1505+ assertContainsChange(t, changes, port.KeyChangeAdded, "", "workspace.shortcuts.actions.toggle-current-page-favorite")
1506+
1507+ applied, err := migrator.Migrate()
1508+ require.NoError(t, err)
1509+ assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1510+ assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-current-page-favorite")
1511+
1512+ raw, err := migrator.readRawConfig(configFile)
1513+ require.NoError(t, err)
1514+ workspace, ok := raw["workspace"].(map[string]any)
1515+ require.True(t, ok)
1516+ shortcuts, ok := workspace["shortcuts"].(map[string]any)
1517+ require.True(t, ok)
1518+ actions, ok := shortcuts["actions"].(map[string]any)
1519+ require.True(t, ok)
1520+
1521+ expectedActions := DefaultConfig().Workspace.Shortcuts.Actions
1522+ _, ok = actions["toggle-favorites-systemview"]
1523+ assert.False(t, ok)
1524+ favorites, ok := actions["toggle-favorites-sidebar"].(map[string]any)
1525+ require.True(t, ok)
1526+ assert.Equal(t, []any{"ctrl+b"}, favorites["keys"])
1527+ assert.Equal(t, expectedActions["toggle-favorites-sidebar"].Desc, favorites["desc"])
1528+ currentPage, ok := actions["toggle-current-page-favorite"].(map[string]any)
1529+ require.True(t, ok)
1530+ assert.Equal(t, []any{"ctrl+d"}, currentPage["keys"])
1531+ assert.Equal(t, expectedActions["toggle-current-page-favorite"].Desc, currentPage["desc"])
1532+
1533+ mgr, err := NewManager()
1534+ require.NoError(t, err)
1535+ require.NoError(t, mgr.Load())
1536+ cfg := mgr.Get()
1537+ require.NotNil(t, cfg)
1538+ _, ok = cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1539+ assert.False(t, ok)
1540+ assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)
1541+ assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)
1542+}
M internal/infrastructure/config/schema.go +26 -0
@@ -24,6 +24,9 @@ type TabModeConfig = entity.TabModeConfig
2424 // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
2525 type ResizeModeConfig = entity.ResizeModeConfig
2626
27+// VimModeConfig defines modal behavior for Vim-style scrolling.
28+type VimModeConfig = entity.VimModeConfig
29+
2730 // SessionModeConfig defines modal behavior for session management.
2831 type SessionModeConfig = entity.SessionModeConfig
2932
@@ -42,6 +45,29 @@ type FloatingPaneConfig = entity.FloatingPaneConfig
4245 // WorkspaceStylingConfig defines visual styling for workspace panes.
4346 type WorkspaceStylingConfig = entity.WorkspaceStylingConfig
4447
48+// ExternalLinkBehavior defines how externally opened URLs are placed in the workspace.
49+type ExternalLinkBehavior = entity.ExternalLinkBehavior
50+
51+const (
52+ ExternalLinkBehaviorWindowed = entity.ExternalLinkBehaviorWindowed
53+ ExternalLinkBehaviorTabbed = entity.ExternalLinkBehaviorTabbed
54+ ExternalLinkBehaviorSplit = entity.ExternalLinkBehaviorSplit
55+ ExternalLinkBehaviorStacked = entity.ExternalLinkBehaviorStacked
56+)
57+
58+// ExternalLinkPlacement defines the direction used when splitting for an external URL.
59+type ExternalLinkPlacement = entity.ExternalLinkPlacement
60+
61+const (
62+ ExternalLinkPlacementRight = entity.ExternalLinkPlacementRight
63+ ExternalLinkPlacementLeft = entity.ExternalLinkPlacementLeft
64+ ExternalLinkPlacementTop = entity.ExternalLinkPlacementTop
65+ ExternalLinkPlacementBottom = entity.ExternalLinkPlacementBottom
66+)
67+
68+// ExternalLinksConfig controls how externally opened URLs are placed in the workspace.
69+type ExternalLinksConfig = entity.ExternalLinksConfig
70+
4571 // PopupBehavior defines how popup windows should be opened.
4672 type PopupBehavior = entity.PopupBehavior
4773
M internal/infrastructure/config/schema_provider.go +55 -0
@@ -472,6 +472,44 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
472472 Description: "Key bindings for tab mode actions",
473473 Section: SectionWorkspace,
474474 },
475+ // Vim mode
476+ {
477+ Key: "workspace.vim_mode.activation_shortcut",
478+ Type: "string",
479+ Default: defaults.Workspace.VimMode.ActivationShortcut,
480+ Description: "Shortcut to enter vim scroll mode",
481+ Section: SectionWorkspace,
482+ },
483+ {
484+ Key: "workspace.vim_mode.timeout_ms",
485+ Type: "int",
486+ Default: fmt.Sprintf("%d", defaults.Workspace.VimMode.TimeoutMilliseconds),
487+ Description: "Vim mode timeout in milliseconds (0 = no timeout)",
488+ Range: ">=0",
489+ Section: SectionWorkspace,
490+ },
491+ {
492+ Key: "workspace.vim_mode.sequence_timeout_ms",
493+ Type: "int",
494+ Default: fmt.Sprintf("%d", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds),
495+ Description: "Vim mode multi-key sequence timeout in milliseconds (0 = immediate ambiguity resolution)",
496+ Range: ">=0",
497+ Section: SectionWorkspace,
498+ },
499+ {
500+ Key: "workspace.vim_mode.preload_accessibility",
501+ Type: "bool",
502+ Default: fmt.Sprintf("%t", defaults.Workspace.VimMode.PreloadAccessibility),
503+ Description: "Pre-enable browser accessibility when a WebView is shown (default false; enable on Vim Mode enter otherwise)",
504+ Section: SectionWorkspace,
505+ },
506+ {
507+ Key: "workspace.vim_mode.actions.<action>",
508+ Type: "object",
509+ Default: "(see defaults)",
510+ Description: "Vim mode action object with keys ([]string) and desc (string)",
511+ Section: SectionWorkspace,
512+ },
475513 // Resize mode
476514 {
477515 Key: "workspace.resize_mode.activation_shortcut",
@@ -541,6 +579,23 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
541579 Description: "Named floating pane URL profiles with keys, url, and optional desc",
542580 Section: SectionWorkspace,
543581 },
582+ // External links
583+ {
584+ Key: "workspace.external_links.behavior",
585+ Type: "string",
586+ Default: string(defaults.Workspace.ExternalLinks.Behavior),
587+ Description: "How URLs opened from external applications are placed",
588+ Values: []string{"windowed", "tabbed", "split", "stacked"},
589+ Section: SectionWorkspace,
590+ },
591+ {
592+ Key: "workspace.external_links.placement",
593+ Type: "string",
594+ Default: string(defaults.Workspace.ExternalLinks.Placement),
595+ Description: "Placement direction for split external links",
596+ Values: []string{"right", "left", "top", "bottom"},
597+ Section: SectionWorkspace,
598+ },
544599 // Browsing contexts
545600 {
546601 Key: "workspace.browsing_contexts.behavior",
M internal/infrastructure/config/schema_provider_test.go +1 -0
@@ -146,6 +146,7 @@ func TestSchemaProviderCoversViperDefaults(t *testing.T) {
146146 "search_shortcuts": "search_shortcuts.<key>",
147147 "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>",
148148 "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>",
149+ "workspace.vim_mode.actions": "workspace.vim_mode.actions.<action>",
149150 "workspace.resize_mode.actions": "workspace.resize_mode.actions.<action>",
150151 "workspace.floating_pane.profiles": "workspace.floating_pane.profiles.<name>",
151152 "session.session_mode.actions": "session.session_mode.actions.<action>",
M internal/infrastructure/config/validation.go +141 -37
@@ -11,6 +11,7 @@ import (
1111
1212 domainurl "github.com/bnema/dumber/internal/domain/url"
1313 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
14+ "github.com/bnema/dumber/internal/domain/vimkeys"
1415 )
1516
1617 const cefLogSeverityDisabled = 99
@@ -24,10 +25,12 @@ func validateConfig(config *Config) error {
2425 validationErrors = append(validationErrors, validateAppearance(config)...)
2526 validationErrors = append(validationErrors, validateSearchEngine(config)...)
2627 validationErrors = append(validationErrors, validatePopups(config)...)
28+ validationErrors = append(validationErrors, validateExternalLinks(config)...)
2729 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
2830 validationErrors = append(validationErrors, validatePaneMode(config)...)
2931 validationErrors = append(validationErrors, validateTabBar(config)...)
3032 validationErrors = append(validationErrors, validateTabMode(config)...)
33+ validationErrors = append(validationErrors, validateVimMode(config)...)
3134 validationErrors = append(validationErrors, validateFloatingPane(config)...)
3235 validationErrors = append(validationErrors, validateLogging(config)...)
3336 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
@@ -187,6 +190,28 @@ func validatePopups(config *Config) []string {
187190 return validationErrors
188191 }
189192
193+func validateExternalLinks(config *Config) []string {
194+ var validationErrors []string
195+ switch config.Workspace.ExternalLinks.Behavior {
196+ case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked:
197+ default:
198+ validationErrors = append(validationErrors, fmt.Sprintf(
199+ "workspace.external_links.behavior must be one of: windowed, tabbed, split, stacked (got: %s)",
200+ config.Workspace.ExternalLinks.Behavior,
201+ ))
202+ }
203+
204+ switch config.Workspace.ExternalLinks.Placement {
205+ case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom:
206+ default:
207+ validationErrors = append(validationErrors, fmt.Sprintf(
208+ "workspace.external_links.placement must be one of: right, left, top, bottom (got: %s)",
209+ config.Workspace.ExternalLinks.Placement,
210+ ))
211+ }
212+ return validationErrors
213+}
214+
190215 func validateWorkspaceStyling(config *Config) []string {
191216 var validationErrors []string
192217 if config.Workspace.Styling.BorderWidth < 0 {
@@ -202,32 +227,7 @@ func validateWorkspaceStyling(config *Config) []string {
202227 }
203228
204229 func validatePaneMode(config *Config) []string {
205- var validationErrors []string
206- if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
207- validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
208- }
209- if len(config.Workspace.PaneMode.Actions) == 0 {
210- validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
211- }
212-
213- seenKeys := make(map[string]string)
214- for action, binding := range config.Workspace.PaneMode.Actions {
215- if len(binding.Keys) == 0 {
216- validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
217- }
218- for _, key := range binding.Keys {
219- if existingAction, exists := seenKeys[key]; exists {
220- validationErrors = append(validationErrors, fmt.Sprintf(
221- "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'",
222- key,
223- existingAction,
224- action,
225- ))
226- }
227- seenKeys[key] = action
228- }
229- }
230- return validationErrors
230+ return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions)
231231 }
232232
233233 func validateTabBar(config *Config) []string {
@@ -240,34 +240,138 @@ func validateTabBar(config *Config) []string {
240240 }
241241
242242 func validateTabMode(config *Config) []string {
243+ return validateModalModeActions("tab_mode", config.Workspace.TabMode.TimeoutMilliseconds, config.Workspace.TabMode.Actions)
244+}
245+
246+func validateVimMode(config *Config) []string {
247+ var validationErrors []string
248+ if config.Workspace.VimMode.SequenceTimeoutMilliseconds < 0 {
249+ validationErrors = append(validationErrors, "workspace.vim_mode.sequence_timeout_ms must be non-negative")
250+ }
251+ validationErrors = append(validationErrors, validateModalModeActionsWithKeyCanon(
252+ "vim_mode",
253+ config.Workspace.VimMode.TimeoutMilliseconds,
254+ config.Workspace.VimMode.Actions,
255+ canonicalizeVimModeSequenceKey,
256+ )...)
257+ return validationErrors
258+}
259+
260+// validateModalModeActions shared timeout/empty-actions/duplicate-key checks for
261+// pane, tab, and page modal modes. Messages stay path-exact via modePath.
262+func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string {
263+ return validateModalModeActionsWithKeyCanon(modePath, timeoutMS, actions, nil)
264+}
265+
266+func validateModalModeActionsWithKeyCanon(
267+ modePath string,
268+ timeoutMS int,
269+ actions map[string]ActionBinding,
270+ keyCanon func(string) (string, error),
271+) []string {
243272 var validationErrors []string
244- if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
245- validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
273+ if timeoutMS < 0 {
274+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath))
275+ }
276+ if len(actions) == 0 {
277+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))
246278 }
247- if len(config.Workspace.TabMode.Actions) == 0 {
248- validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
279+
280+ if keyCanon == nil {
281+ keyCanon = func(key string) (string, error) { return key, nil }
249282 }
250283
251- tabSeenKeys := make(map[string]string)
252- for action, binding := range config.Workspace.TabMode.Actions {
284+ actionNames := make([]string, 0, len(actions))
285+ for action := range actions {
286+ actionNames = append(actionNames, action)
287+ }
288+ sort.Strings(actionNames)
289+
290+ seenSequence := make(map[string]string)
291+ for _, action := range actionNames {
292+ binding := actions[action]
253293 if len(binding.Keys) == 0 {
254- validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
294+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
295+ continue
255296 }
256- for _, key := range binding.Keys {
257- if existingAction, exists := tabSeenKeys[key]; exists {
297+ keys := append([]string(nil), binding.Keys...)
298+ sort.Strings(keys)
299+ for _, key := range keys {
300+ canonical, err := keyCanon(key)
301+ if err != nil {
258302 validationErrors = append(validationErrors, fmt.Sprintf(
259- "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'",
303+ "workspace.%s.actions.%s has invalid key binding '%s': %v",
304+ modePath,
305+ action,
260306 key,
307+ err,
308+ ))
309+ continue
310+ }
311+ if modePath == "vim_mode" {
312+ sequence, parseErr := vimkeys.ParseBinding(canonical)
313+ if parseErr == nil {
314+ if len(sequence) > 0 && isVimCountStartKey(sequence[0]) {
315+ validationErrors = append(validationErrors, fmt.Sprintf(
316+ "workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'",
317+ modePath,
318+ action,
319+ canonical,
320+ ))
321+ }
322+ legacyExit := action == "confirm" || action == "cancel"
323+ if !legacyExit {
324+ for _, key := range sequence {
325+ if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") {
326+ validationErrors = append(validationErrors, fmt.Sprintf(
327+ "workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions",
328+ modePath,
329+ action,
330+ ))
331+ break
332+ }
333+ }
334+ }
335+ }
336+ }
337+ if existingAction, exists := seenSequence[canonical]; exists {
338+ validationErrors = append(validationErrors, fmt.Sprintf(
339+ "duplicate key binding '%s' found in %s actions '%s' and '%s'",
340+ canonical,
341+ modePath,
261342 existingAction,
262343 action,
263344 ))
264345 }
265- tabSeenKeys[key] = action
346+ seenSequence[canonical] = action
266347 }
267348 }
268349 return validationErrors
269350 }
270351
352+func isVimCountStartKey(key vimkeys.Key) bool {
353+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
354+}
355+
356+func canonicalizeVimModeBinding(key string) string {
357+ switch strings.ToLower(strings.TrimSpace(key)) {
358+ case "enter":
359+ return "<Return>"
360+ case "escape":
361+ return "<Escape>"
362+ default:
363+ return key
364+ }
365+}
366+
367+func canonicalizeVimModeSequenceKey(key string) (string, error) {
368+ seq, err := vimkeys.ParseBinding(canonicalizeVimModeBinding(key))
369+ if err != nil {
370+ return "", err
371+ }
372+ return seq.String(), nil
373+}
374+
271375 func validateFloatingPane(config *Config) []string {
272376 var validationErrors []string
273377 seenProfileKeys := make(map[string]string)
M internal/infrastructure/config/validation_test.go +32 -0
@@ -294,6 +294,38 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
294294 }
295295 }
296296
297+func TestValidateConfig_ExternalLinksAcceptsSupportedValues(t *testing.T) {
298+ for _, behavior := range []ExternalLinkBehavior{
299+ ExternalLinkBehaviorWindowed,
300+ ExternalLinkBehaviorTabbed,
301+ ExternalLinkBehaviorSplit,
302+ ExternalLinkBehaviorStacked,
303+ } {
304+ for _, placement := range []ExternalLinkPlacement{
305+ ExternalLinkPlacementRight,
306+ ExternalLinkPlacementLeft,
307+ ExternalLinkPlacementTop,
308+ ExternalLinkPlacementBottom,
309+ } {
310+ cfg := DefaultConfig()
311+ cfg.Workspace.ExternalLinks.Behavior = behavior
312+ cfg.Workspace.ExternalLinks.Placement = placement
313+ require.NoError(t, validateConfig(cfg))
314+ }
315+ }
316+}
317+
318+func TestValidateConfig_ExternalLinksRejectsUnsupportedValues(t *testing.T) {
319+ cfg := DefaultConfig()
320+ cfg.Workspace.ExternalLinks.Behavior = "invalid"
321+ cfg.Workspace.ExternalLinks.Placement = "invalid"
322+
323+ err := validateConfig(cfg)
324+ require.Error(t, err)
325+ assert.Contains(t, err.Error(), "workspace.external_links.behavior")
326+ assert.Contains(t, err.Error(), "workspace.external_links.placement")
327+}
328+
297329 func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {
298330 tmpDir := t.TempDir()
299331 path := filepath.Join(tmpDir, "page.html")
A internal/infrastructure/config/vim_mode_config_test.go +487 -0
@@ -0,0 +1,487 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+var legacyVimModeActions = []string{
12+ "vim-scroll-left",
13+ "vim-scroll-down",
14+ "vim-scroll-up",
15+ "vim-scroll-right",
16+ "vim-scroll-down-fast",
17+ "vim-scroll-up-fast",
18+ "confirm",
19+ "cancel",
20+}
21+
22+var sequenceVimModeActions = map[string]string{
23+ "heading-next": "]]",
24+ "heading-prev": "[[",
25+ "focus-input": "gi",
26+ "code-next": "]c",
27+ "code-prev": "[c",
28+ "table-next": "]t",
29+ "image-next": "]i",
30+ "list-next": "]l",
31+ "outline": "gO",
32+ "yank-section": "yah",
33+ "half-page-down": "<C-d>",
34+ "half-page-up": "<C-u>",
35+}
36+
37+func TestDefaultConfig_VimModeDefaults(t *testing.T) {
38+ cfg := DefaultConfig()
39+
40+ // VimMode must exist in workspace config.
41+ vimMode := cfg.Workspace.VimMode
42+ assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
43+ assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
44+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
45+
46+ // All required actions must exist with exact key bindings.
47+ require.NotNil(t, vimMode.Actions)
48+ require.Len(t, legacyVimModeActions, 8)
49+ require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
50+ requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"})
51+ requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"})
52+ requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"})
53+ requireActionBinding(t, vimMode.Actions, "vim-scroll-right", []string{"l"})
54+ requireActionBinding(t, vimMode.Actions, "vim-scroll-down-fast", []string{"shift+j"})
55+ requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"})
56+ requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
57+ requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
58+ for _, action := range legacyVimModeActions {
59+ _, ok := vimMode.Actions[action]
60+ require.True(t, ok, "missing legacy vim mode action %q", action)
61+ }
62+}
63+
64+func TestVimModeSequenceDefaults(t *testing.T) {
65+ cfg := DefaultConfig()
66+ vimMode := cfg.Workspace.VimMode
67+
68+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
69+ require.Len(t, sequenceVimModeActions, 12)
70+
71+ for action, key := range sequenceVimModeActions {
72+ requireActionBinding(t, vimMode.Actions, action, []string{key})
73+ }
74+}
75+
76+func TestVimModeConfig_DefaultsLoadThroughViper(t *testing.T) {
77+ mgr := &Manager{viper: viper.New()}
78+ mgr.setDefaults()
79+
80+ cfg, err := mgr.unmarshalConfig()
81+ require.NoError(t, err)
82+
83+ vimMode := cfg.Workspace.VimMode
84+ assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
85+ assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
86+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
87+ require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
88+ requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
89+ requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
90+ requireActionBinding(t, vimMode.Actions, "half-page-down", []string{"<C-d>"})
91+ requireActionBinding(t, vimMode.Actions, "heading-next", []string{"]]"})
92+}
93+
94+func TestDefaultConfig_VimModeDeepClone(t *testing.T) {
95+ first := DefaultConfig()
96+ second := DefaultConfig()
97+
98+ first.Workspace.VimMode.SequenceTimeoutMilliseconds = 999
99+ first.Workspace.VimMode.Actions["confirm"] = ActionBinding{Keys: []string{"mutated"}, Desc: "mutated"}
100+ delete(first.Workspace.VimMode.Actions, "heading-next")
101+
102+ assert.Equal(t, 500, second.Workspace.VimMode.SequenceTimeoutMilliseconds)
103+ requireActionBinding(t, second.Workspace.VimMode.Actions, "confirm", []string{"enter"})
104+ requireActionBinding(t, second.Workspace.VimMode.Actions, "heading-next", []string{"]]"})
105+ require.Len(t, second.Workspace.VimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
106+}
107+
108+func TestVimModeConfig_GetKeyBindings(t *testing.T) {
109+ cfg := DefaultConfig()
110+ bindings := cfg.Workspace.VimMode.GetKeyBindings()
111+
112+ assert.Equal(t, "vim-scroll-left", bindings["h"])
113+ assert.Equal(t, "vim-scroll-down", bindings["j"])
114+ assert.Equal(t, "vim-scroll-up", bindings["k"])
115+ assert.Equal(t, "vim-scroll-right", bindings["l"])
116+ assert.Equal(t, "vim-scroll-down-fast", bindings["shift+j"])
117+ assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"])
118+ assert.Equal(t, "confirm", bindings["enter"])
119+ assert.Equal(t, "cancel", bindings["escape"])
120+ assert.Equal(t, "heading-next", bindings["]]"])
121+ assert.Equal(t, "heading-prev", bindings["[["])
122+ assert.Equal(t, "focus-input", bindings["gi"])
123+ assert.Equal(t, "code-next", bindings["]c"])
124+ assert.Equal(t, "code-prev", bindings["[c"])
125+ assert.Equal(t, "table-next", bindings["]t"])
126+ assert.Equal(t, "image-next", bindings["]i"])
127+ assert.Equal(t, "list-next", bindings["]l"])
128+ assert.Equal(t, "outline", bindings["gO"])
129+ assert.Equal(t, "yank-section", bindings["yah"])
130+ assert.Equal(t, "half-page-down", bindings["<C-d>"])
131+ assert.Equal(t, "half-page-up", bindings["<C-u>"])
132+ assert.Len(t, bindings, 20)
133+}
134+
135+func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
136+ tests := []struct {
137+ name string
138+ mutate func(*Config)
139+ want string
140+ }{
141+ {
142+ name: "negative timeout fails",
143+ mutate: func(cfg *Config) {
144+ cfg.Workspace.VimMode.TimeoutMilliseconds = -1
145+ },
146+ want: "workspace.vim_mode.timeout_ms must be non-negative",
147+ },
148+ {
149+ name: "zero timeout ok",
150+ mutate: func(_ *Config) {
151+ },
152+ want: "",
153+ },
154+ {
155+ name: "positive timeout ok",
156+ mutate: func(cfg *Config) {
157+ cfg.Workspace.VimMode.TimeoutMilliseconds = 5000
158+ },
159+ want: "",
160+ },
161+ }
162+
163+ for _, tt := range tests {
164+ t.Run(tt.name, func(t *testing.T) {
165+ cfg := DefaultConfig()
166+ tt.mutate(cfg)
167+ err := validateConfig(cfg)
168+ if tt.want == "" {
169+ require.NoError(t, err)
170+ } else {
171+ require.Error(t, err)
172+ assert.Contains(t, err.Error(), tt.want)
173+ }
174+ })
175+ }
176+}
177+
178+func TestValidateVimMode_SequenceTimeout(t *testing.T) {
179+ tests := []struct {
180+ name string
181+ mutate func(*Config)
182+ want string
183+ }{
184+ {
185+ name: "negative sequence timeout fails",
186+ mutate: func(cfg *Config) {
187+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = -1
188+ },
189+ want: "workspace.vim_mode.sequence_timeout_ms must be non-negative",
190+ },
191+ {
192+ name: "zero sequence timeout ok",
193+ mutate: func(cfg *Config) {
194+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 0
195+ },
196+ want: "",
197+ },
198+ {
199+ name: "positive sequence timeout ok",
200+ mutate: func(cfg *Config) {
201+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 750
202+ },
203+ want: "",
204+ },
205+ }
206+
207+ for _, tt := range tests {
208+ t.Run(tt.name, func(t *testing.T) {
209+ cfg := DefaultConfig()
210+ tt.mutate(cfg)
211+ err := validateConfig(cfg)
212+ if tt.want == "" {
213+ require.NoError(t, err)
214+ } else {
215+ require.Error(t, err)
216+ assert.Contains(t, err.Error(), tt.want)
217+ }
218+ })
219+ }
220+}
221+
222+func TestValidateVimMode_NilActionsFail(t *testing.T) {
223+ cfg := DefaultConfig()
224+ cfg.Workspace.VimMode.Actions = nil
225+
226+ err := validateConfig(cfg)
227+ require.Error(t, err)
228+ assert.Contains(t, err.Error(), "workspace.vim_mode.actions cannot be empty")
229+}
230+
231+func TestValidateVimMode_DuplicateKeyBindings(t *testing.T) {
232+ cfg := DefaultConfig()
233+ // Add a duplicate key binding that conflicts with an existing one.
234+ action := "custom-action"
235+ cfg.Workspace.VimMode.Actions[action] = ActionBinding{
236+ Keys: []string{"h"}, // "h" is already used by vim-scroll-left
237+ Desc: "Custom action",
238+ }
239+
240+ err := validateConfig(cfg)
241+ require.Error(t, err)
242+ assert.Contains(t, err.Error(), "duplicate key binding 'h' found in vim_mode actions")
243+ assert.Contains(t, err.Error(), "vim-scroll-left")
244+ assert.Contains(t, err.Error(), action)
245+}
246+
247+func TestValidateVimMode_MalformedBindings(t *testing.T) {
248+ malformed := []string{"<C-d", "<Nope>", "<C->"}
249+ for _, key := range malformed {
250+ t.Run(key, func(t *testing.T) {
251+ cfg := DefaultConfig()
252+ cfg.Workspace.VimMode.Actions["broken"] = ActionBinding{
253+ Keys: []string{key},
254+ Desc: "Broken binding",
255+ }
256+
257+ err := validateConfig(cfg)
258+ require.Error(t, err)
259+ assert.Contains(t, err.Error(), "broken")
260+ assert.Contains(t, err.Error(), key)
261+ })
262+ }
263+}
264+
265+func TestValidateVimMode_LegacyEnterEscapeRemainCompatible(t *testing.T) {
266+ cfg := DefaultConfig()
267+ requireActionBinding(t, cfg.Workspace.VimMode.Actions, "confirm", []string{"enter"})
268+ requireActionBinding(t, cfg.Workspace.VimMode.Actions, "cancel", []string{"escape"})
269+ require.NoError(t, validateConfig(cfg))
270+
271+ assert.Equal(t, "<Return>", canonicalizeVimModeBinding("enter"))
272+ assert.Equal(t, "<Escape>", canonicalizeVimModeBinding("escape"))
273+ assert.Equal(t, "<C-d>", canonicalizeVimModeBinding("<C-d>"))
274+}
275+
276+func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
277+ tests := []struct {
278+ name string
279+ actionA string
280+ keysA []string
281+ actionB string
282+ keysB []string
283+ canon string
284+ }{
285+ {
286+ name: "CR and Return",
287+ actionA: "alias-a",
288+ keysA: []string{"<CR>"},
289+ actionB: "alias-b",
290+ keysB: []string{"<Return>"},
291+ canon: "<Return>",
292+ },
293+ {
294+ name: "enter and Return",
295+ actionA: "alias-a",
296+ keysA: []string{"enter"},
297+ actionB: "alias-b",
298+ keysB: []string{"<Return>"},
299+ canon: "<Return>",
300+ },
301+ {
302+ name: "escape and Escape",
303+ actionA: "alias-a",
304+ keysA: []string{"escape"},
305+ actionB: "alias-b",
306+ keysB: []string{"<Escape>"},
307+ canon: "<Escape>",
308+ },
309+ {
310+ name: "J and shift+j",
311+ actionA: "alias-a",
312+ keysA: []string{"J"},
313+ actionB: "alias-b",
314+ keysB: []string{"shift+j"},
315+ canon: "J",
316+ },
317+ {
318+ name: "C-d and ctrl+d",
319+ actionA: "alias-a",
320+ keysA: []string{"<C-d>"},
321+ actionB: "alias-b",
322+ keysB: []string{"ctrl+d"},
323+ canon: "<C-d>",
324+ },
325+ }
326+
327+ for _, tt := range tests {
328+ t.Run(tt.name, func(t *testing.T) {
329+ cfg := DefaultConfig()
330+ // Drop defaults that would collide with the aliases under test.
331+ delete(cfg.Workspace.VimMode.Actions, "half-page-down")
332+ delete(cfg.Workspace.VimMode.Actions, "vim-scroll-down-fast")
333+ delete(cfg.Workspace.VimMode.Actions, "confirm")
334+ delete(cfg.Workspace.VimMode.Actions, "cancel")
335+ cfg.Workspace.VimMode.Actions[tt.actionA] = ActionBinding{Keys: tt.keysA, Desc: "alias a"}
336+ cfg.Workspace.VimMode.Actions[tt.actionB] = ActionBinding{Keys: tt.keysB, Desc: "alias b"}
337+
338+ err := validateConfig(cfg)
339+ require.Error(t, err)
340+ assert.Contains(t, err.Error(), "duplicate key binding '"+tt.canon+"' found in vim_mode actions")
341+ assert.Contains(t, err.Error(), tt.actionA)
342+ assert.Contains(t, err.Error(), tt.actionB)
343+ })
344+ }
345+}
346+
347+func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {
348+ cfg := DefaultConfig()
349+ // Override an action with no key bindings.
350+ cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
351+ "vim-scroll-left": {Keys: []string{}, Desc: "No keys"},
352+ }
353+
354+ err := validateConfig(cfg)
355+ require.Error(t, err)
356+ assert.Contains(t, err.Error(), "workspace.vim_mode.actions.vim-scroll-left must have at least one key binding")
357+}
358+
359+func TestValidateModalModeActions_RegressionTable(t *testing.T) {
360+ tests := []struct {
361+ name string
362+ modePath string
363+ timeout int
364+ actions map[string]ActionBinding
365+ want string
366+ }{
367+ {
368+ name: "page negative timeout",
369+ modePath: "vim_mode",
370+ timeout: -1,
371+ actions: map[string]ActionBinding{"vim-scroll-left": {Keys: []string{"h"}}},
372+ want: "workspace.vim_mode.timeout_ms must be non-negative",
373+ },
374+ {
375+ name: "pane empty actions",
376+ modePath: "pane_mode",
377+ timeout: 0,
378+ actions: nil,
379+ want: "workspace.pane_mode.actions cannot be empty",
380+ },
381+ {
382+ name: "tab empty keys",
383+ modePath: "tab_mode",
384+ timeout: 0,
385+ actions: map[string]ActionBinding{"new-tab": {Keys: []string{}}},
386+ want: "workspace.tab_mode.actions.new-tab must have at least one key binding",
387+ },
388+ {
389+ name: "page duplicate vim binding",
390+ modePath: "vim_mode",
391+ timeout: 0,
392+ actions: map[string]ActionBinding{
393+ "vim-scroll-left": {Keys: []string{"h"}},
394+ "vim-scroll-right": {Keys: []string{"h"}},
395+ },
396+ want: "duplicate key binding 'h' found in vim_mode actions",
397+ },
398+ {
399+ name: "page canonical vim set ok",
400+ modePath: "vim_mode",
401+ timeout: 0,
402+ actions: map[string]ActionBinding{
403+ "vim-scroll-left": {Keys: []string{"h"}},
404+ "vim-scroll-down": {Keys: []string{"j"}},
405+ "vim-scroll-up": {Keys: []string{"k"}},
406+ "vim-scroll-right": {Keys: []string{"l"}},
407+ "vim-scroll-down-fast": {Keys: []string{"shift+j"}},
408+ "vim-scroll-up-fast": {Keys: []string{"shift+k"}},
409+ "confirm": {Keys: []string{"enter"}},
410+ "cancel": {Keys: []string{"escape"}},
411+ },
412+ want: "",
413+ },
414+ }
415+
416+ for _, tt := range tests {
417+ t.Run(tt.name, func(t *testing.T) {
418+ errs := validateModalModeActions(tt.modePath, tt.timeout, tt.actions)
419+ if tt.want == "" {
420+ assert.Empty(t, errs)
421+ return
422+ }
423+ require.NotEmpty(t, errs)
424+ assert.Contains(t, errs[0], tt.want)
425+ })
426+ }
427+}
428+
429+func TestValidateVimMode_DefaultsAccept(t *testing.T) {
430+ cfg := DefaultConfig()
431+ require.NoError(t, validateConfig(cfg))
432+}
433+
434+func TestSchemaProvider_VimModeKeys(t *testing.T) {
435+ provider := &SchemaProvider{}
436+ schema := provider.GetSchema()
437+
438+ var foundActivation, foundTimeout, foundSequenceTimeout, foundActions bool
439+ for _, key := range schema {
440+ switch key.Key {
441+ case "workspace.vim_mode.activation_shortcut":
442+ foundActivation = true
443+ assert.Equal(t, "string", key.Type)
444+ assert.Equal(t, "ctrl+y", key.Default)
445+ assert.Equal(t, SectionWorkspace, key.Section)
446+ case "workspace.vim_mode.timeout_ms":
447+ foundTimeout = true
448+ assert.Equal(t, "int", key.Type)
449+ assert.Equal(t, "0", key.Default)
450+ assert.Equal(t, ">=0", key.Range)
451+ assert.Equal(t, SectionWorkspace, key.Section)
452+ case "workspace.vim_mode.sequence_timeout_ms":
453+ foundSequenceTimeout = true
454+ assert.Equal(t, "int", key.Type)
455+ assert.Equal(t, "500", key.Default)
456+ assert.Equal(t, ">=0", key.Range)
457+ assert.Equal(t, SectionWorkspace, key.Section)
458+ case "workspace.vim_mode.actions.<action>":
459+ foundActions = true
460+ assert.Equal(t, "object", key.Type)
461+ assert.Equal(t, SectionWorkspace, key.Section)
462+ }
463+ }
464+
465+ assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key")
466+ assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key")
467+ assert.True(t, foundSequenceTimeout, "missing workspace.vim_mode.sequence_timeout_ms schema key")
468+ assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key")
469+}
470+
471+func TestSchemaExposesVimModeSequenceTimeout(t *testing.T) {
472+ provider := &SchemaProvider{}
473+ schema := provider.GetSchema()
474+
475+ var found bool
476+ for _, key := range schema {
477+ if key.Key != "workspace.vim_mode.sequence_timeout_ms" {
478+ continue
479+ }
480+ found = true
481+ assert.Equal(t, "int", key.Type)
482+ assert.Equal(t, "500", key.Default)
483+ assert.Equal(t, ">=0", key.Range)
484+ assert.Equal(t, SectionWorkspace, key.Section)
485+ }
486+ assert.True(t, found, "schema must expose workspace.vim_mode.sequence_timeout_ms")
487+}
M internal/infrastructure/desktop/adapter.go +25 -1
@@ -446,6 +446,10 @@ func NewSessionSpawner(ctx context.Context, spawnEnv port.SessionSpawnEnvironmen
446446 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
447447 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
448448
449+// FreshWindowLaunchEnvVar marks a browse child that must open a fresh window
450+// instead of applying external-link workspace placement.
451+const FreshWindowLaunchEnvVar = "DUMBER_BROWSER_FRESH_WINDOW"
452+
449453 const layerShellPreloadToken = "libgtk4" + "-layer-shell"
450454
451455 // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).
@@ -466,6 +470,23 @@ func LaunchBrowserURL(uri string) {
466470 }
467471
468472 func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, error), spawnDetachedProcess func(*exec.Cmd) error) error {
473+ return launchBrowserURL(uri, false, resolveExecutablePath, spawnDetachedProcess)
474+}
475+
476+func launchBrowserFreshWindowURL(
477+ uri string,
478+ resolveExecutablePath func() (string, error),
479+ spawnDetachedProcess func(*exec.Cmd) error,
480+) error {
481+ return launchBrowserURL(uri, true, resolveExecutablePath, spawnDetachedProcess)
482+}
483+
484+func launchBrowserURL(
485+ uri string,
486+ freshWindow bool,
487+ resolveExecutablePath func() (string, error),
488+ spawnDetachedProcess func(*exec.Cmd) error,
489+) error {
469490 if resolveExecutablePath == nil {
470491 resolveExecutablePath = getExecutablePath
471492 }
@@ -479,7 +500,10 @@ func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, er
479500 }
480501
481502 cmd := exec.Command(execPath, "browse", uri)
482- cmd.Env = sanitizedChildEnv(os.Environ())
503+ cmd.Env = withoutEnvKeys(sanitizedChildEnv(os.Environ()), FreshWindowLaunchEnvVar)
504+ if freshWindow {
505+ cmd.Env = append(cmd.Env, FreshWindowLaunchEnvVar+"=1")
506+ }
483507 if err := spawnDetachedProcess(cmd); err != nil {
484508 return fmt.Errorf("failed to launch dumber browse for requested URL: %w", err)
485509 }
M internal/infrastructure/desktop/adapter_test.go +15 -0
@@ -220,6 +220,21 @@ func TestLaunchBrowserBrowseURL_SpawnErrorDoesNotLeakURL(t *testing.T) {
220220 }
221221 }
222222
223+func TestLaunchBrowserBrowseURL_ClearsFreshWindowMarker(t *testing.T) {
224+ t.Setenv(FreshWindowLaunchEnvVar, "1")
225+
226+ err := launchBrowserBrowseURL(
227+ "https://example.com",
228+ func() (string, error) { return "/usr/bin/dumber", nil },
229+ func(cmd *exec.Cmd) error {
230+ assert.NotContains(t, cmd.Env, FreshWindowLaunchEnvVar+"=1")
231+ return nil
232+ },
233+ )
234+
235+ require.NoError(t, err)
236+}
237+
223238 func TestSetAsDefaultBrowserAlsoUpdatesMimeHandlers(t *testing.T) {
224239 t.Setenv("HOME", t.TempDir())
225240 t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))
M internal/infrastructure/desktop/browser_launch_relay.go +37 -7
@@ -39,10 +39,18 @@ type browserLaunchRelay struct {
3939 }
4040
4141 type browserLaunchRequest struct {
42- RequestID string `json:"request_id,omitempty"`
43- URL string `json:"url"`
42+ RequestID string `json:"request_id,omitempty"`
43+ URL string `json:"url"`
44+ Action browserLaunchAction `json:"action,omitempty"`
4445 }
4546
47+type browserLaunchAction string
48+
49+const (
50+ browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url"
51+ browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window"
52+)
53+
4654 type browserLaunchResponse struct {
4755 RequestID string `json:"request_id,omitempty"`
4856 Accepted bool `json:"accepted,omitempty"`
@@ -68,7 +76,15 @@ func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay
6876 return &browserLaunchRelay{ipc: ipc}
6977 }
7078
79+func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
80+ return r.deliver(ctx, url, "")
81+}
82+
7183 func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
84+ return r.deliver(ctx, url, browserLaunchActionOpenFreshWindow)
85+}
86+
87+func (r *browserLaunchRelay) deliver(ctx context.Context, url string, action browserLaunchAction) (bool, error) {
7288 socketPath, err := r.socketPath()
7389 if err != nil {
7490 return false, err
@@ -93,7 +109,7 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str
93109 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
94110 return false, err
95111 }
96- if err := json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: requestID, URL: url}); err != nil {
112+ if err := json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: requestID, URL: url, Action: action}); err != nil {
97113 return false, err
98114 }
99115
@@ -353,20 +369,34 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
353369 log.Debug().
354370 Str("request_id", requestID).
355371 Str("url_host", safeURLHost(request.URL)).
356- Msg("browser launch relay calling browser window opener")
357- if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
372+ Msg("browser launch relay calling browser URL opener")
373+ var err error
374+ switch request.Action {
375+ case "", browserLaunchActionOpenExternalURL:
376+ err = opener.OpenExternalURL(ctx, request.URL)
377+ case browserLaunchActionOpenFreshWindow:
378+ err = opener.OpenFreshWindow(ctx, request.URL)
379+ default:
380+ log.Warn().
381+ Str("request_id", requestID).
382+ Str("url_host", safeURLHost(request.URL)).
383+ Str("action", string(request.Action)).
384+ Msg("browser launch relay rejected unknown action")
385+ return
386+ }
387+ if err != nil {
358388 log.Warn().Err(err).
359389 Str("request_id", requestID).
360390 Str("url_host", safeURLHost(request.URL)).
361391 Dur("elapsed", time.Since(started)).
362- Msg("browser launch relay opener failed")
392+ Msg("browser launch relay browser URL opener failed")
363393 return
364394 }
365395 log.Debug().
366396 Str("request_id", requestID).
367397 Str("url_host", safeURLHost(request.URL)).
368398 Dur("elapsed", time.Since(started)).
369- Msg("browser launch relay opener returned success")
399+ Msg("browser launch relay browser URL opener returned success")
370400 }()
371401 }
372402
M internal/infrastructure/desktop/browser_launch_relay_test.go +78 -23
@@ -17,10 +17,27 @@ import (
1717
1818 type browserWindowOpenerFunc func(context.Context, string) error
1919
20+func (f browserWindowOpenerFunc) OpenExternalURL(ctx context.Context, url string) error {
21+ return f(ctx, url)
22+}
23+
2024 func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
2125 return f(ctx, url)
2226 }
2327
28+type browserWindowOpener struct {
29+ openExternalURL func(context.Context, string) error
30+ openFreshWindow func(context.Context, string) error
31+}
32+
33+func (o browserWindowOpener) OpenExternalURL(ctx context.Context, url string) error {
34+ return o.openExternalURL(ctx, url)
35+}
36+
37+func (o browserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {
38+ return o.openFreshWindow(ctx, url)
39+}
40+
2441 func waitForSocket(t *testing.T, path string) {
2542 t.Helper()
2643
@@ -112,10 +129,10 @@ func overrideBrowserLaunchRequestID(t *testing.T, id string) func() {
112129 return func() { newBrowserLaunchRequestID = previous }
113130 }
114131
115-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {
132+func TestBrowserLaunchRelay_DeliverOpenExternalURL_MissingListenerReturnsFalseNil(t *testing.T) {
116133 relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))
117134
118- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")
135+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com")
119136
120137 require.NoError(t, err)
121138 assert.False(t, delivered)
@@ -138,7 +155,7 @@ func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) {
138155
139156 waitForSocket(t, ipc.BrowserLaunchSocket)
140157
141- delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace")
158+ delivered, err := clientRelay.DeliverOpenExternalURL(context.Background(), "https://example.com/same-namespace")
142159 require.NoError(t, err)
143160 require.True(t, delivered)
144161 require.Equal(t, "https://example.com/same-namespace", <-received)
@@ -151,7 +168,7 @@ func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T)
151168 require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)
152169 }
153170
154-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {
171+func TestBrowserLaunchRelay_DeliverOpenExternalURL_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {
155172 ipc := testIPC(shortTempDir(t))
156173 relay := NewBrowserLaunchRelay(ipc)
157174 restore := overrideBrowserLaunchRequestID(t, "request-test-1")
@@ -180,7 +197,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch
180197 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: request.RequestID, Accepted: true})
181198 }()
182199
183- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/request-id")
200+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id")
184201
185202 require.NoError(t, err)
186203 require.True(t, delivered)
@@ -189,7 +206,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch
189206 require.Equal(t, "https://example.com/request-id", request.URL)
190207 }
191208
192-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequestID(t *testing.T) {
209+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RejectsMismatchedResponseRequestID(t *testing.T) {
193210 ipc := testIPC(shortTempDir(t))
194211 relay := NewBrowserLaunchRelay(ipc)
195212 restore := overrideBrowserLaunchRequestID(t, "request-test-2")
@@ -216,30 +233,36 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequ
216233 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: "different-request", Accepted: true})
217234 }()
218235
219- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/request-id-mismatch")
236+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id-mismatch")
220237
221238 require.Error(t, err)
222239 require.True(t, delivered)
223240 require.Contains(t, err.Error(), "mismatched browser launch relay response")
224241 }
225242
226-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
243+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RoundTrip(t *testing.T) {
227244 ipc := testIPC(shortTempDir(t))
228245 relay := NewBrowserLaunchRelay(ipc)
229246
230247 received := make(chan string, 1)
231248 ctx := t.Context()
232249
233- closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
234- received <- url
235- return nil
236- }))
250+ closer, err := relay.Listen(ctx, browserWindowOpener{
251+ openExternalURL: func(_ context.Context, url string) error {
252+ received <- url
253+ return nil
254+ },
255+ openFreshWindow: func(context.Context, string) error {
256+ t.Fatal("external URL relay request must preserve configured placement")
257+ return nil
258+ },
259+ })
237260 require.NoError(t, err)
238261 defer closer.Close()
239262
240263 waitForSocket(t, ipc.BrowserLaunchSocket)
241264
242- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")
265+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/new")
243266
244267 require.NoError(t, err)
245268 assert.True(t, delivered)
@@ -252,6 +275,38 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
252275 }
253276 }
254277
278+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
279+ ipc := testIPC(shortTempDir(t))
280+ relay := NewBrowserLaunchRelay(ipc)
281+
282+ freshWindows := make(chan string, 1)
283+ ctx := t.Context()
284+ closer, err := relay.Listen(ctx, browserWindowOpener{
285+ openExternalURL: func(context.Context, string) error {
286+ t.Fatal("external-link placement must not handle standalone omnibox navigation")
287+ return nil
288+ },
289+ openFreshWindow: func(_ context.Context, url string) error {
290+ freshWindows <- url
291+ return nil
292+ },
293+ })
294+ require.NoError(t, err)
295+ defer closer.Close()
296+
297+ waitForSocket(t, ipc.BrowserLaunchSocket)
298+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/omnibox")
299+
300+ require.NoError(t, err)
301+ require.True(t, delivered)
302+ select {
303+ case got := <-freshWindows:
304+ assert.Equal(t, "https://example.com/omnibox", got)
305+ case <-time.After(time.Second):
306+ t.Fatal("expected fresh-window opener to receive the URL")
307+ }
308+}
309+
255310 func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) {
256311 relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{})
257312
@@ -279,7 +334,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t
279334 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
280335 require.Error(t, err)
281336
282- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/live")
337+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/live")
283338 require.NoError(t, err)
284339 require.True(t, delivered)
285340
@@ -310,7 +365,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
310365
311366 waitForSocket(t, ipc.BrowserLaunchSocket)
312367
313- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")
368+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/stale")
314369 require.NoError(t, err)
315370 require.True(t, delivered)
316371
@@ -345,7 +400,7 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
345400 require.NoError(t, err)
346401 require.NoError(t, conn.Close())
347402
348- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/still-works")
403+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/still-works")
349404
350405 require.NoError(t, err)
351406 assert.True(t, delivered)
@@ -387,12 +442,12 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
387442 waitForSocketGone(t, ipc.BrowserLaunchSocket)
388443 require.NoError(t, closer.Close())
389444
390- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")
445+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/after-cancel")
391446 require.NoError(t, err)
392447 assert.False(t, delivered)
393448 }
394449
395-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {
450+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RespectsResponseReadTimeout(t *testing.T) {
396451 ipc := testIPC(shortTempDir(t))
397452 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
398453
@@ -419,13 +474,13 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
419474 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
420475 defer cancel()
421476
422- delivered, err := relay.DeliverOpenFreshWindow(ctx, "https://example.com/slow")
477+ delivered, err := relay.DeliverOpenExternalURL(ctx, "https://example.com/slow")
423478 require.Error(t, err)
424479 require.ErrorIs(t, err, context.DeadlineExceeded)
425480 assert.False(t, delivered)
426481 }
427482
428-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
483+func TestBrowserLaunchRelay_DeliverOpenExternalURL_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
429484 ipc := testIPC(shortTempDir(t))
430485 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
431486
@@ -459,7 +514,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe
459514 result := make(chan deliverResult, 1)
460515 go func() {
461516 close(started)
462- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")
517+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/slow")
463518 result <- deliverResult{delivered: delivered, err: err}
464519 }()
465520 <-started
@@ -470,7 +525,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe
470525 require.Error(t, got.err)
471526 require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)
472527 case <-time.After(browserLaunchIOTimeout * 3):
473- t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")
528+ t.Fatal("DeliverOpenExternalURL blocked without a caller deadline")
474529 }
475530 }
476531
@@ -574,7 +629,7 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
574629 }
575630 result := make(chan deliverResult, 1)
576631 go func() {
577- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/recovered")
632+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/recovered")
578633 result <- deliverResult{delivered: delivered, err: err}
579634 }()
580635
M internal/infrastructure/desktop/browser_launcher.go +6 -4
@@ -13,7 +13,7 @@ import (
1313 // received the request, but the caller could not confirm that in time.
1414 var ErrBrowserLaunchUnconfirmed = errors.New("browser launch could not be confirmed")
1515
16-// BrowserLauncher opens URLs in a relay-first dumber browser instance.
16+// BrowserLauncher opens standalone-omnibox URLs in a relay-first dumber browser instance.
1717 type BrowserLauncher struct {
1818 relay port.BrowserLaunchRelay
1919 resolveExecutablePath func() (string, error)
@@ -29,8 +29,10 @@ func NewBrowserLauncher(relay port.BrowserLaunchRelay) *BrowserLauncher {
2929 }
3030 }
3131
32-// LaunchURL forwards the URL to a running instance when possible, otherwise spawns a new one.
33-func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) error {
32+// LaunchFreshWindowURL forwards the URL to a running instance when possible,
33+// always requesting a new browser window. This keeps standalone omnibox
34+// navigation independent from external-link workspace placement settings.
35+func (l *BrowserLauncher) LaunchFreshWindowURL(ctx context.Context, url string) error {
3436 if l == nil {
3537 return errors.New("browser launcher is unavailable")
3638 }
@@ -48,7 +50,7 @@ func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) error {
4850 }
4951 }
5052
51- if err := launchBrowserBrowseURL(url, l.resolveExecutablePath, l.startDetachedProcess); err != nil {
53+ if err := launchBrowserFreshWindowURL(url, l.resolveExecutablePath, l.startDetachedProcess); err != nil {
5254 return fmt.Errorf("launch dumber browse: %w", err)
5355 }
5456 return nil
M internal/infrastructure/desktop/browser_launcher_test.go +11 -10
@@ -11,7 +11,7 @@ import (
1111 "github.com/stretchr/testify/require"
1212 )
1313
14-func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) {
14+func TestBrowserLauncher_LaunchFreshWindowURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) {
1515 relay := mocks.NewMockBrowserLaunchRelay(t)
1616 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)
1717
@@ -26,13 +26,13 @@ func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *te
2626 return nil
2727 }
2828
29- err := launcher.LaunchURL(context.Background(), "https://example.com")
29+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
3030
3131 require.NoError(t, err)
3232 assert.False(t, spawned)
3333 }
3434
35-func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) {
35+func TestBrowserLauncher_LaunchFreshWindowURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) {
3636 relay := mocks.NewMockBrowserLaunchRelay(t)
3737 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)
3838
@@ -47,15 +47,16 @@ func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T)
4747 return nil
4848 }
4949
50- err := launcher.LaunchURL(context.Background(), "https://example.com")
50+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
5151
5252 require.NoError(t, err)
5353 require.NotNil(t, gotCmd)
5454 assert.Equal(t, "/usr/bin/dumber", gotCmd.Path)
5555 assert.Equal(t, []string{"/usr/bin/dumber", "browse", "https://example.com"}, gotCmd.Args)
56+ assert.Contains(t, gotCmd.Env, FreshWindowLaunchEnvVar+"=1")
5657 }
5758
58-func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmbiguous(t *testing.T) {
59+func TestBrowserLauncher_LaunchFreshWindowURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmbiguous(t *testing.T) {
5960 relay := mocks.NewMockBrowserLaunchRelay(t)
6061 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, ErrBrowserLaunchRelayUnconfirmed)
6162
@@ -69,13 +70,13 @@ func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmb
6970 return nil
7071 }
7172
72- err := launcher.LaunchURL(context.Background(), "https://example.com")
73+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
7374
7475 require.Error(t, err)
7576 assert.ErrorIs(t, err, ErrBrowserLaunchUnconfirmed)
7677 }
7778
78-func TestBrowserLauncher_LaunchURL_PropagatesRelayError(t *testing.T) {
79+func TestBrowserLauncher_LaunchFreshWindowURL_PropagatesRelayError(t *testing.T) {
7980 relay := mocks.NewMockBrowserLaunchRelay(t)
8081 wantErr := errors.New("relay exploded")
8182 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr)
@@ -90,17 +91,17 @@ func TestBrowserLauncher_LaunchURL_PropagatesRelayError(t *testing.T) {
9091 return nil
9192 }
9293
93- err := launcher.LaunchURL(context.Background(), "https://example.com")
94+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
9495
9596 require.Error(t, err)
9697 require.ErrorIs(t, err, wantErr)
9798 assert.NotContains(t, err.Error(), "https://example.com")
9899 }
99100
100-func TestBrowserLauncher_LaunchURL_NilLauncherReturnsError(t *testing.T) {
101+func TestBrowserLauncher_LaunchFreshWindowURL_NilLauncherReturnsError(t *testing.T) {
101102 var launcher *BrowserLauncher
102103
103- err := launcher.LaunchURL(context.Background(), "https://example.com")
104+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
104105
105106 require.Error(t, err)
106107 assert.Contains(t, err.Error(), "unavailable")
A internal/infrastructure/webkit/editable_focus_token_test.go +74 -0
@@ -0,0 +1,74 @@
1+package webkit
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestNewEditableFocusBridgeToken_UsesRandomHex(t *testing.T) {
12+ old := editableFocusTokenRand
13+ t.Cleanup(func() { editableFocusTokenRand = old })
14+ editableFocusTokenRand = func(b []byte) (int, error) {
15+ for i := range b {
16+ b[i] = byte(i + 1)
17+ }
18+ return len(b), nil
19+ }
20+
21+ token := newEditableFocusBridgeToken()
22+ require.Equal(t, "0102030405060708090a0b0c0d0e0f10", token)
23+}
24+
25+func TestNewEditableFocusBridgeToken_RandFailureReturnsEmpty(t *testing.T) {
26+ old := editableFocusTokenRand
27+ t.Cleanup(func() { editableFocusTokenRand = old })
28+ editableFocusTokenRand = func([]byte) (int, error) {
29+ return 0, errors.New("entropy exhausted")
30+ }
31+
32+ token := newEditableFocusBridgeToken()
33+ require.Empty(t, token)
34+}
35+
36+func TestEditableFocusBridgeToken_ConcurrentAccess(t *testing.T) {
37+ t.Parallel()
38+ wv := &WebView{}
39+ wv.setEditableFocusBridgeToken("initial-token")
40+
41+ var wg sync.WaitGroup
42+ for range 32 {
43+ wg.Add(2)
44+ go func() {
45+ defer wg.Done()
46+ for range 200 {
47+ _ = wv.EditableFocusBridgeToken()
48+ _ = wv.matchesEditableFocusBridgeToken("initial-token")
49+ }
50+ }()
51+ go func() {
52+ defer wg.Done()
53+ for i := range 200 {
54+ wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken())
55+ if i%17 == 0 {
56+ wv.setEditableFocusBridgeToken("initial-token")
57+ }
58+ }
59+ }()
60+ }
61+ wg.Wait()
62+}
63+
64+func TestMatchesEditableFocusBridgeToken_RejectsEmpty(t *testing.T) {
65+ wv := &WebView{}
66+ wv.setEditableFocusBridgeToken("")
67+ require.False(t, wv.matchesEditableFocusBridgeToken(""))
68+ require.False(t, wv.matchesEditableFocusBridgeToken("anything"))
69+
70+ wv.setEditableFocusBridgeToken("secret")
71+ require.True(t, wv.matchesEditableFocusBridgeToken("secret"))
72+ require.False(t, wv.matchesEditableFocusBridgeToken("other"))
73+ require.False(t, wv.matchesEditableFocusBridgeToken(""))
74+}
M internal/infrastructure/webkit/injector.go +79 -21
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "sort"
7+ "strconv"
78 "strings"
89
910 "github.com/bnema/dumber/internal/application/port"
@@ -179,35 +180,46 @@ const explicitCopyScript = `(function() {
179180 }
180181 })();`
181182
182-// accentDetectionScript is built at init from entity.AccentMap so the JS
183+// accentDetectionScriptKeys is built at init from entity.AccentMap so the JS
183184 // filter stays in sync with the Go-side accent table.
184-var accentDetectionScript string
185+var accentDetectionScriptKeys string
185186
186187 func buildExplicitCopyScript() string {
187188 return explicitCopyScript
188189 }
189190
190-func init() {
191- // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"
192- keys := make([]string, 0, len(entity.AccentMap))
193- for k := range entity.AccentMap {
194- keys = append(keys, fmt.Sprintf("'%c'", k))
195- }
196- sort.Strings(keys)
197-
198- accentDetectionScript = fmt.Sprintf(`(function() {
191+func buildAccentDetectionScript(token string) string {
192+ return fmt.Sprintf(`(function() {
199193 'use strict';
200194 const accentKeys = new Set([%s]);
195+ const editableFocusToken = %q;
201196 let pressedKey = null;
197+ let lastEditableFocusState = null;
198+
199+ function postEditableFocus(editable) {
200+ if (lastEditableFocusState === editable) return;
201+ if (!editableFocusToken) return;
202+ if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return;
203+ lastEditableFocusState = editable;
204+ window.webkit.messageHandlers.dumber.postMessage({
205+ type: 'editable_focus_changed',
206+ payload: { editable: editable, token: editableFocusToken }
207+ });
208+ }
202209
203210 function isEditableTarget(target) {
204211 const el = target instanceof Element ? target : null;
205212 if (!el) return false;
206- if (el.closest('[contenteditable=""], [contenteditable="true"]')) return true;
207- if (el.closest('textarea')) return true;
213+ if (el.isContentEditable) return true;
214+ const editableAncestor = el.closest('[contenteditable]');
215+ if (editableAncestor && editableAncestor.isContentEditable) return true;
216+ const textarea = el.closest('textarea');
217+ if (textarea) return !textarea.matches(':disabled') && !textarea.readOnly;
208218 const input = el.closest('input');
209219 if (!input) return false;
210- return !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i.test(input.type);
220+ const disabled = input.matches(':disabled');
221+ const unsupported = /^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i.test(input.type);
222+ return !disabled && !input.readOnly && !unsupported;
211223 }
212224
213225 document.addEventListener('keydown', function(e) {
@@ -234,12 +246,50 @@ func init() {
234246 }
235247 }, true);
236248
237- document.addEventListener('focusin', function(e) {
238- if (isEditableTarget(e.target)) {
239- window.__dumber_lastEditableEl = e.target;
240- }
241- }, true);
242-})();`, strings.Join(keys, ","))
249+ function canReportNegativeFocus(target) {
250+ if (window.top === window) return true;
251+ return lastEditableFocusState === true && window.__dumber_lastEditableEl === target;
252+ }
253+
254+ document.addEventListener('focusin', function(e) {
255+ if (e && e.isTrusted === false) return;
256+ if (isEditableTarget(e.target)) {
257+ window.__dumber_lastEditableEl = e.target;
258+ postEditableFocus(true);
259+ return;
260+ }
261+ if (canReportNegativeFocus(window.__dumber_lastEditableEl)) {
262+ postEditableFocus(false);
263+ }
264+ }, true);
265+
266+ document.addEventListener('focusout', function(e) {
267+ if (e && e.isTrusted === false) return;
268+ if (!isEditableTarget(e.target)) return;
269+ setTimeout(function() {
270+ if (!isEditableTarget(document.activeElement) && canReportNegativeFocus(e.target)) {
271+ postEditableFocus(false);
272+ }
273+ }, 0);
274+ }, true);
275+
276+ if (isEditableTarget(document.activeElement)) {
277+ window.__dumber_lastEditableEl = document.activeElement;
278+ postEditableFocus(true);
279+ } else if (window.top === window) {
280+ postEditableFocus(false);
281+ }
282+})();`, accentDetectionScriptKeys, token)
283+}
284+
285+func init() {
286+ // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"
287+ keys := make([]string, 0, len(entity.AccentMap))
288+ for k := range entity.AccentMap {
289+ keys = append(keys, fmt.Sprintf("'%c'", k))
290+ }
291+ sort.Strings(keys)
292+ accentDetectionScriptKeys = strings.Join(keys, ",")
243293 }
244294
245295 // accentDetectionInjectionMode controls which frames receive the accent detection script.
@@ -449,9 +499,17 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
449499
450500 // 8. Inject accent key detection for all pages and all frames (unconditional).
451501 // JS only reports keydown/keyup events; Go handles timing and picker display.
502+ wv := LookupWebView(webviewID)
503+ if wv == nil || wv.EditableFocusBridgeToken() == "" {
504+ log.Error().
505+ Str("webview_id", strconv.FormatUint(uint64(webviewID), 10)).
506+ Msg("skipping accent detection script without editable-focus bridge token")
507+ return
508+ }
509+ accentScript := buildAccentDetectionScript(wv.EditableFocusBridgeToken())
452510 addScript(
453511 webkit.NewUserScript(
454- accentDetectionScript,
512+ accentScript,
455513 accentDetectionInjectionMode,
456514 webkit.UserScriptInjectAtDocumentEndValue,
457515 nil, // all pages
M internal/infrastructure/webkit/injector_accent_test.go +30 -2
@@ -19,16 +19,44 @@ func TestAccentDetectionScriptInjectionMode(t *testing.T) {
1919 }
2020
2121 func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) {
22+ script := buildAccentDetectionScript("")
2223 assert.Contains(t,
23- accentDetectionScript,
24+ script,
2425 "window.__dumber_lastEditableEl = e.target",
2526 "accent detection script must track the last focused editable element",
2627 )
2728 assert.Contains(t,
28- accentDetectionScript,
29+ script,
2930 "document.addEventListener('focusin'",
3031 "accent detection script must register a focusin listener",
3132 )
33+ assert.Contains(t, script, "el.isContentEditable")
34+ assert.Contains(t, script, "closest('[contenteditable]')")
35+}
36+
37+func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) {
38+ script := buildAccentDetectionScript("")
39+ assert.Contains(t, script, "editable_focus_changed")
40+ assert.Contains(t, script, "document.addEventListener('focusout'")
41+ assert.Contains(t, script, `const editableFocusToken = "";`)
42+ assert.Contains(t, script, `payload: { editable: editable, token: editableFocusToken }`)
43+ assert.Contains(t, script, "let lastEditableFocusState = null;")
44+ assert.Contains(t, script, "if (lastEditableFocusState === editable) return;")
45+ assert.Contains(t, script, `postEditableFocus(true)`)
46+ assert.Contains(t, script, `postEditableFocus(false)`)
47+ assert.Contains(t, script, `e && e.isTrusted === false`)
48+ assert.Contains(t, script, `document.activeElement`)
49+ assert.Contains(t, script, `window.__dumber_lastEditableEl = document.activeElement`)
50+}
51+
52+func TestBuildAccentDetectionScript_EmptyTokenReturnsBeforeHandlerLookup(t *testing.T) {
53+ empty := buildAccentDetectionScript("")
54+ assert.Contains(t, empty, "if (!editableFocusToken) return;")
55+ nonEmpty := buildAccentDetectionScript("abc123")
56+ assert.Contains(t, nonEmpty, `const editableFocusToken = "abc123";`)
57+ assert.Contains(t, nonEmpty, "if (!editableFocusToken) return;")
58+ assert.Contains(t, nonEmpty, `postEditableFocus(true)`)
59+ assert.Contains(t, nonEmpty, "window.webkit.messageHandlers.dumber.postMessage")
3260 }
3361
3462 func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
M internal/infrastructure/webkit/messaging.go +68 -26
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/dumber/internal/logging"
1212 "github.com/bnema/puregotk/v4/javascriptcore"
1313 "github.com/bnema/puregotk/v4/webkit"
14+ "github.com/rs/zerolog"
1415 )
1516
1617 // Message is the JS -> Go message envelope sent via postMessage.
@@ -187,32 +188,8 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
187188
188189 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry")
189190
190- if valuePtr == 0 {
191- log.Warn().Msg("received script message with nil value pointer")
192- return
193- }
194-
195- jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr)
196- if jscValue == nil {
197- log.Warn().Msg("failed to wrap script message JSC value")
198- return
199- }
200-
201- rawJSON := jscValue.ToJson(0)
202- log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result")
203- if rawJSON == "" {
204- // This typically happens when the JS side sends non-serializable values
205- // like Svelte 5 Proxy objects or functions. The frontend should use
206- // JSON.parse(JSON.stringify(obj)) to convert Proxies to plain objects.
207- log.Error().
208- Msg("script message JSON serialization failed (ToJson returned empty) - " +
209- "this usually means JS sent non-serializable values like Proxy objects")
210- return
211- }
212-
213- var msg Message
214- if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
215- log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message")
191+ msg, ok := decodeScriptMessage(valuePtr, log)
192+ if !ok {
216193 return
217194 }
218195
@@ -221,6 +198,9 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
221198 log.Warn().Msg("rejecting script message from unknown sender")
222199 return
223200 }
201+ if r.handleAllowlistedBridgeMessage(senderWV, msg) {
202+ return
203+ }
224204 if !isTrustedBridgeWebView(senderWV) {
225205 log.Warn().
226206 Str("type", msg.Type).
@@ -279,6 +259,68 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
279259 }
280260 }
281261
262+func decodeScriptMessage(valuePtr uintptr, log zerolog.Logger) (Message, bool) {
263+ if valuePtr == 0 {
264+ log.Warn().Msg("received script message with nil value pointer")
265+ return Message{}, false
266+ }
267+
268+ jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr)
269+ if jscValue == nil {
270+ log.Warn().Msg("failed to wrap script message JSC value")
271+ return Message{}, false
272+ }
273+
274+ rawJSON := jscValue.ToJson(0)
275+ log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result")
276+ if rawJSON == "" {
277+ log.Error().
278+ Msg("script message JSON serialization failed (ToJson returned empty) - " +
279+ "this usually means JS sent non-serializable values like Proxy objects")
280+ return Message{}, false
281+ }
282+
283+ var msg Message
284+ if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
285+ log.Warn().Err(err).Int("json_len", len(rawJSON)).Msg("failed to unmarshal script message")
286+ return Message{}, false
287+ }
288+ return msg, true
289+}
290+
291+// handleAllowlistedBridgeMessage handles the one narrow page-origin hint that
292+// is allowed before trusted-page routing: editable focus state for Vim mode.
293+// This is intentionally not a privileged bridge command; the only effect is
294+// updating editable-focus state on the sender WebView so Vim mode can block or
295+// exit locally. The per-WebView token is a reuse/staleness guard, not a broad
296+// authorization boundary for page privileges.
297+func (r *MessageRouter) handleAllowlistedBridgeMessage(senderWV *WebView, msg Message) bool {
298+ if msg.Type != "editable_focus_changed" {
299+ return false
300+ }
301+ log := logging.FromContext(r.baseContext())
302+ if senderWV == nil {
303+ log.Warn().Str("type", msg.Type).Msg("dropping allowlisted bridge message without sender WebView")
304+ return true
305+ }
306+ var payload struct {
307+ Editable bool `json:"editable"`
308+ Token string `json:"token"`
309+ }
310+ if len(msg.Payload) != 0 {
311+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
312+ log.Warn().Err(err).Str("type", msg.Type).Msg("failed to decode allowlisted bridge payload")
313+ return true
314+ }
315+ }
316+ if payload.Token == "" || !senderWV.matchesEditableFocusBridgeToken(payload.Token) {
317+ log.Warn().Str("type", msg.Type).Msg("rejecting allowlisted bridge message with invalid token")
318+ return true
319+ }
320+ senderWV.dispatchEditableFocusChanged(payload.Editable)
321+ return true
322+}
323+
282324 func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
283325 ptr := senderUCM.GoPointer()
284326 if ptr == 0 {
M internal/infrastructure/webkit/messaging_test.go +53 -0
@@ -2,9 +2,11 @@ package webkit
22
33 import (
44 "context"
5+ "encoding/json"
56 "sync"
67 "testing"
78
9+ "github.com/bnema/dumber/internal/application/port"
810 "github.com/stretchr/testify/require"
911 )
1012
@@ -35,6 +37,57 @@ func TestMessageRouterBaseContextConcurrentUpdate(t *testing.T) {
3537 wg.Wait()
3638 }
3739
40+func TestHandleAllowlistedEditableFocusMessage_AllowsUntrustedPage(t *testing.T) {
41+ router := NewMessageRouter(context.Background())
42+ wv := &WebView{uri: "https://example.com", editableFocusBridgeToken: "secret-token"}
43+
44+ var states []bool
45+ wv.SetCallbacks(&port.WebViewCallbacks{
46+ OnEditableFocusChanged: func(editable bool) {
47+ states = append(states, editable)
48+ },
49+ })
50+
51+ handled := router.handleAllowlistedBridgeMessage(wv, Message{
52+ Type: "editable_focus_changed",
53+ Payload: json.RawMessage(`{"editable":true,"token":"secret-token"}`),
54+ })
55+
56+ require.True(t, handled)
57+ require.Equal(t, []bool{true}, states)
58+}
59+
60+func TestHandleAllowlistedEditableFocusMessage_IsNoopAfterPoolReuse(t *testing.T) {
61+ router := NewMessageRouter(context.Background())
62+ wv := &WebView{uri: "https://example.com", editableFocusBridgeToken: "secret-token"}
63+ wv.ResetForPoolReuse()
64+
65+ // Re-register after reset so a stale token is the only reason handling is a noop.
66+ called := false
67+ wv.SetCallbacks(&port.WebViewCallbacks{
68+ OnEditableFocusChanged: func(bool) {
69+ called = true
70+ },
71+ })
72+
73+ handled := router.handleAllowlistedBridgeMessage(wv, Message{
74+ Type: "editable_focus_changed",
75+ Payload: json.RawMessage(`{"editable":true,"token":"secret-token"}`),
76+ })
77+
78+ require.True(t, handled)
79+ require.False(t, called)
80+}
81+
82+func TestResetForPoolReuse_RotatesEditableFocusBridgeToken(t *testing.T) {
83+ wv := &WebView{editableFocusBridgeToken: "secret-token"}
84+
85+ wv.ResetForPoolReuse()
86+
87+ require.NotEmpty(t, wv.EditableFocusBridgeToken())
88+ require.NotEqual(t, "secret-token", wv.EditableFocusBridgeToken())
89+}
90+
3891 func TestIsTrustedBridgeURI(t *testing.T) {
3992 t.Parallel()
4093
M internal/infrastructure/webkit/pool.go +18 -1
@@ -178,7 +178,17 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
178178 }
179179
180180 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
181- _ = wv.AttachFrontend(ctx, p.injector, p.router)
181+ alreadyAttached := wv.FrontendAttached()
182+ if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
183+ log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to pooled webview")
184+ }
185+ // Reinjection is only for already-attached reuse after ResetForPoolReuse.
186+ // First AttachFrontend already injected; avoid an immediate double inject.
187+ if needsFrontendReinjectionOnAcquire(alreadyAttached) && p.injector != nil && wv.ucm != nil {
188+ wv.ucm.RemoveAllScripts()
189+ wv.ucm.RemoveAllStyleSheets()
190+ p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
191+ }
182192 // Apply filters if available (may not have been applied during prewarm)
183193 if p.filterApplier != nil {
184194 p.filterApplier.ApplyTo(ctx, wv.ucm)
@@ -240,6 +250,13 @@ func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuP
240250 return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
241251 }
242252
253+// needsFrontendReinjectionOnAcquire is true when AttachFrontend already succeeded
254+// earlier, so pool acquire must wipe and reinject after reuse. First-time attach
255+// injects inside AttachFrontend and must not immediately reinject.
256+func needsFrontendReinjectionOnAcquire(alreadyAttached bool) bool {
257+ return alreadyAttached
258+}
259+
243260 func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool {
244261 return settings != nil && wv != nil && wv.inner != nil
245262 }
M internal/infrastructure/webkit/pool_policy_test.go +5 -0
@@ -16,3 +16,8 @@ func TestResolveReusePolicy(t *testing.T) {
1616 t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe")
1717 assert.Equal(t, reusePolicySafe, resolveReusePolicy(""))
1818 }
19+
20+func TestNeedsFrontendReinjectionOnAcquire(t *testing.T) {
21+ assert.False(t, needsFrontendReinjectionOnAcquire(false), "first AttachFrontend must not double-inject")
22+ assert.True(t, needsFrontendReinjectionOnAcquire(true), "already-attached reuse must reinject")
23+}
M internal/infrastructure/webkit/webview.go +151 -39
@@ -2,6 +2,9 @@ package webkit
22
33 import (
44 "context"
5+ "crypto/rand"
6+ "crypto/subtle"
7+ "encoding/hex"
58 "fmt"
69 "net/url"
710 "os"
@@ -16,6 +19,7 @@ import (
1619 "github.com/bnema/dumber/internal/domain/entity"
1720 urlutil "github.com/bnema/dumber/internal/domain/url"
1821 "github.com/bnema/dumber/internal/infrastructure/desktop"
22+ "github.com/bnema/dumber/internal/infrastructure/webutil"
1923 "github.com/bnema/dumber/internal/logging"
2024 "github.com/bnema/puregotk/v4/gdk"
2125 "github.com/bnema/puregotk/v4/gio"
@@ -34,6 +38,26 @@ var _ port.Printer = (*WebView)(nil)
3438 var _ port.PopupLifecycleCapable = (*WebView)(nil)
3539 var _ port.PopupFeatureResolver = (*WebView)(nil)
3640 var _ port.OAuthCallbackCapable = (*WebView)(nil)
41+var _ port.PageScrollable = (*WebView)(nil)
42+
43+var buildPageScrollFallbackJS = webutil.BuildPageScrollByJS
44+var runPageScrollFallbackJS = func(ctx context.Context, wv *WebView, script string) {
45+ wv.RunJavaScript(ctx, script)
46+}
47+
48+// editableFocusTokenRand is the injectable CSPRNG seam for bridge tokens.
49+// Tests may override it; production uses crypto/rand.Read.
50+var editableFocusTokenRand = rand.Read
51+
52+const editableFocusBridgeTokenBytes = 16
53+
54+func newEditableFocusBridgeToken() string {
55+ buf := make([]byte, editableFocusBridgeTokenBytes)
56+ if _, err := editableFocusTokenRand(buf); err != nil {
57+ return ""
58+ }
59+ return hex.EncodeToString(buf)
60+}
3761
3862 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
3963 // Infrastructure layer uses the type defined in the application port.
@@ -163,6 +187,7 @@ type WebView struct {
163187 OnEnterFullscreen func() bool // Return true to prevent fullscreen
164188 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
165189 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
190+ OnEditableFocusChanged func(editable bool) // Called when page editable focus changes
166191 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
167192 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
168193 browsingContextDecision dto.HostDecision
@@ -179,8 +204,9 @@ type WebView struct {
179204 logger zerolog.Logger
180205 mu sync.RWMutex
181206
182- frontendAttached atomic.Bool
183- navigationActive atomic.Bool
207+ frontendAttached atomic.Bool
208+ navigationActive atomic.Bool
209+ editableFocusBridgeToken string
184210
185211 // asyncCallbacks keeps references to async JS callbacks to prevent GC
186212 asyncCallbacks []any
@@ -346,11 +372,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
346372 }
347373
348374 wv := &WebView{
349- inner: inner,
350- ucm: inner.GetUserContentManager(),
351- logger: log.With().Str("component", "webview").Logger(),
352- signalIDs: make([]uintptr, 0, 4),
353- runJSErrorStats: make(map[string]runJSErrorStat),
375+ inner: inner,
376+ ucm: inner.GetUserContentManager(),
377+ logger: log.With().Str("component", "webview").Logger(),
378+ signalIDs: make([]uintptr, 0, 4),
379+ runJSErrorStats: make(map[string]runJSErrorStat),
380+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
354381 }
355382
356383 // Register in global registry
@@ -396,12 +423,13 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti
396423 Msg("related webview created, checking pointers")
397424
398425 wv := &WebView{
399- inner: inner,
400- isRelated: true, // Shares web process with parent - must not terminate process on destroy
401- ucm: inner.GetUserContentManager(),
402- logger: log.With().Str("component", "webview-popup").Logger(),
403- signalIDs: make([]uintptr, 0, 6),
404- runJSErrorStats: make(map[string]runJSErrorStat),
426+ inner: inner,
427+ isRelated: true, // Shares web process with parent - must not terminate process on destroy
428+ ucm: inner.GetUserContentManager(),
429+ logger: log.With().Str("component", "webview-popup").Logger(),
430+ signalIDs: make([]uintptr, 0, 6),
431+ runJSErrorStats: make(map[string]runJSErrorStat),
432+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
405433 }
406434
407435 wv.id = globalRegistry.register(wv)
@@ -448,37 +476,49 @@ func (wv *WebView) connectLoadChangedSignal() {
448476 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
449477 uri := inner.GetUri()
450478 title := inner.GetTitle()
451-
452- wv.mu.Lock()
453- wv.uri = uri
454- wv.title = title
455- // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
456- // which fires for both traditional navigation and SPA history.pushState()
457- wv.progress = inner.GetEstimatedLoadProgress()
458-
459- switch event {
460- case webkit.LoadStartedValue:
461- wv.navigationActive.Store(true)
462- wv.isLoading = true
463- wv.logger.Debug().Str("uri", uri).Msg("load started")
464- case webkit.LoadRedirectedValue:
465- wv.logger.Debug().Str("uri", uri).Msg("load redirected")
466- case webkit.LoadCommittedValue:
467- wv.logger.Debug().Str("uri", uri).Msg("load committed")
468- case webkit.LoadFinishedValue:
469- wv.isLoading = false
470- wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
471- }
472- wv.mu.Unlock()
473-
474- if wv.OnLoadChanged != nil {
475- wv.OnLoadChanged(LoadEvent(event))
476- }
479+ progress := inner.GetEstimatedLoadProgress()
480+ wv.handleLoadChanged(LoadEvent(event), uri, title, progress)
477481 }
478482 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
479483 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
480484 }
481485
486+// handleLoadChanged updates load state then notifies outside wv.mu so callbacks
487+// may re-enter URI()/State() without deadlocking.
488+func (wv *WebView) handleLoadChanged(event LoadEvent, uri, title string, progress float64) {
489+ wv.mu.Lock()
490+ wv.uri = uri
491+ wv.title = title
492+ // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
493+ // which fires for both traditional navigation and SPA history.pushState()
494+ wv.progress = progress
495+
496+ notifyEditableBlur := false
497+ switch event {
498+ case LoadStarted:
499+ wv.navigationActive.Store(true)
500+ wv.isLoading = true
501+ wv.logger.Debug().Str("uri", uri).Msg("load started")
502+ notifyEditableBlur = true
503+ case LoadRedirected:
504+ wv.logger.Debug().Str("uri", uri).Msg("load redirected")
505+ case LoadCommitted:
506+ wv.logger.Debug().Str("uri", uri).Msg("load committed")
507+ case LoadFinished:
508+ wv.isLoading = false
509+ wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
510+ }
511+ wv.mu.Unlock()
512+
513+ if notifyEditableBlur {
514+ wv.dispatchEditableFocusChanged(false)
515+ }
516+
517+ if wv.OnLoadChanged != nil {
518+ wv.OnLoadChanged(event)
519+ }
520+}
521+
482522 func (wv *WebView) connectLoadFailedSignal() {
483523 loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool {
484524 wv.logger.Warn().
@@ -1762,6 +1802,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
17621802 wv.OnEnterFullscreen = nil
17631803 wv.OnLeaveFullscreen = nil
17641804 wv.OnAudioStateChanged = nil
1805+ wv.OnEditableFocusChanged = nil
17651806 return
17661807 }
17671808
@@ -1807,6 +1848,16 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
18071848 wv.OnEnterFullscreen = callbacks.OnEnterFullscreen
18081849 wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen
18091850 wv.OnAudioStateChanged = callbacks.OnAudioStateChanged
1851+ wv.OnEditableFocusChanged = callbacks.OnEditableFocusChanged
1852+}
1853+
1854+func (wv *WebView) dispatchEditableFocusChanged(editable bool) {
1855+ if wv == nil || wv.destroyed.Load() {
1856+ return
1857+ }
1858+ if wv.OnEditableFocusChanged != nil {
1859+ wv.OnEditableFocusChanged(editable)
1860+ }
18101861 }
18111862
18121863 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1999,6 +2050,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
19992050 wv.OnEnterFullscreen = nil
20002051 wv.OnLeaveFullscreen = nil
20012052 wv.OnAudioStateChanged = nil
2053+ wv.OnEditableFocusChanged = nil
20022054 wv.OnLinkHover = nil
20032055 wv.OnWebProcessTerminated = nil
20042056 wv.OnPermissionRequest = nil
@@ -2047,6 +2099,7 @@ func (wv *WebView) ResetForPoolReuse() {
20472099 return
20482100 }
20492101 wv.generation.Add(1)
2102+ wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken())
20502103
20512104 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.
20522105 // They will be reconnected when the WebView is re-acquired from the pool.
@@ -2064,6 +2117,7 @@ func (wv *WebView) ResetForPoolReuse() {
20642117 wv.OnEnterFullscreen = nil
20652118 wv.OnLeaveFullscreen = nil
20662119 wv.OnAudioStateChanged = nil
2120+ wv.OnEditableFocusChanged = nil
20672121 wv.OnLinkHover = nil
20682122 wv.OnWebProcessTerminated = nil
20692123 wv.OnPermissionRequest = nil
@@ -2376,3 +2430,61 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
23762430 log.Debug().Msg("frontend assets attached to webview")
23772431 return nil
23782432 }
2433+
2434+// ScrollPage scrolls from the viewport center, handing exhausted nested
2435+// scrollers off to their ancestors and then the document. CEF uses the same
2436+// shared directional DOM-target resolution once its browser is ready.
2437+// Implements port.PageScrollable.
2438+func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
2439+ if err := ctx.Err(); err != nil {
2440+ return err
2441+ }
2442+ if wv == nil {
2443+ return fmt.Errorf("webkit: webview is nil")
2444+ }
2445+ if err := request.Validate(); err != nil {
2446+ return err
2447+ }
2448+ if wv.destroyed.Load() {
2449+ return fmt.Errorf("webview %d is destroyed", wv.id)
2450+ }
2451+ js := buildPageScrollFallbackJS(request.FallbackDX, request.FallbackDY)
2452+ runPageScrollFallbackJS(ctx, wv, js)
2453+ return nil
2454+}
2455+
2456+// FrontendAttached reports whether AttachFrontend has already wired scripts/router.
2457+func (wv *WebView) FrontendAttached() bool {
2458+ return wv != nil && wv.frontendAttached.Load()
2459+}
2460+
2461+// EditableFocusBridgeToken returns the current allowlisted bridge auth token.
2462+func (wv *WebView) EditableFocusBridgeToken() string {
2463+ if wv == nil {
2464+ return ""
2465+ }
2466+ wv.mu.RLock()
2467+ defer wv.mu.RUnlock()
2468+ return wv.editableFocusBridgeToken
2469+}
2470+
2471+func (wv *WebView) setEditableFocusBridgeToken(token string) {
2472+ if wv == nil {
2473+ return
2474+ }
2475+ wv.mu.Lock()
2476+ wv.editableFocusBridgeToken = token
2477+ wv.mu.Unlock()
2478+}
2479+
2480+// matchesEditableFocusBridgeToken compares a candidate token in constant time.
2481+func (wv *WebView) matchesEditableFocusBridgeToken(candidate string) bool {
2482+ if wv == nil || candidate == "" {
2483+ return false
2484+ }
2485+ token := wv.EditableFocusBridgeToken()
2486+ if token == "" {
2487+ return false
2488+ }
2489+ return subtle.ConstantTimeCompare([]byte(candidate), []byte(token)) == 1
2490+}
A internal/infrastructure/webkit/webview_load_changed_test.go +50 -0
@@ -0,0 +1,50 @@
1+package webkit
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHandleLoadChanged_LoadStartedDispatchesOutsideLock(t *testing.T) {
11+ wv := &WebView{uri: "https://example.com/start", title: "start"}
12+ var sawURI string
13+ var sawStateURI string
14+ done := make(chan struct{})
15+ wv.OnEditableFocusChanged = func(editable bool) {
16+ require.False(t, editable)
17+ // Same-goroutine re-entry would deadlock if wv.mu were still held.
18+ sawURI = wv.URI()
19+ state := wv.State()
20+ sawStateURI = state.URI
21+ close(done)
22+ }
23+ var loadEvents []LoadEvent
24+ wv.OnLoadChanged = func(ev LoadEvent) {
25+ loadEvents = append(loadEvents, ev)
26+ }
27+
28+ finished := make(chan struct{})
29+ go func() {
30+ wv.handleLoadChanged(LoadStarted, "https://example.com/next", "next", 0.1)
31+ close(finished)
32+ }()
33+
34+ select {
35+ case <-done:
36+ case <-time.After(2 * time.Second):
37+ t.Fatal("editable focus callback deadlocked re-entering URI/State")
38+ }
39+ select {
40+ case <-finished:
41+ case <-time.After(2 * time.Second):
42+ t.Fatal("handleLoadChanged did not finish")
43+ }
44+
45+ require.Equal(t, "https://example.com/next", sawURI)
46+ require.Equal(t, "https://example.com/next", sawStateURI)
47+ require.Equal(t, []LoadEvent{LoadStarted}, loadEvents)
48+ require.True(t, wv.isLoading)
49+ require.True(t, wv.navigationActive.Load())
50+}
M internal/infrastructure/webkit/webview_runjavascript_test.go +105 -0
@@ -1,11 +1,15 @@
11 package webkit
22
33 import (
4+ "context"
45 "errors"
6+ "fmt"
57 "testing"
68 "time"
79
10+ "github.com/bnema/dumber/internal/application/port"
811 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
913 )
1014
1115 func TestClassifyRunJSEvaluateError(t *testing.T) {
@@ -87,3 +91,104 @@ func TestShouldLogRunJSError_FatalAlwaysLogs(t *testing.T) {
8791 assert.True(t, shouldLog)
8892 assert.Equal(t, uint64(2), count)
8993 }
94+
95+func TestScrollPage_Destroyed_ReturnsError(t *testing.T) {
96+ wv := &WebView{}
97+ wv.destroyed.Store(true)
98+
99+ err := wv.ScrollPage(context.Background(), port.PageScrollRequest{
100+ Command: port.PageScrollCommandDown,
101+ FallbackDX: 0,
102+ FallbackDY: 80,
103+ })
104+
105+ require.Error(t, err)
106+ assert.Contains(t, err.Error(), "destroyed")
107+}
108+
109+func TestScrollPage_UsesFallbackDeltasAndRunsJavaScript(t *testing.T) {
110+ oldBuilder := buildPageScrollFallbackJS
111+ oldRunner := runPageScrollFallbackJS
112+ defer func() {
113+ buildPageScrollFallbackJS = oldBuilder
114+ runPageScrollFallbackJS = oldRunner
115+ }()
116+
117+ var gotDX, gotDY int
118+ var gotScript string
119+ var runCount int
120+ buildPageScrollFallbackJS = func(dx, dy int) string {
121+ gotDX, gotDY = dx, dy
122+ return fmt.Sprintf("scroll(%d,%d)", dx, dy)
123+ }
124+ runPageScrollFallbackJS = func(_ context.Context, _ *WebView, script string) {
125+ runCount++
126+ gotScript = script
127+ }
128+
129+ wv := &WebView{
130+ uri: "https://example.com",
131+ runJSErrorStats: make(map[string]runJSErrorStat),
132+ }
133+
134+ err := wv.ScrollPage(context.Background(), port.PageScrollRequest{
135+ Command: port.PageScrollCommandLeft,
136+ FallbackDX: -12,
137+ FallbackDY: 80,
138+ })
139+
140+ require.NoError(t, err)
141+ assert.Equal(t, -12, gotDX)
142+ assert.Equal(t, 80, gotDY)
143+ assert.Equal(t, "scroll(-12,80)", gotScript)
144+ assert.Equal(t, 1, runCount)
145+}
146+
147+func TestScrollPage_VariousRequestsForwardFallbackDeltas(t *testing.T) {
148+ oldBuilder := buildPageScrollFallbackJS
149+ oldRunner := runPageScrollFallbackJS
150+ defer func() {
151+ buildPageScrollFallbackJS = oldBuilder
152+ runPageScrollFallbackJS = oldRunner
153+ }()
154+
155+ tests := []struct {
156+ name string
157+ req port.PageScrollRequest
158+ }{
159+ {"zero request", port.PageScrollRequest{}},
160+ {"down", port.PageScrollRequest{Command: port.PageScrollCommandDown, FallbackDY: 80}},
161+ {"up", port.PageScrollRequest{Command: port.PageScrollCommandUp, FallbackDY: -80}},
162+ {"left", port.PageScrollRequest{Command: port.PageScrollCommandLeft, FallbackDX: -80}},
163+ {"right", port.PageScrollRequest{Command: port.PageScrollCommandRight, FallbackDX: 80}},
164+ {"up fast", port.PageScrollRequest{Command: port.PageScrollCommandUpFast, FallbackDY: -320}},
165+ {"down fast", port.PageScrollRequest{Command: port.PageScrollCommandDownFast, FallbackDY: 320}},
166+ }
167+
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ var gotDX, gotDY int
171+ buildCalls, runCalls := 0, 0
172+ buildPageScrollFallbackJS = func(dx, dy int) string {
173+ buildCalls++
174+ gotDX, gotDY = dx, dy
175+ return "ok"
176+ }
177+ runPageScrollFallbackJS = func(_ context.Context, _ *WebView, script string) {
178+ runCalls++
179+ assert.Equal(t, "ok", script)
180+ }
181+
182+ wv := &WebView{
183+ uri: "https://example.com",
184+ runJSErrorStats: make(map[string]runJSErrorStat),
185+ }
186+ err := wv.ScrollPage(context.Background(), tt.req)
187+ require.NoError(t, err)
188+ assert.Equal(t, 1, buildCalls)
189+ assert.Equal(t, 1, runCalls)
190+ assert.Equal(t, tt.req.FallbackDX, gotDX)
191+ assert.Equal(t, tt.req.FallbackDY, gotDY)
192+ })
193+ }
194+}
A internal/infrastructure/webutil/pagescroll.go +68 -0
@@ -0,0 +1,68 @@
1+package webutil
2+
3+import "fmt"
4+
5+// BuildPageScrollByJS returns one immediate JavaScript scroll step for the given
6+// CSS-pixel delta. It resolves the initial target under the viewport center,
7+// walks toward scrollable ancestors that can move in the requested direction,
8+// and hands off to the document when a nested scroller reaches its boundary.
9+// Cross-origin frame contents remain best-effort because elementFromPoint can
10+// only return the accessible frame element from the top-level document.
11+//
12+// The app-level Vim Mode repeater owns held-key cadence; this helper performs
13+// no requestAnimationFrame loop of its own.
14+func BuildPageScrollByJS(dx, dy int) string {
15+ return fmt.Sprintf(`(function(){
16+var dx=%d,dy=%d,doc=document;
17+function hasScrollableOverflow(value){
18+ return value==='auto'||value==='scroll'||value==='overlay';
19+}
20+function canScroll(el){
21+ if(!el)return false;
22+ var style=window.getComputedStyle(el);
23+ if(dy!==0){
24+ var overflowY=style.overflowY||style.overflow;
25+ var maxTop=el.scrollHeight-el.clientHeight;
26+ if(hasScrollableOverflow(overflowY)){
27+ if((dy<0&&el.scrollTop>0)||(dy>0&&el.scrollTop<maxTop))return true;
28+ }
29+ }
30+ if(dx!==0){
31+ var overflowX=style.overflowX||style.overflow;
32+ var maxLeft=el.scrollWidth-el.clientWidth;
33+ if(hasScrollableOverflow(overflowX)){
34+ if((dx<0&&el.scrollLeft>0)||(dx>0&&el.scrollLeft<maxLeft))return true;
35+ }
36+ }
37+ return false;
38+}
39+function scrollElement(el){
40+ if(!el)return false;
41+ var beforeLeft=el.scrollLeft,beforeTop=el.scrollTop;
42+ try{
43+ if(dx!==0)el.scrollLeft=beforeLeft+dx;
44+ if(dy!==0)el.scrollTop=beforeTop+dy;
45+ }catch(_){
46+ return false;
47+ }
48+ return el.scrollLeft!==beforeLeft||el.scrollTop!==beforeTop;
49+}
50+try{
51+ var node=typeof doc.elementFromPoint==='function'?
52+ doc.elementFromPoint(window.innerWidth/2,window.innerHeight/2):null;
53+ while(node&&node!==doc.body&&node!==doc.documentElement){
54+ if(canScroll(node)&&scrollElement(node))return;
55+ node=node.parentElement;
56+ }
57+ var scroller=doc.scrollingElement||doc.documentElement;
58+ if(scroller&&canScroll(scroller)&&scrollElement(scroller))return;
59+ if(typeof window.scrollBy==='function'){
60+ window.scrollBy(dx,dy);
61+ return;
62+ }
63+ if(typeof window.scrollTo==='function'){
64+ window.scrollTo(window.scrollX+dx,window.scrollY+dy);
65+ }
66+}catch(e){}
67+})()`, dx, dy)
68+}
A internal/infrastructure/webutil/pagescroll_test.go +147 -0
@@ -0,0 +1,147 @@
1+package webutil
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestBuildPageScrollByJS_TargetsViewportCenterInsteadOfActiveElement(t *testing.T) {
9+ js := BuildPageScrollByJS(0, 80)
10+
11+ if !strings.Contains(js, "doc.elementFromPoint(window.innerWidth/2,window.innerHeight/2)") {
12+ t.Fatalf("page scroll must resolve its initial target from the viewport center, got: %s", js)
13+ }
14+ if strings.Contains(js, "activeElement") {
15+ t.Fatalf("page scroll must not use the legacy active-element target, got: %s", js)
16+ }
17+}
18+
19+func TestBuildPageScrollByJS_ContainsRequiredOperations(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ dx int
23+ dy int
24+ }{
25+ {"down", 0, 80},
26+ {"up", 0, -80},
27+ {"left", -80, 0},
28+ {"right", 80, 0},
29+ {"upFast", 0, -320},
30+ {"downFast", 0, 320},
31+ }
32+ required := []struct {
33+ message string
34+ snippets []string
35+ }{
36+ {"JS must resolve the viewport-center element", []string{"elementFromPoint", "window.innerWidth/2", "window.innerHeight/2"}},
37+ {"JS must walk parentElement in a loop", []string{"parentElement"}},
38+ {"JS must reference document.scrollingElement", []string{"scrollingElement"}},
39+ {"JS must fall back to window.scrollBy", []string{"window.scrollBy"}},
40+ {"JS must use getComputedStyle", []string{"getComputedStyle"}},
41+ {"JS must check overflowY", []string{"overflowY"}},
42+ {"JS must check overflowX", []string{"overflowX"}},
43+ {"JS must use direct element scroll offsets", []string{"scrollLeft=beforeLeft+dx", "scrollTop=beforeTop+dy"}},
44+ {"JS must check vertical direction-specific remaining scroll", []string{"dy<0&&el.scrollTop>0", "dy>0&&el.scrollTop<maxTop"}},
45+ {"JS must check horizontal direction-specific remaining scroll", []string{"dx<0&&el.scrollLeft>0", "dx>0&&el.scrollLeft<maxLeft"}},
46+ {"JS must check scrollable overflow values", []string{"'scroll'", "'auto'", "'overlay'"}},
47+ {"JS must wrap in try/catch", []string{"try{", "catch(e)"}},
48+ {"JS must support immediate window scrolling", []string{"window.scrollBy(dx,dy)"}},
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ js := BuildPageScrollByJS(tt.dx, tt.dy)
54+ for _, check := range required {
55+ for _, snippet := range check.snippets {
56+ if !strings.Contains(js, snippet) {
57+ t.Error(check.message)
58+ break
59+ }
60+ }
61+ }
62+ if !strings.HasPrefix(js, "(function(){") || !strings.HasSuffix(js, "})()") {
63+ t.Error("JS must be wrapped in an IIFE")
64+ }
65+ })
66+ }
67+}
68+
69+func TestBuildPageScrollByJS_SpecificDelta(t *testing.T) {
70+ js := BuildPageScrollByJS(0, 80)
71+ if !strings.Contains(js, "var dx=0,dy=80") {
72+ t.Errorf("expected dy=80 in JS variables, got: %s", js)
73+ }
74+ if strings.Contains(js, "requestAnimationFrame") {
75+ t.Errorf("expected immediate fallback scroll step without requestAnimationFrame, got: %s", js)
76+ }
77+
78+ js = BuildPageScrollByJS(-80, 0)
79+ if !strings.Contains(js, "var dx=-80,dy=0") {
80+ t.Errorf("expected dx=-80 in JS variables, got: %s", js)
81+ }
82+}
83+
84+func TestBuildPageScrollByJS_HorizontalAxisCheck(t *testing.T) {
85+ js := BuildPageScrollByJS(-80, 0)
86+ if !strings.Contains(js, "clientWidth") || !strings.Contains(js, "scrollWidth") {
87+ t.Error("horizontal scroll must check scrollWidth/clientWidth")
88+ }
89+}
90+
91+func TestBuildPageScrollByJS_VerticalAxisCheck(t *testing.T) {
92+ js := BuildPageScrollByJS(0, 80)
93+ if !strings.Contains(js, "clientHeight") || !strings.Contains(js, "scrollHeight") {
94+ t.Error("vertical scroll must check scrollHeight/clientHeight")
95+ }
96+}
97+
98+func TestBuildPageScrollByJS_NegativeDeltaMagnitude(t *testing.T) {
99+ js := BuildPageScrollByJS(0, -320)
100+ if !strings.Contains(js, "var dx=0,dy=-320") {
101+ t.Errorf("expected dy=-320 in JS variables, got: %s", js)
102+ }
103+}
104+
105+func TestBuildPageScrollByJS_SyntacticallyValid(t *testing.T) {
106+ tests := []struct{ dx, dy int }{{0, 80}, {0, -320}, {-80, 0}, {80, 0}, {0, 0}}
107+
108+ for _, tt := range tests {
109+ js := BuildPageScrollByJS(tt.dx, tt.dy)
110+
111+ depth := 0
112+ for _, ch := range js {
113+ switch ch {
114+ case '(':
115+ depth++
116+ case ')':
117+ depth--
118+ }
119+ }
120+ if depth != 0 {
121+ t.Errorf("dx=%d,dy=%d: unbalanced parentheses (depth=%d)", tt.dx, tt.dy, depth)
122+ }
123+
124+ depth = 0
125+ for _, ch := range js {
126+ switch ch {
127+ case '{':
128+ depth++
129+ case '}':
130+ depth--
131+ }
132+ }
133+ if depth != 0 {
134+ t.Errorf("dx=%d,dy=%d: unbalanced braces (depth=%d)", tt.dx, tt.dy, depth)
135+ }
136+ }
137+}
138+
139+func TestBuildPageScrollByJS_ZeroDeltaSafe(t *testing.T) {
140+ js := BuildPageScrollByJS(0, 0)
141+ if !strings.Contains(js, "var dx=0,dy=0") {
142+ t.Errorf("expected zero deltas in JS variables, got: %s", js)
143+ }
144+ if !strings.Contains(js, "clientHeight") || !strings.Contains(js, "scrollHeight") {
145+ t.Error("zero-delta script must still include vertical scroll checks")
146+ }
147+}
M internal/ui/app.go +521 -26
@@ -52,6 +52,12 @@ const (
5252 floatingPaneIDPrefix = "floating-pane:"
5353 floatingSessionIDDefault = "default"
5454 floatingPaneVisibleClass = "floating-pane-visible"
55+
56+ // vimModePulseInterval is the minimum interval between vim mode pulses.
57+ // Held Vim Mode scrolling now runs on its own smooth repeater, so the pulse
58+ // is intentionally throttled well below scroll cadence to avoid GTK CSS churn
59+ // becoming part of the perceived scroll jank.
60+ vimModePulseInterval = 120 * time.Millisecond
5561 )
5662
5763 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -117,6 +123,10 @@ type App struct {
117123 // Focus tracking stays app-global.
118124 focusMgr *focus.Manager
119125
126+ vimModePolicyUC *usecase.VimModePolicyUseCase
127+ vimNavigationUC *usecase.VimNavigationUseCase
128+ pageEditableFocusByPane map[entity.PaneID]bool
129+
120130 resizeModeBorderTarget layout.Widget
121131
122132 // Omnibox configuration (omnibox is created per workspace view)
@@ -150,6 +160,12 @@ type App struct {
150160 // Accent picker for dead keys support
151161 accentFocusProvider port.FocusedInputProvider
152162
163+ // Vim mode pulse debounce - tracks last pulse time to skip rapid
164+ // repeats during held-key scroll actions. Reset on vim mode exit
165+ // via handleModeChange.
166+ vimModePulseLastTime time.Time
167+ vimModePulseMu sync.Mutex
168+
153169 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
154170 deferredInitOnce sync.Once
155171 deferredInitFn func()
@@ -192,15 +208,18 @@ func New(deps *Dependencies) (*App, error) {
192208 ctx, cancel := context.WithCancelCause(deps.Ctx)
193209
194210 app := &App{
195- deps: deps,
196- runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig),
197- tabs: entity.NewTabList(),
198- tabsUC: deps.TabsUC,
199- panesUC: deps.PanesUC,
200- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
201- windowForTab: make(map[entity.TabID]*browserWindow),
202- floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
203- browserWindows: make(map[string]*browserWindow),
211+ deps: deps,
212+ runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig),
213+ tabs: entity.NewTabList(),
214+ tabsUC: deps.TabsUC,
215+ panesUC: deps.PanesUC,
216+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
217+ windowForTab: make(map[entity.TabID]*browserWindow),
218+ floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
219+ browserWindows: make(map[string]*browserWindow),
220+ vimModePolicyUC: usecase.NewVimModePolicyUseCase(),
221+ vimNavigationUC: usecase.NewVimNavigationUseCase(),
222+ pageEditableFocusByPane: make(map[entity.PaneID]bool),
204223 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
205224 if fn != nil {
206225 fn()
@@ -210,6 +229,9 @@ func New(deps *Dependencies) (*App, error) {
210229 engine: deps.Engine,
211230 cancel: cancel,
212231 }
232+ if deps.Theme != nil {
233+ deps.Theme.SetTransitionDuration(app.runtimeConfigSnapshot().UI.Workspace.Styling.TransitionDuration)
234+ }
213235 var autoCopyConfig port.AutoCopyConfig
214236 var clipboardOrchestrator port.ClipboardTextOrchestrator
215237 if deps.Clipboard != nil {
@@ -814,6 +836,9 @@ func (a *App) wireSessionManagerShortcut() {
814836 return nil
815837 }
816838 bw.sessionManager.Toggle(ctx)
839+ if bw.sessionManager.IsVisible() {
840+ a.handleVimModeFocusTrigger(ctx, bw, usecase.VimModePolicyTriggerOverlayFocus)
841+ }
817842 return nil
818843 })
819844 }
@@ -1065,13 +1090,24 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
10651090 }
10661091 return a.dispatchBrowserWindowAction(actionCtx, bw, action)
10671092 })
1093+ bw.keyboardHandler.SetOnVimScrollLifecycle(func(scrollCtx context.Context, action input.Action, phase input.VimScrollPhase) error {
1094+ a.activateBrowserWindow(bw)
1095+ return a.kbDispatcher.DispatchVimScrollLifecycle(scrollCtx, action, phase)
1096+ })
10681097 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
10691098 a.activateBrowserWindow(bw)
10701099 return a.handleGlobalEscape(escapeCtx)
10711100 })
10721101 bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
10731102 a.activateBrowserWindow(bw)
1074- a.handleModeChange(ctx, from, to)
1103+ a.handleModeChange(ctx, bw, from, to)
1104+ })
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)
10751111 })
10761112 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
10771113 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
@@ -1099,6 +1135,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
10991135
11001136 return input.RouteHandleShortcuts
11011137 })
1138+ bw.keyboardHandler.SetVimModeActivationPassthrough(func() bool {
1139+ return a.shouldBypassVimModeActivation(bw)
1140+ })
11021141 bw.keyboardHandler.SetAccentHandler(a)
11031142 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())
11041143
@@ -1339,6 +1378,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
13391378 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
13401379 }
13411380 }
1381+ a.handleVimModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.VimModePolicyTriggerOmniboxFocus)
13421382 },
13431383 OnFocusOut: func() {
13441384 // When omnibox loses focus, set WebView as the focused input
@@ -1377,6 +1417,7 @@ func (a *App) initFindBarConfig(ctx context.Context) {
13771417 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
13781418 }
13791419 }
1420+ a.handleVimModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.VimModePolicyTriggerFindBarFocus)
13801421 },
13811422 OnFocusOut: func() {
13821423 // When find bar loses focus, set WebView as the focused input
@@ -1400,6 +1441,9 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
14001441 return
14011442 }
14021443 bw.sessionManager.Toggle(ctx)
1444+ if bw.sessionManager.IsVisible() {
1445+ a.handleVimModeFocusTrigger(ctx, bw, usecase.VimModePolicyTriggerOverlayFocus)
1446+ }
14031447 }
14041448
14051449 func (a *App) attachTabPickerToActivePane() {
@@ -1498,6 +1542,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
14981542
14991543 a.attachTabPickerToActivePane()
15001544 bw.tabPicker.Show(ctx, items)
1545+ a.handleVimModeFocusTrigger(ctx, bw, usecase.VimModePolicyTriggerOverlayFocus)
15011546 return nil
15021547 }
15031548
@@ -2123,6 +2168,111 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
21232168 return paneID, a.contentCoord.GetWebView(paneID)
21242169 }
21252170
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+ return
2205+ }
2206+ if bw != nil && (action == "heading-next" || action == "heading-prev") {
2207+ if _, ok := wv.(port.SemanticNavigable); ok {
2208+ if bw.vimNavigationHighlightedWebViewIDs == nil {
2209+ bw.vimNavigationHighlightedWebViewIDs = make(map[port.WebViewID]struct{})
2210+ }
2211+ id := wv.ID()
2212+ if _, exists := bw.vimNavigationHighlightedWebViewIDs[id]; !exists {
2213+ bw.vimNavigationHighlightedWebViewIDs[id] = struct{}{}
2214+ bw.vimNavigationHighlightedWebViews = append(bw.vimNavigationHighlightedWebViews, wv)
2215+ }
2216+ }
2217+ }
2218+}
2219+
2220+func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) {
2221+ if bw == nil {
2222+ return
2223+ }
2224+ navigationUC := a.vimNavigationUseCase()
2225+ if navigationUC == nil {
2226+ return
2227+ }
2228+ for _, wv := range bw.vimNavigationHighlightedWebViews {
2229+ if err := navigationUC.ClearSemanticNavigationHighlight(ctx, wv); err != nil {
2230+ logging.FromContext(ctx).Debug().Err(err).Msg("failed to clear vim navigation highlight")
2231+ }
2232+ }
2233+ bw.vimNavigationHighlightedWebViews = nil
2234+ bw.vimNavigationHighlightedWebViewIDs = nil
2235+}
2236+
2237+func (a *App) vimNavigationHighlightColor() string {
2238+ if a == nil || a.deps == nil || a.deps.Theme == nil {
2239+ return ""
2240+ }
2241+ return a.deps.Theme.GetCurrentPalette().Accent
2242+}
2243+
2244+func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) {
2245+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2246+ if wv == nil {
2247+ return
2248+ }
2249+ enabler, ok := wv.(port.AccessibilityEnabler)
2250+ if !ok {
2251+ return
2252+ }
2253+ enabler.EnableAccessibility()
2254+ windowID := ""
2255+ if bw != nil {
2256+ windowID = bw.id
2257+ }
2258+ logging.FromContext(ctx).Debug().
2259+ Str("window_id", windowID).
2260+ Str("pane_id", string(paneID)).
2261+ Msg("webview accessibility enabled for vim mode")
2262+}
2263+
2264+func (a *App) preloadAccessibilityForShownPane(paneID entity.PaneID) {
2265+ if a == nil || paneID == "" || !a.runtimeConfigSnapshot().UI.Workspace.VimMode.PreloadAccessibility || a.contentCoord == nil {
2266+ return
2267+ }
2268+ wv := a.contentCoord.GetWebView(paneID)
2269+ enabler, ok := wv.(port.AccessibilityEnabler)
2270+ if !ok {
2271+ return
2272+ }
2273+ enabler.EnableAccessibility()
2274+}
2275+
21262276 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
21272277 // navigation through navigate for the supplied browser window. The closure captures
21282278 // bw at creation time, so navigation targets the owning window even when another
@@ -3072,6 +3222,7 @@ func (a *App) initTabCoordinator(ctx context.Context) {
30723222 // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.
30733223 if bw := a.browserWindowForTabTarget(target); bw != nil {
30743224 a.activateBrowserWindow(bw)
3225+ a.handleVimModeTabSwitch(ctx, bw)
30753226 }
30763227 a.switchWorkspaceView(ctx, tab.ID)
30773228 })
@@ -3209,8 +3360,13 @@ func (a *App) initCoordinators(ctx context.Context) {
32093360 a.deps.HistoryRecorderUC,
32103361 a.contentCoord,
32113362 )
3363+ // Wire the page scroll usecase so semantic scroll commands (left, right,
3364+ // up, down, fast up, fast down) can be dispatched through the
3365+ // NavigationCoordinator to the active WebView.
3366+ a.navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
32123367 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
32133368 a.navCoord.ClearPaneHistory(paneID)
3369+ a.clearPageEditableFocusState(paneID)
32143370 })
32153371
32163372 // Wire title updates to history persistence
@@ -3220,6 +3376,7 @@ func (a *App) initCoordinators(ctx context.Context) {
32203376
32213377 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
32223378 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
3379+ a.clearPageEditableFocusState(paneID)
32233380 a.navCoord.RecordHistory(ctx, paneID, url)
32243381 })
32253382
@@ -3235,6 +3392,9 @@ func (a *App) initCoordinators(ctx context.Context) {
32353392 // Mark dirty so snapshot captures the new URI
32363393 a.MarkDirty()
32373394 })
3395+ a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
3396+ a.handlePageEditableFocusChanged(ctx, paneID, editable)
3397+ })
32383398
32393399 // Hide loading skeleton once the WebView paints
32403400 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
@@ -3253,6 +3413,7 @@ func (a *App) initCoordinators(ctx context.Context) {
32533413 a.deps.OnFirstWebViewShown(ctx)
32543414 })
32553415 }
3416+ a.preloadAccessibilityForShownPane(paneID)
32563417
32573418 // This only updates focus when the shown pane belongs to the last-focused
32583419 // window's active workspace. activeWorkspace() delegates to
@@ -3347,6 +3508,9 @@ func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bo
33473508
33483509 func (a *App) wireKeyboardActions() {
33493510 a.kbDispatcher.SetOnQuit(a.Quit)
3511+ a.kbDispatcher.SetOnVimModePulse(func(ctx context.Context, fast bool) {
3512+ a.triggerVimModePulse(ctx, fast)
3513+ })
33503514 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
33513515 a.ToggleFindBar(ctx)
33523516 return nil
@@ -3678,8 +3842,120 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
36783842 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
36793843 }
36803844
3681-// handleModeChange is called when the input mode changes.
3682-func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
3845+func (a *App) vimModePolicy() *usecase.VimModePolicyUseCase {
3846+ if a == nil {
3847+ return usecase.NewVimModePolicyUseCase()
3848+ }
3849+ if a.vimModePolicyUC == nil {
3850+ a.vimModePolicyUC = usecase.NewVimModePolicyUseCase()
3851+ }
3852+ return a.vimModePolicyUC
3853+}
3854+
3855+func (a *App) ensurePageEditableFocusMap() {
3856+ if a != nil && a.pageEditableFocusByPane == nil {
3857+ a.pageEditableFocusByPane = make(map[entity.PaneID]bool)
3858+ }
3859+}
3860+
3861+func (a *App) pageEditableFocused(paneID entity.PaneID) bool {
3862+ if a == nil || paneID == "" || a.pageEditableFocusByPane == nil {
3863+ return false
3864+ }
3865+ return a.pageEditableFocusByPane[paneID]
3866+}
3867+
3868+func (a *App) setPageEditableFocused(paneID entity.PaneID, editable bool) {
3869+ if a == nil || paneID == "" {
3870+ return
3871+ }
3872+ a.ensurePageEditableFocusMap()
3873+ if editable {
3874+ a.pageEditableFocusByPane[paneID] = true
3875+ return
3876+ }
3877+ delete(a.pageEditableFocusByPane, paneID)
3878+}
3879+
3880+func (a *App) clearPageEditableFocusState(paneID entity.PaneID) {
3881+ a.setPageEditableFocused(paneID, false)
3882+}
3883+
3884+func (a *App) vimModeActiveForBrowserWindow(bw *browserWindow) bool {
3885+ return bw != nil && bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim
3886+}
3887+
3888+func (a *App) applyVimModePolicyTransition(_ context.Context, bw *browserWindow, transition usecase.VimModePolicyTransition) {
3889+ if transition != usecase.VimModePolicyTransitionExit || bw == nil || bw.keyboardHandler == nil {
3890+ return
3891+ }
3892+ if bw.keyboardHandler.Mode() != input.ModeVim {
3893+ return
3894+ }
3895+ bw.keyboardHandler.ExitMode()
3896+}
3897+
3898+func (a *App) handleVimModeFocusTrigger(ctx context.Context, bw *browserWindow, trigger usecase.VimModePolicyTrigger) {
3899+ if bw == nil {
3900+ return
3901+ }
3902+ transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
3903+ Trigger: trigger,
3904+ VimModeActive: a.vimModeActiveForBrowserWindow(bw),
3905+ })
3906+ a.applyVimModePolicyTransition(ctx, bw, transition)
3907+}
3908+
3909+func (a *App) handleVimModeTabSwitch(_ context.Context, bw *browserWindow) {
3910+ if bw == nil || bw.tabs == nil || !a.vimModeActiveForBrowserWindow(bw) {
3911+ return
3912+ }
3913+ transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
3914+ Trigger: usecase.VimModePolicyTriggerContextChanged,
3915+ VimModeActive: true,
3916+ PreserveOnContextChange: false,
3917+ })
3918+ if transition != usecase.VimModePolicyTransitionExit {
3919+ return
3920+ }
3921+ if prevTabID := bw.tabs.PreviousActiveTabID; prevTabID != "" {
3922+ if prevView := a.workspaceViews[prevTabID]; prevView != nil {
3923+ if pv := prevView.GetPaneView(bw.vimModePaneID); pv != nil {
3924+ pv.SetVimMode(false)
3925+ }
3926+ }
3927+ }
3928+ bw.vimModePaneID = ""
3929+ bw.keyboardHandler.ExitMode()
3930+}
3931+
3932+func (a *App) handlePageEditableFocusChanged(ctx context.Context, paneID entity.PaneID, editable bool) {
3933+ if paneID == "" {
3934+ return
3935+ }
3936+ a.setPageEditableFocused(paneID, editable)
3937+
3938+ bw := a.browserWindowForAnyPane(paneID)
3939+ if bw == nil {
3940+ return
3941+ }
3942+ ws := a.activeWorkspaceForBrowserWindow(bw)
3943+ activeContext := ws != nil && ws.ActivePaneID == paneID && a.lastFocusedBrowserWindow() == bw
3944+ transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
3945+ Trigger: usecase.VimModePolicyTriggerPageEditableFocusChanged,
3946+ VimModeActive: a.vimModeActiveForBrowserWindow(bw),
3947+ PageEditableFocused: editable,
3948+ EventInActiveContext: activeContext,
3949+ })
3950+ a.applyVimModePolicyTransition(ctx, bw, transition)
3951+}
3952+
3953+func (*App) shouldBypassVimModeActivation(_ *browserWindow) bool {
3954+ return false
3955+}
3956+
3957+// handleModeChange is called when the input mode changes for a specific browser window.
3958+func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to input.Mode) {
36833959 log := logging.FromContext(ctx)
36843960 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
36853961
@@ -3691,42 +3967,250 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
36913967 a.applyResizeModeBorder(ctx, a.activeWorkspace())
36923968 }
36933969
3970+ if to == input.ModeVim && from != input.ModeVim {
3971+ a.enableAccessibilityForVimMode(ctx, bw)
3972+ }
3973+ if from == input.ModeVim && to != input.ModeVim {
3974+ a.clearVimNavigationHighlight(ctx, bw)
3975+ }
3976+
3977+ // Handle pane-local Vim Mode visual ownership.
3978+ // Entering Vim Mode accents the active pane; leaving removes the accent.
3979+ a.handleVimModeOwnership(ctx, bw, to, from)
3980+
36943981 // Update global border overlay visibility based on mode.
36953982 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
3696- if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil {
3983+ // Vim mode explicitly skips the global border overlay.
3984+ if bw != nil && bw.borderMgr != nil {
36973985 bw.borderMgr.OnModeChange(ctx, from, to)
36983986 }
36993987
3700- // Show/hide mode indicator toaster based on config.
3701- a.updateModeIndicatorToaster(ctx, to)
3988+ // Show/hide this window's mode indicator toaster based on mode and config.
3989+ a.updateModeIndicatorToaster(ctx, bw, to)
37023990 }
37033991
3704-// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
3705-func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
3992+// transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent
3993+// from the current owning pane to another pane in the same browser window.
3994+// The transfer only happens while that window is in Vim Mode.
3995+func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWindow, newPaneID entity.PaneID) {
3996+ if bw == nil {
3997+ return
3998+ }
3999+
4000+ // Only transfer if vim mode is currently active and the owner is changing.
4001+ if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID {
4002+ return
4003+ }
4004+
4005+ // Check if this window is actually in vim mode.
4006+ if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {
4007+ // Vim mode not active on this window; just clear stale ownership.
4008+ bw.vimModePaneID = ""
4009+ return
4010+ }
4011+
4012+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
4013+ if wsView == nil {
4014+ return
4015+ }
4016+
4017+ transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
4018+ Trigger: usecase.VimModePolicyTriggerContextChanged,
4019+ VimModeActive: true,
4020+ PreserveOnContextChange: !a.pageEditableFocused(newPaneID),
4021+ })
4022+ if transition == usecase.VimModePolicyTransitionExit {
4023+ a.applyVimModePolicyTransition(ctx, bw, transition)
4024+ return
4025+ }
4026+
4027+ oldPaneID := bw.vimModePaneID
4028+
4029+ // Deactivate the old owning pane.
4030+ if oldPV := wsView.GetPaneView(oldPaneID); oldPV != nil {
4031+ oldPV.SetVimMode(false)
4032+ }
4033+
4034+ // Activate the new pane.
4035+ if newPV := wsView.GetPaneView(newPaneID); newPV != nil {
4036+ newPV.SetVimMode(true)
4037+ }
4038+ bw.vimModePaneID = newPaneID
4039+
4040+ logging.FromContext(ctx).Debug().
4041+ Str("window_id", bw.id).
4042+ Str("old_pane_id", string(oldPaneID)).
4043+ Str("new_pane_id", string(newPaneID)).
4044+ Msg("vim mode ownership transferred")
4045+}
4046+
4047+// handleVimModeOwnership manages the pane-local Vim Mode accent and pulse
4048+// owner for a specific browser window when input modes change.
4049+func (a *App) handleVimModeOwnership(ctx context.Context, bw *browserWindow, to, from input.Mode) {
4050+ if bw == nil {
4051+ return
4052+ }
4053+
4054+ ws := a.activeWorkspaceForBrowserWindow(bw)
4055+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
4056+ if ws == nil || wsView == nil {
4057+ // If there's no workspace yet (startup), just track the intent.
4058+ if to != input.ModeVim && from == input.ModeVim {
4059+ bw.vimModePaneID = ""
4060+ }
4061+ return
4062+ }
4063+
4064+ if to == input.ModeVim && from != input.ModeVim {
4065+ // Entering vim mode: activate on the current active pane of this window.
4066+ paneID := ws.ActivePaneID
4067+ if pv := wsView.GetPaneView(paneID); pv != nil {
4068+ pv.SetVimMode(true)
4069+ bw.vimModePaneID = paneID
4070+ logging.FromContext(ctx).Debug().
4071+ Str("window_id", bw.id).
4072+ Str("pane_id", string(paneID)).
4073+ Msg("vim mode activated on pane")
4074+ }
4075+ } else if from == input.ModeVim && to != input.ModeVim {
4076+ // Leaving Vim Mode: deactivate the pane that owns the visual accent.
4077+ a.clearVimModeOwnership(ctx, bw)
4078+ }
4079+}
4080+
4081+// triggerVimModePulse triggers a pane-local vim mode pulse on the
4082+// last-focused browser window's owning pane. This is called by the
4083+// keyboard dispatcher after a page scroll action. The bw used is the
4084+// window that was active when the action was dispatched (via
4085+// dispatchBrowserWindowAction which calls activateBrowserWindow first).
4086+// If no pane is currently in vim mode, the pulse is a no-op.
4087+//
4088+// Pulses are debounced at vimModePulseInterval to prevent excessive GTK CSS
4089+// class churn during held-key repeats. Smooth scroll cadence is owned by the
4090+// Vim Mode repeater and backend scroll path; pulse feedback is deliberately
4091+// slower so indicator/overlay animation work does not become part of the
4092+// scrolling critical path.
4093+func (a *App) triggerVimModePulse(_ context.Context, fast bool) {
4094+ // Debounce: skip if called again too soon during a continuous hold.
4095+ // We still serialize the timestamp update so repeated dispatcher calls do
4096+ // not restart CSS animations at scroll cadence.
4097+ a.vimModePulseMu.Lock()
4098+ since := time.Since(a.vimModePulseLastTime)
4099+ if since < vimModePulseInterval {
4100+ a.vimModePulseMu.Unlock()
4101+ return
4102+ }
4103+ a.vimModePulseLastTime = time.Now()
4104+ a.vimModePulseMu.Unlock()
4105+
37064106 bw := a.lastFocusedBrowserWindow()
4107+ if bw == nil || bw.vimModePaneID == "" {
4108+ return
4109+ }
4110+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
4111+ if wsView == nil {
4112+ return
4113+ }
4114+ if pv := wsView.GetPaneView(bw.vimModePaneID); pv != nil {
4115+ if fast {
4116+ pv.TriggerVimModePulseFast()
4117+ } else {
4118+ pv.TriggerVimModePulse()
4119+ }
4120+ }
4121+}
4122+
4123+// clearVimModeOwnership deactivates vim mode on the owning pane for a
4124+// specific browser window and resets its tracked pane ID.
4125+// Also resets the pulse debounce timer so the first pulse on re-entry
4126+// is never skipped.
4127+func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) {
4128+ if bw == nil || bw.vimModePaneID == "" {
4129+ return
4130+ }
4131+
4132+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
4133+ if wsView != nil {
4134+ if pv := wsView.GetPaneView(bw.vimModePaneID); pv != nil {
4135+ pv.SetVimMode(false)
4136+ logging.FromContext(ctx).Debug().
4137+ Str("window_id", bw.id).
4138+ Str("pane_id", string(bw.vimModePaneID)).
4139+ Msg("vim mode deactivated on pane")
4140+ }
4141+ }
4142+ bw.vimModePaneID = ""
4143+
4144+ // Reset debounce timer so re-entry pulses are never mis-skipped.
4145+ a.vimModePulseMu.Lock()
4146+ a.vimModePulseLastTime = time.Time{}
4147+ a.vimModePulseMu.Unlock()
4148+}
4149+
4150+// updateModeIndicatorToaster reconciles one window's mode toaster with its
4151+// current mode and the live mode-indicator preference.
4152+func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, mode input.Mode) {
37074153 if bw == nil || bw.modeToaster == nil {
37084154 return
37094155 }
37104156
3711- // Check if mode indicator toaster is enabled in config.
3712- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
4157+ if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal {
37134158 bw.modeToaster.Hide()
37144159 return
37154160 }
37164161
3717- if mode == input.ModeNormal {
4162+ // Modal mode toasts remain visible until that same window exits its mode.
4163+ // The mode class is applied atomically with Show() to avoid visual flicker.
4164+ bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
4165+ component.WithDuration(0),
4166+ component.WithPosition(component.ToastPositionBottomLeft),
4167+ component.WithModeClass(getModeToastClass(mode)),
4168+ )
4169+}
4170+
4171+// bindVimModeSequenceToaster wires pending Vim-sequence text onto this
4172+// window's modeToaster only (never appToaster / pane toasters).
4173+func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {
4174+ if a == nil || bw == nil || bw.keyboardHandler == nil {
4175+ return
4176+ }
4177+ bw.keyboardHandler.SetOnPendingSequenceChange(func(pending string) {
4178+ a.showPendingSequence(ctx, bw, pending)
4179+ })
4180+ logging.FromContext(ctx).Debug().
4181+ Str("window_id", bw.id).
4182+ Msg("vim mode pending sequence toaster bound")
4183+}
4184+
4185+// showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster
4186+// while a sequence is incomplete, and restores stable "VIM MODE" when pending
4187+// clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster;
4188+// silent reset under ModalState avoids stale pending callbacks.
4189+func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
4190+ if a == nil || bw == nil || bw.modeToaster == nil {
4191+ return
4192+ }
4193+ log := logging.FromContext(ctx)
4194+ if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
37184195 bw.modeToaster.Hide()
4196+ log.Debug().Str("window_id", bw.id).Msg("vim mode pending toaster hidden (disabled)")
37194197 return
37204198 }
37214199
3722- // Show persistent toaster at bottom-left with mode display name.
3723- // Mode class is applied atomically with Show() to avoid visual flicker.
3724- modeClass := getModeToastClass(mode)
3725- bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
3726- component.WithDuration(0), // Persistent until mode exits.
4200+ text := input.ModeVim.DisplayName()
4201+ if pending != "" {
4202+ text = text + " · " + pending
4203+ }
4204+ bw.modeToaster.Show(ctx, text, component.ToastInfo,
4205+ component.WithDuration(0),
37274206 component.WithPosition(component.ToastPositionBottomLeft),
3728- component.WithModeClass(modeClass),
4207+ component.WithModeClass(getModeToastClass(input.ModeVim)),
37294208 )
4209+ log.Debug().
4210+ Str("window_id", bw.id).
4211+ Str("pending", pending).
4212+ Str("toast_text", text).
4213+ Msg("vim mode pending toaster updated")
37304214 }
37314215
37324216 // getModeToastClass returns the CSS class for the given mode's toast styling.
@@ -3734,6 +4218,8 @@ func getModeToastClass(mode input.Mode) string {
37344218 switch mode {
37354219 case input.ModePane:
37364220 return "toast-pane-mode"
4221+ case input.ModeVim:
4222+ return "toast-vim-mode"
37374223 case input.ModeTab:
37384224 return "toast-tab-mode"
37394225 case input.ModeSession:
@@ -3843,6 +4329,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
38434329 })
38444330 wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {
38454331 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
4332+ if bw := a.browserWindowForTab(tab.ID); bw != nil {
4333+ a.transferVimModeOwnershipToPane(ctx, bw, paneID)
4334+ }
38464335 })
38474336 }
38484337
@@ -4965,6 +5454,11 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
49655454 if bw.globalShortcutHandler != nil {
49665455 bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)
49675456 }
5457+ mode := input.ModeNormal
5458+ if bw.keyboardHandler != nil {
5459+ mode = bw.keyboardHandler.Mode()
5460+ }
5461+ a.updateModeIndicatorToaster(ctx, bw, mode)
49685462 }
49695463 }
49705464
@@ -5033,6 +5527,7 @@ func (a *App) applyThemeAppearance(ctx context.Context) {
50335527 if resolveThemeUC == nil {
50345528 resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource)
50355529 }
5530+ a.deps.Theme.SetTransitionDuration(workspaceStyling.TransitionDuration)
50365531 resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig(
50375532 &appearanceCfg,
50385533 runtimeCfg.DefaultUIScale,
M internal/ui/app_browser_launch_test.go +57 -14
@@ -35,7 +35,9 @@ import (
3535 "github.com/bnema/dumber/internal/ui/window"
3636 "github.com/bnema/puregotk/v4/gdk"
3737 "github.com/bnema/puregotk/v4/gio"
38+ "github.com/bnema/puregotk/v4/glib"
3839 "github.com/bnema/puregotk/v4/gtk"
40+ "github.com/stretchr/testify/assert"
3941 "github.com/stretchr/testify/mock"
4042 "github.com/stretchr/testify/require"
4143 )
@@ -45,6 +47,10 @@ type testBrowserLaunchRelay struct {
4547 closer *testCloser
4648 }
4749
50+func (r *testBrowserLaunchRelay) DeliverOpenExternalURL(context.Context, string) (bool, error) {
51+ return false, nil
52+}
53+
4854 func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
4955 return false, nil
5056 }
@@ -957,6 +963,10 @@ func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
957963 return reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().IsNil()
958964 }
959965
966+// newTestShellToaster builds a Toaster backed by testify mocks.
967+// Callers that Show() must Hide() (or otherwise cancel the dismiss timer)
968+// before the test returns — default Show arms a GLib TimeoutAdd that can
969+// fire during a later test's MainContext.Iteration and panic on dead mocks.
960970 func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {
961971 t.Helper()
962972
@@ -979,7 +989,9 @@ func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBox
979989 label.EXPECT().SetCanTarget(false).Once()
980990 label.EXPECT().SetCanFocus(false).Once()
981991
982- return component.NewToaster(factory), box, label
992+ toaster := component.NewToaster(factory)
993+ t.Cleanup(toaster.CancelAutoDismiss)
994+ return toaster, box, label
983995 }
984996
985997 func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
@@ -987,6 +999,9 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
987999 toaster, box, label := newTestShellToaster(t)
9881000 box.EXPECT().SetVisible(true).Once()
9891001 label.EXPECT().SetText("Ad blocker loading").Once()
1002+ // Cancel the auto-dismiss GLib timer before mock teardown so a later test
1003+ // iterating the main context cannot fire hide() on a completed test's mocks.
1004+ box.EXPECT().SetVisible(false).Once()
9901005
9911006 bw := &browserWindow{id: "window-1", appToaster: toaster}
9921007 app := &App{
@@ -995,6 +1010,30 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
9951010 }
9961011
9971012 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
1013+ toaster.Hide()
1014+}
1015+
1016+func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {
1017+ toaster, box, label := newTestShellToaster(t)
1018+ box.EXPECT().SetVisible(true).Once()
1019+ label.EXPECT().SetText("will not outlive this test").Once()
1020+ toaster.Show(
1021+ context.Background(),
1022+ "will not outlive this test",
1023+ component.ToastInfo,
1024+ component.WithDuration(component.ToastBriefDurationMs),
1025+ )
1026+ toaster.CancelAutoDismiss()
1027+
1028+ mainContext := glib.MainContextDefault()
1029+ deadline := time.Now().Add(component.ToastBriefDurationMs*time.Millisecond + 600*time.Millisecond)
1030+ for time.Now().Before(deadline) {
1031+ for mainContext.Pending() {
1032+ mainContext.Iteration(false)
1033+ }
1034+ time.Sleep(10 * time.Millisecond)
1035+ }
1036+ assert.True(t, toaster.IsVisible(), "canceling must prevent the deferred hide callback")
9981037 }
9991038
10001039 func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
@@ -1002,6 +1041,9 @@ func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.
10021041 toaster, box, label := newTestShellToaster(t)
10031042 box.EXPECT().SetVisible(true).Once()
10041043 label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()
1044+ // Cancel the auto-dismiss GLib timer before mock teardown so a later test
1045+ // iterating the main context cannot fire hide() on a completed test's mocks.
1046+ box.EXPECT().SetVisible(false).Once()
10051047
10061048 migrator := portmocks.NewMockConfigMigrator(t)
10071049 migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()
@@ -1020,6 +1062,7 @@ func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.
10201062 }
10211063
10221064 app.checkConfigMigration(ctx)
1065+ toaster.Hide()
10231066 }
10241067
10251068 func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
@@ -1061,7 +1104,7 @@ func TestApp_GetWindowSnapshotStateReturnsUnavailableWhenMainThreadDispatchTimes
10611104 require.Equal(t, -1, activeWindowIndex)
10621105 }
10631106
1064-func TestApp_OpenFreshWindowReportsMainThreadDispatchTimeout(t *testing.T) {
1107+func TestApp_OpenExternalURLReportsMainThreadDispatchTimeout(t *testing.T) {
10651108 var factoryCalls int
10661109 app := &App{
10671110 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
@@ -1073,20 +1116,20 @@ func TestApp_OpenFreshWindowReportsMainThreadDispatchTimeout(t *testing.T) {
10731116 },
10741117 }
10751118
1076- err := app.OpenFreshWindow(context.Background(), "https://example.com")
1119+ err := app.OpenExternalURL(context.Background(), "https://example.com")
10771120
10781121 if err == nil {
1079- t.Fatal("OpenFreshWindow returned nil error, want dispatch timeout")
1122+ t.Fatal("OpenExternalURL returned nil error, want dispatch timeout")
10801123 }
10811124 if !strings.Contains(err.Error(), "main thread dispatch did not complete") {
1082- t.Fatalf("OpenFreshWindow error = %q, want dispatch timeout", err.Error())
1125+ t.Fatalf("OpenExternalURL error = %q, want dispatch timeout", err.Error())
10831126 }
10841127 if factoryCalls != 0 {
10851128 t.Fatalf("browserWindowFactory calls = %d, want 0", factoryCalls)
10861129 }
10871130 }
10881131
1089-func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
1132+func TestApp_OpenExternalURLRecordsTabOwnership(t *testing.T) {
10901133 existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
10911134 existingTabs := entity.NewTabList()
10921135 existingTabs.Add(existingTab)
@@ -1104,8 +1147,8 @@ func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
11041147 },
11051148 }
11061149
1107- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
1108- t.Fatalf("OpenFreshWindow returned error: %v", err)
1150+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1151+ t.Fatalf("OpenExternalURL returned error: %v", err)
11091152 }
11101153 if got := windowForTabCount(t, app); got != 2 {
11111154 t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
@@ -1353,7 +1396,7 @@ func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T
13531396 }
13541397 }
13551398
1356-func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
1399+func TestApp_OpenExternalURLRollsBackOnTabCreationFailure(t *testing.T) {
13571400 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
13581401 originalWindow := &window.MainWindow{}
13591402 tabBar := &component.TabBar{}
@@ -1384,8 +1427,8 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
13841427 MainWindow: &window.MainWindow{},
13851428 })
13861429
1387- if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {
1388- t.Fatalf("OpenFreshWindow = nil error, want failure")
1430+ if err := app.OpenExternalURL(context.Background(), "https://example.com/fail"); err == nil {
1431+ t.Fatalf("OpenExternalURL = nil error, want failure")
13891432 }
13901433 if got := len(app.browserWindows); got != 1 {
13911434 t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
@@ -1404,7 +1447,7 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
14041447 }
14051448 }
14061449
1407-func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
1450+func TestApp_OpenExternalURLTargetsNewWindowTabBar(t *testing.T) {
14081451 existingTabID := entity.TabID("existing-tab")
14091452 createdTabID := entity.TabID("id-1")
14101453 oldWindow := &window.MainWindow{}
@@ -1441,8 +1484,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
14411484 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
14421485 })
14431486
1444- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
1445- t.Fatalf("OpenFreshWindow returned error: %v", err)
1487+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1488+ t.Fatalf("OpenExternalURL returned error: %v", err)
14461489 }
14471490
14481491 gotCreatedTabID := app.tabs.ActiveTabID
A internal/ui/app_vim_mode_test.go +1340 -0
@@ -0,0 +1,1340 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/puregotk/v4/glib"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
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"
16+ "github.com/bnema/dumber/internal/application/usecase"
17+ "github.com/bnema/dumber/internal/domain/entity"
18+ "github.com/bnema/dumber/internal/ui/component"
19+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
20+ "github.com/bnema/dumber/internal/ui/focus"
21+ "github.com/bnema/dumber/internal/ui/input"
22+ "github.com/bnema/dumber/internal/ui/layout"
23+ "github.com/bnema/dumber/internal/ui/layout/mocks"
24+ "github.com/bnema/dumber/internal/ui/theme"
25+)
26+
27+// ---------------------------------------------------------------------------
28+// Mock-based PaneView and WorkspaceView creation helpers
29+//
30+// These are used by vimModeTestFixture to build a minimal test environment
31+// without any GTK infrastructure.
32+// ---------------------------------------------------------------------------
33+
34+// newLoadingSkeletonMocks sets up the mock expectations for the loading
35+// skeleton that NewPaneView creates internally.
36+func newLoadingSkeletonMocks(
37+ t *testing.T,
38+ factory *mocks.MockWidgetFactory,
39+ overlay *mocks.MockOverlayWidget,
40+) {
41+ t.Helper()
42+
43+ loadingContainer := mocks.NewMockBoxWidget(t)
44+ loadingContent := mocks.NewMockBoxWidget(t)
45+ spinner := mocks.NewMockSpinnerWidget(t)
46+ logo := mocks.NewMockImageWidget(t)
47+ version := mocks.NewMockLabelWidget(t)
48+
49+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(loadingContainer).Once()
50+ loadingContainer.EXPECT().SetHexpand(true).Maybe()
51+ loadingContainer.EXPECT().SetVexpand(true).Maybe()
52+ loadingContainer.EXPECT().SetHalign(mock.Anything).Maybe()
53+ loadingContainer.EXPECT().SetValign(mock.Anything).Maybe()
54+ loadingContainer.EXPECT().SetCanFocus(false).Maybe()
55+ loadingContainer.EXPECT().SetCanTarget(false).Maybe()
56+ loadingContainer.EXPECT().AddCssClass(mock.Anything).Maybe()
57+ loadingContainer.EXPECT().SetVisible(mock.Anything).Maybe()
58+
59+ factory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(loadingContent).Once()
60+ loadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
61+ loadingContent.EXPECT().SetValign(mock.Anything).Maybe()
62+ loadingContent.EXPECT().SetCanFocus(false).Maybe()
63+ loadingContent.EXPECT().SetCanTarget(false).Maybe()
64+ loadingContent.EXPECT().AddCssClass(mock.Anything).Maybe()
65+
66+ factory.EXPECT().NewImage().Return(logo).Once()
67+ logo.EXPECT().SetHalign(mock.Anything).Maybe()
68+ logo.EXPECT().SetValign(mock.Anything).Maybe()
69+ logo.EXPECT().SetCanFocus(false).Maybe()
70+ logo.EXPECT().SetCanTarget(false).Maybe()
71+ logo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
72+ logo.EXPECT().SetPixelSize(mock.Anything).Maybe()
73+ logo.EXPECT().AddCssClass(mock.Anything).Maybe()
74+ logo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
75+
76+ factory.EXPECT().NewSpinner().Return(spinner).Once()
77+ spinner.EXPECT().SetHalign(mock.Anything).Maybe()
78+ spinner.EXPECT().SetValign(mock.Anything).Maybe()
79+ spinner.EXPECT().SetCanFocus(false).Maybe()
80+ spinner.EXPECT().SetCanTarget(false).Maybe()
81+ spinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
82+ spinner.EXPECT().AddCssClass(mock.Anything).Maybe()
83+ spinner.EXPECT().Start().Maybe()
84+ spinner.EXPECT().Stop().Maybe()
85+
86+ factory.EXPECT().NewLabel(mock.Anything).Return(version).Once()
87+ version.EXPECT().SetHalign(mock.Anything).Maybe()
88+ version.EXPECT().SetValign(mock.Anything).Maybe()
89+ version.EXPECT().SetCanFocus(false).Maybe()
90+ version.EXPECT().SetCanTarget(false).Maybe()
91+ version.EXPECT().SetMaxWidthChars(mock.Anything).Maybe()
92+ version.EXPECT().SetEllipsize(mock.Anything).Maybe()
93+ version.EXPECT().AddCssClass(mock.Anything).Maybe()
94+
95+ loadingContent.EXPECT().Append(logo).Maybe()
96+ loadingContent.EXPECT().Append(spinner).Maybe()
97+ loadingContent.EXPECT().Append(version).Maybe()
98+ loadingContainer.EXPECT().Append(loadingContent).Maybe()
99+
100+ overlay.EXPECT().AddOverlay(loadingContainer).Once()
101+ overlay.EXPECT().SetClipOverlay(loadingContainer, false).Once()
102+ overlay.EXPECT().SetMeasureOverlay(loadingContainer, false).Once()
103+}
104+
105+// createTestPaneView builds a mock-backed PaneView. Returns the PaneView and
106+// its overlay mock so subsequent vim-mode indicator / pulse expectations can
107+// be set up by the caller.
108+func createTestPaneView(
109+ t *testing.T,
110+ factory *mocks.MockWidgetFactory,
111+ paneID entity.PaneID,
112+) (*component.PaneView, *mocks.MockOverlayWidget) {
113+ t.Helper()
114+
115+ overlay := mocks.NewMockOverlayWidget(t)
116+ borderBox := mocks.NewMockBoxWidget(t)
117+
118+ // Overlay
119+ factory.EXPECT().NewOverlay().Return(overlay).Once()
120+ overlay.EXPECT().SetHexpand(true).Once()
121+ overlay.EXPECT().SetVexpand(true).Once()
122+ overlay.EXPECT().SetVisible(true).Once()
123+ overlay.EXPECT().AddCssClass("pane-overlay").Once()
124+
125+ // Loading skeleton
126+ newLoadingSkeletonMocks(t, factory, overlay)
127+
128+ // Border box
129+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(borderBox).Once()
130+ borderBox.EXPECT().SetCanFocus(false).Once()
131+ borderBox.EXPECT().SetCanTarget(false).Once()
132+ borderBox.EXPECT().AddCssClass("pane-border").Once()
133+ borderBox.EXPECT().SetHexpand(true).Once()
134+ borderBox.EXPECT().SetVexpand(true).Once()
135+ overlay.EXPECT().AddOverlay(borderBox).Once()
136+ overlay.EXPECT().SetClipOverlay(borderBox, false).Once()
137+ overlay.EXPECT().SetMeasureOverlay(borderBox, false).Once()
138+
139+ pv := component.NewPaneView(context.Background(), factory, paneID, nil)
140+ require.NotNil(t, pv)
141+
142+ return pv, overlay
143+}
144+
145+// enterVimMode marks the owning pane with the pane-local visual accent.
146+func enterVimMode(
147+ t *testing.T,
148+ app *App,
149+ overlay *mocks.MockOverlayWidget,
150+ bw *browserWindow,
151+) {
152+ t.Helper()
153+ overlay.EXPECT().AddCssClass("vim-mode-active").Once()
154+ app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
155+}
156+
157+func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) {
158+ overlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
159+}
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+ clearNavigationHighlightCalls int
181+}
182+
183+func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView {
184+ t.Helper()
185+ wv := portmocks.NewMockWebView(t)
186+ wv.EXPECT().ID().Return(id)
187+ return &semanticNavigatingWebView{MockWebView: wv}
188+}
189+
190+func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request dto.SemanticNavigationRequest) error {
191+ wv.requests = append(wv.requests, request)
192+ return nil
193+}
194+
195+func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error {
196+ wv.clearNavigationHighlightCalls++
197+ return nil
198+}
199+
200+type inputFocusingWebView struct {
201+ *portmocks.MockWebView
202+ focusNextInputCalls int
203+ pageFocusNavigationCalls []bool
204+}
205+
206+func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView {
207+ t.Helper()
208+ wv := portmocks.NewMockWebView(t)
209+ wv.EXPECT().ID().Return(id).Once()
210+ return &inputFocusingWebView{MockWebView: wv}
211+}
212+
213+func (wv *inputFocusingWebView) FocusNextInput() {
214+ wv.focusNextInputCalls++
215+}
216+
217+func (wv *inputFocusingWebView) NavigatePageFocus(backward bool) {
218+ wv.pageFocusNavigationCalls = append(wv.pageFocusNavigationCalls, backward)
219+}
220+
221+func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
222+ t.Helper()
223+ paneID := entity.PaneID("pane-a")
224+ ws := &entity.Workspace{
225+ ID: "ws-1",
226+ ActivePaneID: paneID,
227+ Root: &entity.PaneNode{
228+ ID: "ws-1-root",
229+ Pane: entity.NewPane(paneID),
230+ },
231+ }
232+ tab := &entity.Tab{ID: "tab-1", Workspace: ws}
233+ tabs := entity.NewTabList()
234+ tabs.Add(tab)
235+ tabs.SetActive(tab.ID)
236+ bw := &browserWindow{id: "win-1", tabs: tabs}
237+ return &App{
238+ contentCoord: contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil),
239+ }, bw, paneID
240+}
241+
242+// setupWorkspaceViewMocks creates a mock-backed WorkspaceView.
243+func setupWorkspaceViewMocks(
244+ t *testing.T,
245+ factory *mocks.MockWidgetFactory,
246+) *component.WorkspaceView {
247+ t.Helper()
248+
249+ box := mocks.NewMockBoxWidget(t)
250+ overlay := mocks.NewMockOverlayWidget(t)
251+
252+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(box).Once()
253+ box.EXPECT().SetHexpand(true).Once()
254+ box.EXPECT().SetVexpand(true).Once()
255+ box.EXPECT().SetVisible(true).Once()
256+
257+ factory.EXPECT().NewOverlay().Return(overlay).Once()
258+ overlay.EXPECT().SetHexpand(true).Once()
259+ overlay.EXPECT().SetVexpand(true).Once()
260+ overlay.EXPECT().SetChild(box).Once()
261+ overlay.EXPECT().SetVisible(true).Once()
262+
263+ wv := component.NewWorkspaceView(context.Background(), factory)
264+ require.NotNil(t, wv)
265+
266+ return wv
267+}
268+
269+// newKeyboardHandlerInVimMode creates a KeyboardHandler whose Mode() returns
270+// ModeVim — no GTK widgets required.
271+func newKeyboardHandlerInVimMode(t *testing.T) *input.KeyboardHandler {
272+ t.Helper()
273+ kh := input.NewKeyboardHandler(
274+ context.Background(),
275+ &entity.WorkspaceConfig{},
276+ &entity.SessionConfig{},
277+ )
278+ kh.EnterVimMode()
279+ return kh
280+}
281+
282+func bindVimModeKeyboardHandler(t *testing.T, app *App, bw *browserWindow) *input.KeyboardHandler {
283+ t.Helper()
284+ kh := newKeyboardHandlerInVimMode(t)
285+ kh.SetOnModeChange(func(from, to input.Mode) {
286+ app.handleModeChange(context.Background(), bw, from, to)
287+ })
288+ bw.keyboardHandler = kh
289+ return kh
290+}
291+
292+// ---------------------------------------------------------------------------
293+// vimModeTestFixture — complete, minimal App + workspace environment
294+//
295+// Provides two browser windows, each with a two-pane workspace. The active
296+// pane on both workspaces starts at "pane-a".
297+// ---------------------------------------------------------------------------
298+
299+type vimModeTestFixture struct {
300+ app *App
301+ bw1 *browserWindow
302+ bw2 *browserWindow
303+ pv1A *component.PaneView // bw1 / pane-a (active)
304+ pv1B *component.PaneView // bw1 / pane-b
305+ pv2A *component.PaneView // bw2 / pane-a (active)
306+ pv2B *component.PaneView // bw2 / pane-b
307+
308+ factory *mocks.MockWidgetFactory
309+ overlay1A *mocks.MockOverlayWidget
310+ overlay1B *mocks.MockOverlayWidget
311+ overlay2A *mocks.MockOverlayWidget
312+ overlay2B *mocks.MockOverlayWidget
313+}
314+
315+func newVimModeTestFixture(t *testing.T) *vimModeTestFixture {
316+ t.Helper()
317+ factory := mocks.NewMockWidgetFactory(t)
318+
319+ // ---- Browser window 1 ----
320+ pv1A, ol1A := createTestPaneView(t, factory, "pane-a")
321+ pv1B, ol1B := createTestPaneView(t, factory, "pane-b")
322+
323+ ws1 := &entity.Workspace{
324+ ID: "ws-1",
325+ ActivePaneID: "pane-a",
326+ Root: &entity.PaneNode{
327+ ID: "ws-1-root",
328+ Children: []*entity.PaneNode{
329+ {ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")},
330+ {ID: "ws-1-pane-b", Pane: entity.NewPane("pane-b")},
331+ },
332+ SplitDir: entity.SplitHorizontal,
333+ },
334+ }
335+ tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1}
336+ tabs1 := entity.NewTabList()
337+ tabs1.Add(tab1)
338+ tabs1.SetActive(tab1.ID)
339+
340+ wv1 := setupWorkspaceViewMocks(t, factory)
341+ wv1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{
342+ "pane-a": pv1A, "pane-b": pv1B,
343+ })
344+
345+ bw1 := &browserWindow{id: "win-1", tabs: tabs1}
346+
347+ // ---- Browser window 2 ----
348+ pv2A, ol2A := createTestPaneView(t, factory, "pane-a")
349+ pv2B, ol2B := createTestPaneView(t, factory, "pane-b")
350+
351+ ws2 := &entity.Workspace{
352+ ID: "ws-2",
353+ ActivePaneID: "pane-a",
354+ Root: &entity.PaneNode{
355+ ID: "ws-2-root",
356+ Children: []*entity.PaneNode{
357+ {ID: "ws-2-pane-a", Pane: entity.NewPane("pane-a")},
358+ {ID: "ws-2-pane-b", Pane: entity.NewPane("pane-b")},
359+ },
360+ SplitDir: entity.SplitHorizontal,
361+ },
362+ }
363+ tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2}
364+ tabs2 := entity.NewTabList()
365+ tabs2.Add(tab2)
366+ tabs2.SetActive(tab2.ID)
367+
368+ wv2 := setupWorkspaceViewMocks(t, factory)
369+ wv2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{
370+ "pane-a": pv2A, "pane-b": pv2B,
371+ })
372+
373+ bw2 := &browserWindow{id: "win-2", tabs: tabs2}
374+
375+ app := &App{
376+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
377+ "tab-1": wv1, "tab-2": wv2,
378+ },
379+ browserWindows: map[string]*browserWindow{
380+ "win-1": bw1, "win-2": bw2,
381+ },
382+ lastFocusedWindowID: "win-1",
383+ }
384+
385+ return &vimModeTestFixture{
386+ app: app, bw1: bw1, bw2: bw2,
387+ pv1A: pv1A, pv1B: pv1B, pv2A: pv2A, pv2B: pv2B,
388+ factory: factory,
389+ overlay1A: ol1A, overlay1B: ol1B,
390+ overlay2A: ol2A, overlay2B: ol2B,
391+ }
392+}
393+
394+func newSingleWindowVimModeFixture(t *testing.T) *vimModeTestFixture {
395+ t.Helper()
396+ f := newVimModeTestFixture(t)
397+ delete(f.app.browserWindows, "win-2")
398+ delete(f.app.workspaceViews, entity.TabID("tab-2"))
399+ f.bw2 = nil
400+ f.pv2A = nil
401+ f.pv2B = nil
402+ f.overlay2A = nil
403+ f.overlay2B = nil
404+ return f
405+}
406+
407+// ============================================================================
408+// 1. Entering Vim mode marks only the active pane
409+// ============================================================================
410+
411+func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) {
412+ f := newVimModeTestFixture(t)
413+
414+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
415+
416+ assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID,
417+ "entering vim mode sets ownership on the active pane")
418+ assert.True(t, f.pv1A.IsVimMode(),
419+ "the active pane view enters vim mode")
420+ assert.False(t, f.pv1B.IsVimMode(),
421+ "the inactive pane is NOT in vim mode")
422+ assert.Empty(t, f.bw2.vimModePaneID,
423+ "second window is untouched")
424+ assert.False(t, f.pv2A.IsVimMode(),
425+ "second window active pane is NOT in vim mode")
426+}
427+
428+func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {
429+ f := newVimModeTestFixture(t)
430+
431+ enterVimMode(t, f.app, f.overlay2A, f.bw2)
432+
433+ assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
434+ assert.True(t, f.pv2A.IsVimMode())
435+ assert.Empty(t, f.bw1.vimModePaneID)
436+ assert.False(t, f.pv1A.IsVimMode())
437+}
438+
439+func TestVimMode_Enter_EnablesAccessibilityForSupportedActiveWebView(t *testing.T) {
440+ app, bw, paneID := newVimModeAccessibilityFixture(t)
441+ wv := newAccessibilityEnablingWebView(t, 1)
442+ app.contentCoord.RegisterPopupWebView(paneID, wv)
443+
444+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
445+
446+ assert.Equal(t, 1, wv.enableAccessibilityCalls)
447+}
448+
449+func TestVimMode_Enter_AccessibilityNoopsForUnsupportedActiveWebView(t *testing.T) {
450+ app, bw, paneID := newVimModeAccessibilityFixture(t)
451+ wv := portmocks.NewMockWebView(t)
452+ wv.EXPECT().ID().Return(port.WebViewID(2)).Once()
453+ app.contentCoord.RegisterPopupWebView(paneID, wv)
454+
455+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
456+}
457+
458+func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) {
459+ app, bw, _ := newVimModeAccessibilityFixture(t)
460+
461+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
462+}
463+
464+func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) {
465+ app, _, paneID := newVimModeAccessibilityFixture(t)
466+ wv := newAccessibilityEnablingWebView(t, 4)
467+ app.contentCoord.RegisterPopupWebView(paneID, wv)
468+ app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
469+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
470+ VimMode: entity.VimModeConfig{PreloadAccessibility: true},
471+ }},
472+ })
473+
474+ app.preloadAccessibilityForShownPane(paneID)
475+
476+ assert.Equal(t, 1, wv.enableAccessibilityCalls)
477+}
478+
479+func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) {
480+ app, _, paneID := newVimModeAccessibilityFixture(t)
481+ wv := newAccessibilityEnablingWebView(t, 5)
482+ app.contentCoord.RegisterPopupWebView(paneID, wv)
483+
484+ app.preloadAccessibilityForShownPane(paneID)
485+
486+ assert.Equal(t, 0, wv.enableAccessibilityCalls)
487+}
488+
489+func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) {
490+ resolved := entity.ResolvedTheme{
491+ PrefersDark: true,
492+ DarkPalette: entity.ColorPalette{
493+ Accent: "#22c55e",
494+ },
495+ }
496+ app := &App{deps: &Dependencies{Theme: theme.NewManager(context.Background(), resolved)}}
497+
498+ assert.Equal(t, "#22c55e", app.vimNavigationHighlightColor())
499+}
500+
501+func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
502+ app, bw, paneID := newVimModeAccessibilityFixture(t)
503+ wv := newSemanticNavigatingWebView(t, 4)
504+ app.contentCoord.RegisterPopupWebView(paneID, wv)
505+
506+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3)
507+ app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0)
508+ app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1)
509+
510+ assert.Equal(t, []dto.SemanticNavigationRequest{
511+ {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationForward, Count: 3},
512+ {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationBackward, Count: 0},
513+ }, wv.requests)
514+}
515+
516+func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {
517+ app, bw, paneID := newVimModeAccessibilityFixture(t)
518+ wv := newInputFocusingWebView(t, 6)
519+ app.contentCoord.RegisterPopupWebView(paneID, wv)
520+
521+ app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
522+
523+ assert.Equal(t, 1, wv.focusNextInputCalls)
524+}
525+
526+func TestVimMode_Leave_ClearsNavigationHighlight(t *testing.T) {
527+ app, bw, paneID := newVimModeAccessibilityFixture(t)
528+ wv := newSemanticNavigatingWebView(t, 8)
529+ app.contentCoord.RegisterPopupWebView(paneID, wv)
530+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
531+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
532+
533+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
534+
535+ assert.Equal(t, 1, wv.clearNavigationHighlightCalls)
536+ assert.Empty(t, bw.vimNavigationHighlightedWebViews)
537+ assert.Empty(t, bw.vimNavigationHighlightedWebViewIDs)
538+}
539+
540+func TestVimMode_Leave_ClearsNavigationHighlightAfterPaneSwitch(t *testing.T) {
541+ f := newSingleWindowVimModeFixture(t)
542+ f.app.contentCoord = contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil)
543+ oldWebView := newSemanticNavigatingWebView(t, 9)
544+ newWebView := newSemanticNavigatingWebView(t, 10)
545+ f.app.contentCoord.RegisterPopupWebView("pane-a", oldWebView)
546+ f.app.contentCoord.RegisterPopupWebView("pane-b", newWebView)
547+
548+ f.app.navigateVimSequenceAction(context.Background(), f.bw1, "heading-next", 1)
549+ f.bw1.tabs.ActiveTab().Workspace.ActivePaneID = "pane-b"
550+ f.app.handleModeChange(context.Background(), f.bw1, input.ModeVim, input.ModeNormal)
551+
552+ assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
553+ assert.Equal(t, 0, newWebView.clearNavigationHighlightCalls)
554+}
555+
556+func TestVimMode_Leave_ClearsNavigationHighlightAfterTabSwitch(t *testing.T) {
557+ app, bw, paneID := newVimModeAccessibilityFixture(t)
558+ oldWebView := newSemanticNavigatingWebView(t, 11)
559+ app.contentCoord.RegisterPopupWebView(paneID, oldWebView)
560+ otherPaneID := entity.PaneID("pane-b")
561+ otherTab := entity.NewTab("tab-2", "ws-2", entity.NewPane(otherPaneID))
562+ bw.tabs.Add(otherTab)
563+ bw.tabs.SetActive("tab-1")
564+
565+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
566+ bw.tabs.SetActive(otherTab.ID)
567+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
568+
569+ assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
570+}
571+
572+func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) {
573+ app, bw, paneID := newVimModeAccessibilityFixture(t)
574+ wv := newInputFocusingWebView(t, 7)
575+ app.contentCoord.RegisterPopupWebView(paneID, wv)
576+ app.pageEditableFocusByPane = map[entity.PaneID]bool{paneID: true}
577+
578+ assert.True(t, app.navigatePageFocus(context.Background(), bw, true))
579+ assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls)
580+}
581+
582+func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
583+ app, bw, paneID := newVimModeAccessibilityFixture(t)
584+ wv := portmocks.NewMockWebView(t)
585+ wv.EXPECT().ID().Return(port.WebViewID(5)).Once()
586+ app.contentCoord.RegisterPopupWebView(paneID, wv)
587+
588+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
589+ app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
590+}
591+
592+func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
593+ app, bw, paneID := newVimModeAccessibilityFixture(t)
594+ wv := newAccessibilityEnablingWebView(t, 3)
595+ app.contentCoord.RegisterPopupWebView(paneID, wv)
596+
597+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeTab)
598+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
599+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeVim)
600+ assert.Equal(t, 0, wv.enableAccessibilityCalls)
601+
602+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
603+ assert.Equal(t, 1, wv.enableAccessibilityCalls)
604+
605+ app.handleModeChange(context.Background(), bw, input.ModePane, input.ModeVim)
606+ assert.Equal(t, 2, wv.enableAccessibilityCalls)
607+}
608+
609+// ============================================================================
610+// 2. Leaving Vim mode clears ownership
611+// ============================================================================
612+
613+func TestVimMode_Leave_ClearsOwnershipFromOwningPane(t *testing.T) {
614+ f := newVimModeTestFixture(t)
615+
616+ // Enter first
617+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
618+ require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
619+
620+ // Leave — SetVimMode(false) hides indicator and removes the CSS class
621+ expectVimModeAccentHidden(f.overlay1A)
622+ f.app.handleVimModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModeVim)
623+
624+ assert.Empty(t, f.bw1.vimModePaneID,
625+ "leaving vim mode clears ownership field")
626+ assert.False(t, f.pv1A.IsVimMode(),
627+ "the previously owning pane exits vim mode")
628+}
629+
630+// ============================================================================
631+// 3. Transfer within the same window
632+// ============================================================================
633+
634+func TestVimMode_Transfer_MovesOwnershipToNewPane(t *testing.T) {
635+ f := newVimModeTestFixture(t)
636+
637+ // Enter vim mode on bw1 pane-a — save label for hide expectation
638+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
639+ require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
640+
641+ // Give bw1 a keyboard handler in vim mode so transfer performs the switch
642+ f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
643+
644+ // transferVimModeOwnershipToPane calls SetVimMode(false) on the old
645+ // pane before SetVimMode(true) on the new pane. Set up both.
646+ expectVimModeAccentHidden(f.overlay1A)
647+
648+ // Set up the pane-local accent for pane-b.
649+ f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
650+
651+ // Transfer ownership to pane-b
652+ f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
653+
654+ assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID,
655+ "ownership transferred to new pane")
656+ assert.False(t, f.pv1A.IsVimMode(),
657+ "previous owning pane deactivated")
658+ assert.True(t, f.pv1B.IsVimMode(),
659+ "new pane is now in vim mode")
660+}
661+
662+func TestVimMode_Transfer_NoVimModeClearsStale(t *testing.T) {
663+ f := newVimModeTestFixture(t)
664+
665+ f.bw1.vimModePaneID = entity.PaneID("pane-a")
666+ f.bw1.keyboardHandler = input.NewKeyboardHandler(
667+ context.Background(),
668+ &entity.WorkspaceConfig{},
669+ &entity.SessionConfig{},
670+ )
671+ // No EnterVimMode — handler stays in ModeNormal.
672+
673+ f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
674+
675+ assert.Empty(t, f.bw1.vimModePaneID,
676+ "stale ownership cleared when window is not in vim mode")
677+ assert.False(t, f.pv1A.IsVimMode())
678+ assert.False(t, f.pv1B.IsVimMode())
679+}
680+
681+func TestVimMode_Transfer_NilBwDoesNotCrash(t *testing.T) {
682+ app := &App{}
683+ app.transferVimModeOwnershipToPane(context.Background(), nil, entity.PaneID("pane-1"))
684+}
685+
686+func TestVimMode_Transfer_SamePaneIsNoop(t *testing.T) {
687+ app := &App{}
688+ paneID := entity.PaneID("pane-a")
689+ bw := &browserWindow{vimModePaneID: paneID}
690+ app.transferVimModeOwnershipToPane(context.Background(), bw, paneID)
691+ assert.Equal(t, paneID, bw.vimModePaneID)
692+}
693+
694+func TestVimMode_Transfer_EmptyOwnershipIsNoop(t *testing.T) {
695+ app := &App{}
696+ bw := &browserWindow{}
697+ app.transferVimModeOwnershipToPane(context.Background(), bw, entity.PaneID("pane-a"))
698+ assert.Empty(t, bw.vimModePaneID)
699+}
700+
701+// ============================================================================
702+// 4. Pulse targeting — normal vs fast
703+// ============================================================================
704+
705+func setUpNormalPulse(overlay *mocks.MockOverlayWidget) {
706+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
707+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
708+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
709+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
710+ overlay.EXPECT().AddCssClass("vim-mode-pulse").Once()
711+ overlay.EXPECT().AddCssClass("vim-mode-pulse-cycle-a").Once()
712+}
713+
714+func setUpFastPulse(overlay *mocks.MockOverlayWidget) {
715+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
716+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
717+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
718+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
719+ overlay.EXPECT().AddCssClass("vim-mode-pulse-fast").Once()
720+ overlay.EXPECT().AddCssClass("vim-mode-pulse-cycle-a").Once()
721+}
722+
723+func TestVimMode_Pulse_NormalTriggersOnOwningPane(t *testing.T) {
724+ f := newVimModeTestFixture(t)
725+
726+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
727+ require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
728+
729+ // Pulse needs the indicator to exist — it was created lazily during
730+ // enterVimMode, but we also need to set up pulse expectations.
731+ // (enterVimMode created it, so we just set up pulse mocks.)
732+ setUpNormalPulse(f.overlay1A)
733+
734+ f.app.triggerVimModePulse(context.Background(), false)
735+}
736+
737+func TestVimMode_Pulse_FastTriggersOnOwningPane(t *testing.T) {
738+ f := newVimModeTestFixture(t)
739+
740+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
741+ require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
742+
743+ setUpFastPulse(f.overlay1A)
744+
745+ f.app.triggerVimModePulse(context.Background(), true)
746+}
747+
748+func TestVimMode_Pulse_NoOwnerIsNoop(t *testing.T) {
749+ f := newVimModeTestFixture(t)
750+ f.app.triggerVimModePulse(context.Background(), false)
751+ f.app.triggerVimModePulse(context.Background(), true)
752+ // No pane has ownership — no pulse expectations needed.
753+}
754+
755+// ============================================================================
756+// 5. Multi-window isolation
757+// ============================================================================
758+
759+func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) {
760+ f := newVimModeTestFixture(t)
761+
762+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
763+ enterVimMode(t, f.app, f.overlay2A, f.bw2)
764+
765+ assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
766+ assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
767+
768+ // Leave bw1 only
769+ expectVimModeAccentHidden(f.overlay1A)
770+ f.app.handleVimModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModeVim)
771+
772+ assert.Empty(t, f.bw1.vimModePaneID)
773+ assert.False(t, f.pv1A.IsVimMode())
774+
775+ // bw2 unchanged
776+ assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
777+ assert.True(t, f.pv2A.IsVimMode())
778+}
779+
780+func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
781+ f := newVimModeTestFixture(t)
782+
783+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
784+ enterVimMode(t, f.app, f.overlay2A, f.bw2)
785+
786+ f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
787+
788+ // transferVimModeOwnershipToPane calls SetVimMode(false) on old pane first
789+ expectVimModeAccentHidden(f.overlay1A)
790+
791+ // Transfer bw1 to pane-b.
792+ f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
793+ f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
794+
795+ assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID)
796+ assert.True(t, f.pv1B.IsVimMode())
797+ assert.False(t, f.pv1A.IsVimMode())
798+
799+ // bw2 unchanged
800+ assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
801+ assert.True(t, f.pv2A.IsVimMode())
802+}
803+
804+func TestVimMode_MultiWindow_PulseIsolated(t *testing.T) {
805+ f := newVimModeTestFixture(t)
806+
807+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
808+ enterVimMode(t, f.app, f.overlay2A, f.bw2)
809+
810+ // Pulse on bw1 (lastFocusedWindowID = "win-1")
811+ setUpNormalPulse(f.overlay1A)
812+ f.app.triggerVimModePulse(context.Background(), false)
813+
814+ // bw2 pane still in vim mode
815+ assert.True(t, f.pv2A.IsVimMode())
816+}
817+
818+// ============================================================================
819+// Edge cases — nil / no-workspace / startup guards
820+// ============================================================================
821+
822+func TestVimMode_Enter_NoWorkspaceIsNoop(t *testing.T) {
823+ app := &App{}
824+ bw := &browserWindow{id: "win-1"}
825+ app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
826+ assert.Empty(t, bw.vimModePaneID)
827+}
828+
829+func TestVimMode_Clear_NilBwDoesNotCrash(t *testing.T) {
830+ (&App{}).clearVimModeOwnership(context.Background(), nil)
831+}
832+
833+func TestVimMode_Clear_NoPaneID(t *testing.T) {
834+ app := &App{}
835+ bw := &browserWindow{id: "test-win"}
836+ app.clearVimModeOwnership(context.Background(), bw)
837+ assert.Empty(t, bw.vimModePaneID)
838+}
839+
840+func TestVimMode_Clear_StalePaneWithoutWorkspace(t *testing.T) {
841+ app := &App{}
842+ bw := &browserWindow{id: "test-win", vimModePaneID: entity.PaneID("stale-pane")}
843+ app.clearVimModeOwnership(context.Background(), bw)
844+ assert.Empty(t, bw.vimModePaneID)
845+}
846+
847+func TestVimMode_HandleModeChange_NilBwDoesNotCrash(t *testing.T) {
848+ (&App{}).handleModeChange(context.Background(), nil, input.ModeNormal, input.ModeVim)
849+}
850+
851+func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) {
852+ f := newSingleWindowVimModeFixture(t)
853+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
854+ kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
855+
856+ expectVimModeAccentHidden(f.overlay1A)
857+ f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true)
858+
859+ assert.Equal(t, input.ModeNormal, kh.Mode())
860+ assert.Empty(t, f.bw1.vimModePaneID)
861+ assert.False(t, f.pv1A.IsVimMode())
862+}
863+
864+func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) {
865+ f := newSingleWindowVimModeFixture(t)
866+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
867+ kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
868+
869+ f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-b"), true)
870+
871+ assert.Equal(t, input.ModeVim, kh.Mode())
872+ assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
873+ assert.True(t, f.pv1A.IsVimMode())
874+}
875+
876+func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {
877+ f := newSingleWindowVimModeFixture(t)
878+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
879+ kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
880+ f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
881+ "pane-b": true,
882+ }
883+
884+ expectVimModeAccentHidden(f.overlay1A)
885+ f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
886+
887+ assert.Equal(t, input.ModeNormal, kh.Mode())
888+ assert.Empty(t, f.bw1.vimModePaneID)
889+ assert.False(t, f.pv1A.IsVimMode())
890+ assert.False(t, f.pv1B.IsVimMode())
891+}
892+
893+func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t *testing.T) {
894+ factory := mocks.NewMockWidgetFactory(t)
895+ pv1, overlay1 := createTestPaneView(t, factory, "pane-a")
896+ pv2, _ := createTestPaneView(t, factory, "pane-x")
897+
898+ ws1 := &entity.Workspace{ID: "ws-1", ActivePaneID: "pane-a", Root: &entity.PaneNode{ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")}}
899+ ws2 := &entity.Workspace{ID: "ws-2", ActivePaneID: "pane-x", Root: &entity.PaneNode{ID: "ws-2-pane-x", Pane: entity.NewPane("pane-x")}}
900+ bw1 := &browserWindow{id: "win-1", tabs: entity.NewTabList()}
901+ bw2 := &browserWindow{id: "win-2", tabs: entity.NewTabList()}
902+ tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1}
903+ tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2}
904+ bw1.tabs.Add(tab1)
905+ bw1.tabs.SetActive(tab1.ID)
906+ bw2.tabs.Add(tab2)
907+ bw2.tabs.SetActive(tab2.ID)
908+ view1 := setupWorkspaceViewMocks(t, factory)
909+ view1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-a": pv1})
910+ view2 := setupWorkspaceViewMocks(t, factory)
911+ view2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-x": pv2})
912+
913+ app := &App{
914+ workspaceViews: map[entity.TabID]*component.WorkspaceView{"tab-1": view1, "tab-2": view2},
915+ browserWindows: map[string]*browserWindow{"win-1": bw1, "win-2": bw2},
916+ lastFocusedWindowID: "win-1",
917+ }
918+
919+ enterVimMode(t, app, overlay1, bw1)
920+ kh := bindVimModeKeyboardHandler(t, app, bw1)
921+
922+ app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-x"), true)
923+
924+ assert.Equal(t, input.ModeVim, kh.Mode())
925+ assert.Equal(t, entity.PaneID("pane-a"), bw1.vimModePaneID)
926+ assert.True(t, pv1.IsVimMode())
927+}
928+
929+func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) {
930+ f := newSingleWindowVimModeFixture(t)
931+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
932+ kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
933+
934+ expectVimModeAccentHidden(f.overlay1A)
935+ f.app.handleVimModeFocusTrigger(context.Background(), f.bw1, usecase.VimModePolicyTriggerOmniboxFocus)
936+
937+ assert.Equal(t, input.ModeNormal, kh.Mode())
938+ assert.Empty(t, f.bw1.vimModePaneID)
939+}
940+
941+func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) {
942+ f := newSingleWindowVimModeFixture(t)
943+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
944+ kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
945+
946+ expectVimModeAccentHidden(f.overlay1A)
947+ f.app.handleVimModeFocusTrigger(context.Background(), f.bw1, usecase.VimModePolicyTriggerFindBarFocus)
948+
949+ assert.Equal(t, input.ModeNormal, kh.Mode())
950+ assert.Empty(t, f.bw1.vimModePaneID)
951+}
952+
953+func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) {
954+ f := newSingleWindowVimModeFixture(t)
955+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
956+ kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
957+
958+ tab2 := &entity.Tab{ID: "tab-2b", Workspace: &entity.Workspace{
959+ ID: "ws-2b",
960+ ActivePaneID: "pane-c",
961+ Root: &entity.PaneNode{ID: "ws-2b-pane-c", Pane: entity.NewPane("pane-c")},
962+ }}
963+ f.bw1.tabs.Add(tab2)
964+ f.bw1.tabs.SetActive(tab2.ID)
965+ f.app.workspaceViews[tab2.ID] = setupWorkspaceViewMocks(t, f.factory)
966+
967+ expectVimModeAccentHidden(f.overlay1A)
968+ f.app.handleVimModeTabSwitch(context.Background(), f.bw1)
969+
970+ assert.Equal(t, input.ModeNormal, kh.Mode())
971+ assert.Empty(t, f.bw1.vimModePaneID)
972+ assert.False(t, f.pv1A.IsVimMode())
973+}
974+
975+func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) {
976+ f := newSingleWindowVimModeFixture(t)
977+ f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
978+ "pane-a": true,
979+ }
980+
981+ assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
982+}
983+
984+func TestVimMode_ActivationAvailabilityDoesNotDependOnEditableFocus(t *testing.T) {
985+ f := newSingleWindowVimModeFixture(t)
986+
987+ f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true)
988+ assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
989+
990+ f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), false)
991+ assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
992+}
993+
994+func TestVimMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) {
995+ f := newSingleWindowVimModeFixture(t)
996+ f.app.pageEditableFocusByPane = map[entity.PaneID]bool{"pane-a": true}
997+
998+ f.app.clearPageEditableFocusState(entity.PaneID("pane-a"))
999+
1000+ assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
1001+}
1002+
1003+// ============================================================================
1004+// CSS class constants
1005+// ============================================================================
1006+
1007+func TestVimMode_CSSClassConstants(t *testing.T) {
1008+ assert.Equal(t, "vim-mode-active", component.VimModeActiveClass)
1009+ assert.Equal(t, "vim-mode-pulse", component.VimModePulseClass)
1010+ assert.Equal(t, "vim-mode-pulse-fast", component.VimModeFastPulseClass)
1011+}
1012+
1013+// ============================================================================
1014+// Mode toaster
1015+// ============================================================================
1016+
1017+func newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) {
1018+ t.Helper()
1019+
1020+ container := mocks.NewMockBoxWidget(t)
1021+ label := mocks.NewMockLabelWidget(t)
1022+ factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once()
1023+ container.EXPECT().AddCssClass("toast").Once()
1024+ container.EXPECT().AddCssClass("toast-info").Once()
1025+ container.EXPECT().SetHalign(mock.Anything).Once()
1026+ container.EXPECT().SetValign(mock.Anything).Once()
1027+ container.EXPECT().SetHexpand(false).Once()
1028+ container.EXPECT().SetVexpand(false).Once()
1029+ container.EXPECT().SetCanTarget(false).Once()
1030+ container.EXPECT().SetCanFocus(false).Once()
1031+ container.EXPECT().SetVisible(false).Once()
1032+ factory.EXPECT().NewLabel("").Return(label).Once()
1033+ label.EXPECT().SetCanTarget(false).Once()
1034+ label.EXPECT().SetCanFocus(false).Once()
1035+ container.EXPECT().Append(label).Once()
1036+
1037+ return component.NewToaster(factory), container, label
1038+}
1039+
1040+func expectVimModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) {
1041+ container.EXPECT().RemoveCssClass("toast-info").Once()
1042+ container.EXPECT().AddCssClass("toast-vim-mode").Once()
1043+ container.EXPECT().SetHalign(mock.Anything).Once()
1044+ container.EXPECT().SetValign(mock.Anything).Once()
1045+ label.EXPECT().SetText("VIM MODE").Once()
1046+ container.EXPECT().SetVisible(true).Once()
1047+}
1048+
1049+func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1050+ factory := mocks.NewMockWidgetFactory(t)
1051+ toaster, container, label := newModeToasterForVimModeTest(t, factory)
1052+ expectVimModeToastShown(container, label)
1053+ // These optional calls are made only by the old brief auto-dismiss path.
1054+ container.EXPECT().RemoveCssClass("toast-vim-mode").Maybe()
1055+ container.EXPECT().SetVisible(false).Maybe()
1056+
1057+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1058+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1059+ ModeIndicatorToasterEnabled: true,
1060+ }}},
1061+ })}
1062+ bw := &browserWindow{id: "win-1", modeToaster: toaster}
1063+ app.browserWindows = map[string]*browserWindow{bw.id: bw}
1064+ app.lastFocusedWindowID = bw.id
1065+
1066+ app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1067+ require.True(t, toaster.IsVisible())
1068+
1069+ deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)
1070+ mainContext := glib.MainContextDefault()
1071+ for time.Now().Before(deadline) {
1072+ time.Sleep(10 * time.Millisecond)
1073+ for mainContext.Pending() {
1074+ mainContext.Iteration(false)
1075+ }
1076+ }
1077+
1078+ assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit")
1079+}
1080+
1081+func TestVimMode_ModeExitHidesToaster(t *testing.T) {
1082+ factory := mocks.NewMockWidgetFactory(t)
1083+ toaster, container, label := newModeToasterForVimModeTest(t, factory)
1084+ expectVimModeToastShown(container, label)
1085+ container.EXPECT().RemoveCssClass("toast-custom").Once()
1086+ container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1087+ container.EXPECT().SetVisible(false).Once()
1088+
1089+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1090+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1091+ ModeIndicatorToasterEnabled: true,
1092+ }}},
1093+ })}
1094+ bw := &browserWindow{id: "win-1", modeToaster: toaster}
1095+
1096+ app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1097+ require.True(t, toaster.IsVisible())
1098+ app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal)
1099+
1100+ assert.False(t, toaster.IsVisible(), "leaving Vim Mode must hide its persistent toast")
1101+}
1102+
1103+func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) {
1104+ factory := mocks.NewMockWidgetFactory(t)
1105+ toaster, _, _ := newModeToasterForVimModeTest(t, factory)
1106+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})}
1107+ bw := &browserWindow{id: "win-1", modeToaster: toaster}
1108+
1109+ app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1110+
1111+ assert.False(t, toaster.IsVisible())
1112+}
1113+
1114+func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {
1115+ factory := mocks.NewMockWidgetFactory(t)
1116+ toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory)
1117+ toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory)
1118+ expectVimModeToastShown(container1, label1)
1119+ expectVimModeToastShown(container2, label2)
1120+ container1.EXPECT().RemoveCssClass("toast-custom").Once()
1121+ container1.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1122+ container1.EXPECT().SetVisible(false).Once()
1123+
1124+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1125+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1126+ ModeIndicatorToasterEnabled: true,
1127+ }}},
1128+ })}
1129+ bw1 := &browserWindow{id: "win-1", modeToaster: toaster1}
1130+ bw2 := &browserWindow{id: "win-2", modeToaster: toaster2}
1131+
1132+ app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim)
1133+ app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim)
1134+ app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal)
1135+
1136+ assert.False(t, toaster1.IsVisible())
1137+ assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast")
1138+}
1139+
1140+func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) {
1141+ factory := mocks.NewMockWidgetFactory(t)
1142+ toaster, container, label := newModeToasterForVimModeTest(t, factory)
1143+ expectVimModeToastShown(container, label)
1144+ container.EXPECT().RemoveCssClass("toast-custom").Once()
1145+ container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1146+ container.EXPECT().SetVisible(false).Once()
1147+
1148+ enabled := entity.RuntimeConfigSnapshot{
1149+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1150+ ModeIndicatorToasterEnabled: true,
1151+ }}},
1152+ }
1153+ keyboardHandler := newKeyboardHandlerInVimMode(t)
1154+ bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1155+ app := &App{
1156+ runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabled),
1157+ browserWindows: map[string]*browserWindow{bw.id: bw},
1158+ }
1159+
1160+ app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1161+ require.True(t, toaster.IsVisible())
1162+ app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{})
1163+
1164+ assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast")
1165+}
1166+
1167+func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1168+ factory := mocks.NewMockWidgetFactory(t)
1169+ toaster, container, label := newModeToasterForVimModeTest(t, factory)
1170+ expectVimModeToastShown(container, label)
1171+
1172+ keyboardHandler := newKeyboardHandlerInVimMode(t)
1173+ bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1174+ app := &App{
1175+ runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}),
1176+ browserWindows: map[string]*browserWindow{bw.id: bw},
1177+ }
1178+ enabled := entity.RuntimeConfigSnapshot{
1179+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1180+ ModeIndicatorToasterEnabled: true,
1181+ }}},
1182+ }
1183+
1184+ app.applyRuntimeConfigChange(context.Background(), enabled)
1185+
1186+ assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")
1187+}
1188+
1189+// ============================================================================
1190+// BorderManager — Vim mode must NOT use the global border overlay
1191+// ============================================================================
1192+
1193+func TestVimMode_GlobalBorderOverlayStaysOff(t *testing.T) {
1194+ mockFactory := mocks.NewMockWidgetFactory(t)
1195+ mockBox := mocks.NewMockBoxWidget(t)
1196+
1197+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
1198+ mockBox.EXPECT().SetCanFocus(false).Once()
1199+ mockBox.EXPECT().SetCanTarget(false).Once()
1200+ mockBox.EXPECT().SetHexpand(true).Once()
1201+ mockBox.EXPECT().SetVexpand(true).Once()
1202+ mockBox.EXPECT().SetVisible(false).Once()
1203+
1204+ bm := focus.NewBorderManager(mockFactory)
1205+
1206+ mockBox.EXPECT().SetVisible(false).Once()
1207+ bm.OnModeChange(context.Background(), input.ModeNormal, input.ModeVim)
1208+
1209+ mockBox.EXPECT().SetVisible(false).Once()
1210+ bm.OnModeChange(context.Background(), input.ModeVim, input.ModeNormal)
1211+}
1212+
1213+// ============================================================================
1214+// Pulse Debounce
1215+// ============================================================================
1216+
1217+func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) {
1218+ f := newSingleWindowVimModeFixture(t)
1219+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
1220+
1221+ // Set up expectations for exactly ONE normal pulse.
1222+ setUpNormalPulse(f.overlay1A)
1223+
1224+ // First call fires the pulse (debounce timer is zero).
1225+ f.app.triggerVimModePulse(context.Background(), false)
1226+
1227+ // Second call within the same frame should be debounced — no
1228+ // mock expectations set up for it, so the test fails if it fires.
1229+ f.app.triggerVimModePulse(context.Background(), false)
1230+}
1231+
1232+func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
1233+ // Verify that the debounce timer starts at zero on a fresh App,
1234+ // so the first pulse after entering vim mode always fires.
1235+ // The exit-and-re-enter path is covered by the
1236+ // TestVimMode_Pulse_DebounceResetOnClearOwnership test below.
1237+
1238+ f := newSingleWindowVimModeFixture(t)
1239+
1240+ // Fresh app: debounce timer is zero.
1241+ assert.True(t, f.app.vimModePulseLastTime.IsZero(),
1242+ "fresh app should have zero pulse timer")
1243+
1244+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
1245+
1246+ // Pulse #1 fires because timer is zero (time.Since(zero) is huge).
1247+ setUpNormalPulse(f.overlay1A)
1248+ f.app.triggerVimModePulse(context.Background(), false)
1249+
1250+ // Timer is now set, proving the first pulse registered.
1251+ assert.False(t, f.app.vimModePulseLastTime.IsZero(),
1252+ "pulse timer should be non-zero after first pulse")
1253+}
1254+
1255+func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
1256+ // Verify that clearVimModeOwnership resets the debounce timer
1257+ // so a subsequent pulse in a new vim mode session is not skipped.
1258+ // We check the timer state directly rather than triggering a second
1259+ // pulse (which would need pulse-cycle alternation handling).
1260+
1261+ f := newSingleWindowVimModeFixture(t)
1262+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
1263+
1264+ // Pulse #1 fires and sets the timer.
1265+ setUpNormalPulse(f.overlay1A)
1266+ f.app.triggerVimModePulse(context.Background(), false)
1267+ assert.False(t, f.app.vimModePulseLastTime.IsZero(),
1268+ "pulse timer should be non-zero after first pulse")
1269+
1270+ // Clear ownership — this resets the debounce timer.
1271+ expectVimModeAccentHidden(f.overlay1A)
1272+ f.app.clearVimModeOwnership(context.Background(), f.bw1)
1273+ assert.Empty(t, f.bw1.vimModePaneID)
1274+
1275+ // Debounce timer should now be back to zero.
1276+ assert.True(t, f.app.vimModePulseLastTime.IsZero(),
1277+ "pulse timer should be reset after clearVimModeOwnership")
1278+}
1279+
1280+func TestVimMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) {
1281+ // Even if triggerVimModePulse is called rapidly on a fresh
1282+ // App (e.g., first keystroke after vim mode entry), the first
1283+ // call must always fire. This is guaranteed by the zero value
1284+ // of time.Time producing a time.Since result much larger than
1285+ // the debounce interval.
1286+ f := newSingleWindowVimModeFixture(t)
1287+ enterVimMode(t, f.app, f.overlay1A, f.bw1)
1288+
1289+ // Rapid double pulse — only first should fire regardless of order.
1290+ // Set up just one set of expectations.
1291+ setUpNormalPulse(f.overlay1A)
1292+ f.app.triggerVimModePulse(context.Background(), false)
1293+ f.app.triggerVimModePulse(context.Background(), false)
1294+}
1295+
1296+// ============================================================================
1297+// UI Scale: Vim mode CSS uses relative (em) units
1298+// ============================================================================
1299+
1300+func TestVimMode_CSSUsesEms(t *testing.T) {
1301+ css := theme.GenerateCSS(theme.DefaultDarkPalette())
1302+
1303+ assert.Contains(t, css, "box-shadow: inset 0 0 0 0.125em",
1304+ "vim mode overlay box-shadow should use em for scaling")
1305+ assert.Contains(t, css, "0.2em",
1306+ "vim mode overlay pulse glow radius uses em")
1307+ assert.Contains(t, css, "0.26em",
1308+ "vim mode overlay fast-pulse glow radius uses em")
1309+ assert.NotContains(t, css, "vim-mode-indicator",
1310+ "removed PAGE label styling must not remain")
1311+}
1312+
1313+func TestVimMode_CSSPulseTimingUsesDefaultTransitionDuration(t *testing.T) {
1314+ // The pulse animation durations should be derived from the
1315+ // default transition duration (normal = 3x, fast = 6x).
1316+ // Default = 120ms → normal = 360ms, fast = 720ms.
1317+ css := theme.GenerateCSS(theme.DefaultDarkPalette())
1318+
1319+ assert.Contains(t, css, "360ms",
1320+ "normal pulse duration should be 3x transition duration (360ms for 120ms base)")
1321+ assert.Contains(t, css, "720ms",
1322+ "fast pulse duration should be 6x transition duration (720ms for 120ms base)")
1323+}
1324+
1325+func TestVimMode_CSSWithCustomTransitionDuration(t *testing.T) {
1326+ // Custom transition duration (e.g., 200ms) should produce
1327+ // adjusted pulse timings: normal = 600ms, fast = 1200ms.
1328+ css := theme.GenerateCSSFullWithTiming(
1329+ theme.DefaultDarkPalette(),
1330+ 1.0,
1331+ theme.DefaultFontConfig(),
1332+ theme.DefaultModeColors(),
1333+ 200,
1334+ )
1335+
1336+ assert.Contains(t, css, "600ms",
1337+ "normal pulse should be 3x custom transition duration (200ms -> 600ms)")
1338+ assert.Contains(t, css, "1200ms",
1339+ "fast pulse should be 6x custom transition duration (200ms -> 1200ms)")
1340+}
M internal/ui/browser_window.go +215 -19
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/application/port"
1010 "github.com/bnema/dumber/internal/application/usecase"
1111 "github.com/bnema/dumber/internal/domain/entity"
12+ domainurl "github.com/bnema/dumber/internal/domain/url"
1213 "github.com/bnema/dumber/internal/logging"
1314 "github.com/bnema/dumber/internal/shared/syncdispatch"
1415 "github.com/bnema/dumber/internal/ui/component"
@@ -52,6 +53,13 @@ type browserWindow struct {
5253 historySidebarReloader historySidebarReloader
5354 sidebarVisible bool
5455 activeSidebarKind nativeSidebarKind
56+
57+ // vimModePaneID tracks this window's pane-local Vim Mode accent/pulse owner.
58+ vimModePaneID entity.PaneID
59+ // vimNavigationHighlightedWebViews holds every WebView with a semantic Vim
60+ // navigation target that must be cleared when this window leaves Vim Mode.
61+ vimNavigationHighlightedWebViews []port.WebView
62+ vimNavigationHighlightedWebViewIDs map[port.WebViewID]struct{}
5563 }
5664
5765 func (bw *browserWindow) detachInputForDestroy() {
@@ -108,6 +116,9 @@ func (bw *browserWindow) clearShellState() {
108116 bw.favoritesSidebar = nil
109117 bw.historySidebarReloader = nil
110118 bw.activeSidebarKind = nativeSidebarNone
119+ bw.vimModePaneID = ""
120+ bw.vimNavigationHighlightedWebViews = nil
121+ bw.vimNavigationHighlightedWebViewIDs = nil
111122 }
112123
113124 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
@@ -544,11 +555,9 @@ func (a *App) deterministicBrowserWindowFallback() *browserWindow {
544555 return a.browserWindows[ids[0]]
545556 }
546557
547-func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
558+func (a *App) dispatchWindowURLWork(ctx context.Context, label, url string, work func(context.Context) error) error {
548559 log := logging.FromContext(ctx)
549- log.Debug().
550- Str("url_host", logging.SafeURLHost(url)).
551- Msg("ui: open fresh window dispatch requested")
560+ log.Debug().Str("url_host", logging.SafeURLHost(url)).Str("dispatch_label", label).Msg("ui: window URL dispatch requested")
552561
553562 dispatch := a.dispatchOnMainThread
554563 if dispatch == nil {
@@ -560,47 +569,234 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
560569 }
561570 }
562571
563- var openErr error
564- var windowCountBefore int
565- var windowCountAfter int
566- var hasTabCoord bool
567- var hasTabsUC bool
568- result := dispatch("ui.open_fresh_window", func() {
572+ var workErr error
573+ var windowCountBefore, windowCountAfter int
574+ var hasTabCoord, hasTabsUC bool
575+ result := dispatch(label, func() {
569576 windowCountBefore = len(a.browserWindows)
570577 hasTabCoord = a.tabCoord != nil
571578 hasTabsUC = a.tabsUC != nil
572579 log.Debug().
573580 Str("url_host", logging.SafeURLHost(url)).
581+ Str("dispatch_label", label).
574582 Int("window_count_before", windowCountBefore).
575583 Bool("has_tab_coord", hasTabCoord).
576584 Bool("has_tabs_uc", hasTabsUC).
577- Msg("ui: open fresh window main-thread work started")
578- openErr = a.openFreshWindow(ctx, url)
585+ Msg("ui: window URL main-thread work started")
586+ workErr = work(ctx)
579587 windowCountAfter = len(a.browserWindows)
580588 })
581589 if !result.Completed() {
582590 log.Warn().
583591 Str("url_host", logging.SafeURLHost(url)).
592+ Str("dispatch_label", label).
584593 Dur("elapsed", result.Elapsed).
585594 Str("dispatch_status", string(result.Status)).
586- Msg("ui: open fresh window skipped after main-thread dispatch did not complete")
595+ Msg("ui: window URL skipped after main-thread dispatch did not complete")
587596 return fmt.Errorf("main thread dispatch did not complete: %s", result.Status)
588597 }
589- if openErr != nil {
590- log.Warn().Err(openErr).
598+ if workErr != nil {
599+ log.Warn().Err(workErr).
591600 Str("url_host", logging.SafeURLHost(url)).
601+ Str("dispatch_label", label).
592602 Dur("elapsed", result.Elapsed).
593603 Str("dispatch_status", string(result.Status)).
594604 Int("window_count_after", windowCountAfter).
595- Msg("ui: open fresh window failed")
596- return openErr
605+ Msg("ui: window URL operation failed")
606+ return workErr
597607 }
598-
599608 log.Debug().
600609 Str("url_host", logging.SafeURLHost(url)).
610+ Str("dispatch_label", label).
601611 Dur("elapsed", result.Elapsed).
602612 Str("dispatch_status", string(result.Status)).
603613 Int("window_count_after", windowCountAfter).
604- Msg("ui: open fresh window completed")
614+ Msg("ui: window URL operation completed")
615+ return nil
616+}
617+
618+func (a *App) OpenExternalURL(ctx context.Context, url string) error {
619+ url = domainurl.Normalize(url)
620+ return a.dispatchWindowURLWork(ctx, "ui.open_external_url", url, func(ctx context.Context) error {
621+ return a.openExternalURLOnMainThread(ctx, url, a.runtimeConfigSnapshot())
622+ })
623+}
624+
625+func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
626+ url = domainurl.Normalize(url)
627+ return a.dispatchWindowURLWork(ctx, "ui.open_fresh_window", url, func(ctx context.Context) error {
628+ return a.openFreshWindow(ctx, url)
629+ })
630+}
631+
632+func (a *App) openExternalURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error {
633+ externalLinks := cfg.UI.Workspace.ExternalLinks
634+ if externalLinks.Behavior == entity.ExternalLinkBehaviorWindowed || externalLinks.Behavior == "" {
635+ return a.openFreshWindow(ctx, url)
636+ }
637+
638+ err := a.openExternalURLInFocusedWindow(ctx, url, externalLinks)
639+ if err == nil {
640+ return nil
641+ }
642+ logging.FromContext(ctx).Warn().
643+ Err(err).
644+ Str("url_host", logging.SafeURLHost(url)).
645+ Str("behavior", string(externalLinks.Behavior)).
646+ Msg("ui: external URL placement failed; opening fresh window")
647+ return a.openFreshWindow(ctx, url)
648+}
649+
650+func (a *App) openExternalURLInFocusedWindow(ctx context.Context, url string, cfg entity.ExternalLinksConfig) error {
651+ target, activeTab, err := a.externalURLTarget()
652+ if err != nil {
653+ return err
654+ }
655+
656+ switch cfg.Behavior {
657+ case entity.ExternalLinkBehaviorTabbed:
658+ if err := a.createExternalURLTab(ctx, target, url); err != nil {
659+ return err
660+ }
661+ case entity.ExternalLinkBehaviorSplit:
662+ if err := a.placeExternalURLInPane(ctx, activeTab.Workspace, url, func(ctx context.Context) error {
663+ return a.wsCoord.SplitWithURLWithoutOmnibox(ctx, externalLinkSplitDirection(cfg.Placement), url)
664+ }); err != nil {
665+ return err
666+ }
667+ case entity.ExternalLinkBehaviorStacked:
668+ if err := a.placeExternalURLInPane(ctx, activeTab.Workspace, url, func(ctx context.Context) error {
669+ return a.wsCoord.StackPaneWithURLWithoutOmnibox(ctx, url)
670+ }); err != nil {
671+ return err
672+ }
673+ default:
674+ return fmt.Errorf("unsupported external URL behavior %q", cfg.Behavior)
675+ }
676+
677+ if target.mainWindow != nil {
678+ target.mainWindow.Show()
679+ }
680+ a.activateBrowserWindow(target)
681+ return nil
682+}
683+
684+func (a *App) externalURLTarget() (*browserWindow, *entity.Tab, error) {
685+ target := a.lastFocusedBrowserWindow()
686+ if target == nil || target.id == "" || a.browserWindows[target.id] != target {
687+ return nil, nil, fmt.Errorf("last-focused browser window is missing or stale")
688+ }
689+ activeTab := a.activeTabForBrowserWindow(target)
690+ if activeTab == nil || activeTab.Workspace == nil || activeTab.Workspace.ActivePane() == nil {
691+ return nil, nil, fmt.Errorf("last-focused browser window has no active tab and pane")
692+ }
693+ return target, activeTab, nil
694+}
695+
696+func (a *App) createExternalURLTab(ctx context.Context, target *browserWindow, url string) error {
697+ if a.tabCoord == nil {
698+ return fmt.Errorf("tab coordinator not available")
699+ }
700+ created, err := a.tabCoord.Create(ctx, a.tabTargetForBrowserWindow(target), url)
701+ if err != nil {
702+ a.rollbackExternalTabCreation(ctx, target, created)
703+ return err
704+ }
705+ if created == nil || created.Workspace == nil || created.Workspace.ActivePane() == nil ||
706+ created.Workspace.ActivePane().Pane == nil || created.Workspace.ActivePane().Pane.URI != url ||
707+ a.activeTabForBrowserWindow(target) != created || a.workspaceViews[created.ID] == nil {
708+ a.rollbackExternalTabCreation(ctx, target, created)
709+ return fmt.Errorf("created tab did not become an active UI target")
710+ }
711+ return nil
712+}
713+
714+func (a *App) placeExternalURLInPane(ctx context.Context, ws *entity.Workspace, url string, place func(context.Context) error) error {
715+ if a.wsCoord == nil {
716+ return fmt.Errorf("workspace coordinator not available")
717+ }
718+ before := ws.ActivePaneID
719+ if err := place(ctx); err != nil {
720+ a.rollbackExternalPaneCreation(ctx, ws, before)
721+ return err
722+ }
723+ if err := verifyExternalPaneCreation(ws, before, url); err != nil {
724+ a.rollbackExternalPaneCreation(ctx, ws, before)
725+ return err
726+ }
727+ return nil
728+}
729+
730+func (a *App) rollbackExternalPaneCreation(ctx context.Context, ws *entity.Workspace, previous entity.PaneID) {
731+ if a.panesUC == nil {
732+ logging.FromContext(ctx).Warn().Msg("ui: cannot roll back external URL pane; panes use case is unavailable")
733+ return
734+ }
735+ if ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
736+ return
737+ }
738+ created := ws.ActivePane()
739+ if created == nil || created.Pane == nil {
740+ return
741+ }
742+ createdID := created.Pane.ID
743+ if _, err := a.panesUC.Close(ctx, ws, created); err != nil {
744+ logging.FromContext(ctx).Warn().Err(err).Msg("ui: failed to roll back external URL pane")
745+ return
746+ }
747+ ws.ActivePaneID = previous
748+ if a.contentCoord != nil {
749+ a.contentCoord.ReleaseWebView(ctx, createdID)
750+ }
751+ for tabID, view := range a.workspaceViews {
752+ owner := a.browserWindowForTab(tabID)
753+ if owner == nil || owner.tabs == nil || view == nil {
754+ continue
755+ }
756+ tab := owner.tabs.Find(tabID)
757+ if tab == nil || tab.Workspace == nil || tab.Workspace.ID != ws.ID {
758+ continue
759+ }
760+ if err := view.Rebuild(ctx); err != nil {
761+ logging.FromContext(ctx).Warn().Err(err).Msg("ui: failed to rebuild after external URL pane rollback")
762+ }
763+ break
764+ }
765+}
766+
767+func (a *App) rollbackExternalTabCreation(ctx context.Context, target *browserWindow, created *entity.Tab) {
768+ if target == nil || created == nil {
769+ return
770+ }
771+ if tabs := a.tabListForBrowserWindow(target); tabs != nil {
772+ tabs.Remove(created.ID)
773+ }
774+ if target.mainWindow != nil && target.mainWindow.TabBar() != nil {
775+ target.mainWindow.TabBar().RemoveTab(created.ID)
776+ }
777+ a.releaseTabWorkspace(ctx, created)
778+}
779+
780+func externalLinkSplitDirection(placement entity.ExternalLinkPlacement) usecase.SplitDirection {
781+ switch placement {
782+ case entity.ExternalLinkPlacementLeft:
783+ return usecase.SplitLeft
784+ case entity.ExternalLinkPlacementTop:
785+ return usecase.SplitUp
786+ case entity.ExternalLinkPlacementBottom:
787+ return usecase.SplitDown
788+ default:
789+ return usecase.SplitRight
790+ }
791+}
792+
793+func verifyExternalPaneCreation(ws *entity.Workspace, previous entity.PaneID, url string) error {
794+ if ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
795+ return fmt.Errorf("external URL pane was not activated")
796+ }
797+ active := ws.ActivePane()
798+ if active == nil || active.Pane == nil || domainurl.Normalize(active.Pane.URI) != domainurl.Normalize(url) {
799+ return fmt.Errorf("external URL pane was not created with the requested URL")
800+ }
605801 return nil
606802 }
M internal/ui/browser_window_favorites_sidebar.go +9 -2
@@ -119,7 +119,8 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
119119 if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {
120120 return fmt.Errorf("favorites unavailable: usecase not configured")
121121 }
122- _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
122+ bw := a.lastFocusedBrowserWindow()
123+ _, wv := a.activeWebViewForBrowserWindow(bw)
123124 if wv == nil {
124125 return fmt.Errorf("favorites unavailable: no active webview")
125126 }
@@ -127,10 +128,16 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
127128 if uri == "" {
128129 return fmt.Errorf("favorites unavailable: active page has no URI")
129130 }
130- _, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
131+ result, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
131132 if err != nil {
132133 return err
133134 }
135+ if result != nil && strings.TrimSpace(result.Message) != "" {
136+ a.showToastOnBrowserWindow(ctx, bw, result.Message, component.ToastSuccess,
137+ component.WithDuration(component.ToastBriefDurationMs),
138+ component.WithPosition(component.ToastPositionBottomRight),
139+ )
140+ }
134141 a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle")
135142 return nil
136143 }
M internal/ui/browser_window_test.go +11 -11
@@ -447,15 +447,15 @@ func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testi
447447 }
448448 }
449449
450-func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
450+func TestOpenExternalURL_DispatchesAndTracksBrowserWindow(t *testing.T) {
451451 app := &App{browserWindows: make(map[string]*browserWindow)}
452452
453453 dispatched := false
454454 createdURL := ""
455455 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
456456 dispatched = true
457- if label != "ui.open_fresh_window" {
458- t.Fatalf("dispatch label = %q, want ui.open_fresh_window", label)
457+ if label != "ui.open_external_url" {
458+ t.Fatalf("dispatch label = %q, want ui.open_external_url", label)
459459 }
460460 fn()
461461 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
@@ -465,11 +465,11 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
465465 return &browserWindow{id: "window-1", initialURL: url}, nil
466466 }
467467
468- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
469- t.Fatalf("OpenFreshWindow returned error: %v", err)
468+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
469+ t.Fatalf("OpenExternalURL returned error: %v", err)
470470 }
471471 if !dispatched {
472- t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher")
472+ t.Fatalf("OpenExternalURL did not use the main-thread dispatcher")
473473 }
474474 if createdURL != "https://example.com" {
475475 t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
@@ -515,7 +515,7 @@ func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T)
515515 require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)
516516 }
517517
518-func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
518+func TestOpenExternalURL_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
519519 app := &App{
520520 tabs: entity.NewTabList(),
521521 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
@@ -535,8 +535,8 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *
535535 return true
536536 }
537537
538- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
539- t.Fatalf("OpenFreshWindow returned error: %v", err)
538+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
539+ t.Fatalf("OpenExternalURL returned error: %v", err)
540540 }
541541
542542 windowTabs := app.browserWindows["window-1"].tabs
@@ -553,7 +553,7 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *
553553 require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
554554 }
555555
556-func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
556+func TestOpenExternalURL_PropagatesFactoryError(t *testing.T) {
557557 app := &App{browserWindows: make(map[string]*browserWindow)}
558558 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
559559 fn()
@@ -564,7 +564,7 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
564564 return nil, wantErr
565565 }
566566
567- err := app.OpenFreshWindow(context.Background(), "https://example.com")
567+ err := app.OpenExternalURL(context.Background(), "https://example.com")
568568
569569 if !errors.Is(err, wantErr) {
570570 t.Fatalf("expected error %v, got %v", wantErr, err)
M internal/ui/component/favorites_sidebar.go +148 -40
@@ -13,8 +13,6 @@ import (
1313 "github.com/bnema/dumber/internal/domain/entity"
1414 )
1515
16-const favoritesSidebarOuterSpacing = 6
17-
1816 // FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar.
1917 type FavoritesSidebarConfig struct {
2018 FavoritesUC port.FavoritesSidebarFavorites
@@ -26,22 +24,27 @@ type FavoritesSidebarConfig struct {
2624
2725 // FavoritesSidebar is a read-only GTK sidebar component for browsing favorites.
2826 type FavoritesSidebar struct {
29- outerBox *gtk.Box
30- searchEntry *gtk.SearchEntry
31- tagBox *gtk.Box
32- scrolledWin *gtk.ScrolledWindow
33- listBox *gtk.ListBox
34- formBox *gtk.Box
27+ outerBox *gtk.Box
28+ searchEntry *gtk.SearchEntry
29+ tagBox *gtk.Box
30+ tagControls []*gtk.Button
31+ tagPromptBox *gtk.Box
32+ tagNameEntry *gtk.Entry
33+ tagPromptSaveBtn *gtk.Button
34+ scrolledWin *gtk.ScrolledWindow
35+ listBox *gtk.ListBox
36+ formBox *gtk.Box
3537
3638 formURLEntry *gtk.SearchEntry
3739 formTitleEntry *gtk.SearchEntry
38- formTagsEntry *gtk.SearchEntry
40+ formTagSearch *gtk.SearchEntry
41+ formTagMatches *gtk.Box
3942 formShortcutEntry *gtk.SearchEntry
4043 formSaveButton *gtk.Button
4144 formURL string
4245 formTitle string
43- formTags string
4446 formShortcut string
47+ formTagIDs map[entity.TagID]struct{}
4548
4649 favoritesUC port.FavoritesSidebarFavorites
4750 onNavigate func(ctx context.Context, url string) error
@@ -60,16 +63,20 @@ type FavoritesSidebar struct {
6063 destroyed bool
6164 visible bool
6265 focusZone favoritesSidebarFocusZone
66+ textInputWidget *gtk.Widget
6367 mode favoritesSidebarMode
6468 editingID entity.FavoriteID
6569 confirmDelete bool
6670 confirmDeleteID entity.FavoriteID
6771
68- retainedCallbacks []any
69- tagCallbacks []any
70- ctx context.Context
71- cancel context.CancelFunc
72- mu sync.RWMutex
72+ retainedCallbacks []any
73+ tagCallbacks []any
74+ tagPromptCallbacks []any
75+ formCallbacks []any
76+ formTagMatchCallbacks []any
77+ ctx context.Context
78+ cancel context.CancelFunc
79+ mu sync.RWMutex
7380
7481 idleScheduler func(glib.SourceFunc)
7582 }
@@ -157,33 +164,12 @@ func (fs *FavoritesSidebar) Destroy() {
157164 }
158165
159166 func (fs *FavoritesSidebar) createWidgets() error {
160- fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, favoritesSidebarOuterSpacing)
161- if fs.outerBox == nil {
162- return fmt.Errorf("favorites sidebar: outer box creation failed")
167+ if err := fs.createOuterAndSearch(); err != nil {
168+ return err
163169 }
164- fs.outerBox.AddCssClass("favorites-sidebar-outer")
165- fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
166- fs.outerBox.SetHexpand(false)
167- fs.outerBox.SetVexpand(true)
168- fs.outerBox.SetVisible(false)
169-
170- fs.searchEntry = gtk.NewSearchEntry()
171- if fs.searchEntry == nil {
172- return fmt.Errorf("favorites sidebar: search entry creation failed")
170+ if err := fs.createTagControls(); err != nil {
171+ return err
173172 }
174- fs.searchEntry.AddCssClass("favorites-sidebar-search")
175- fs.searchEntry.SetHexpand(true)
176- placeholder := "Search favorites"
177- fs.searchEntry.SetPlaceholderText(&placeholder)
178- fs.outerBox.Append(&fs.searchEntry.Widget)
179-
180- fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
181- if fs.tagBox == nil {
182- return fmt.Errorf("favorites sidebar: tag box creation failed")
183- }
184- fs.tagBox.AddCssClass("favorites-sidebar-tags")
185- fs.tagBox.SetHexpand(true)
186- fs.outerBox.Append(&fs.tagBox.Widget)
187173
188174 fs.scrolledWin = gtk.NewScrolledWindow()
189175 if fs.scrolledWin == nil {
@@ -192,12 +178,15 @@ func (fs *FavoritesSidebar) createWidgets() error {
192178 fs.scrolledWin.SetVexpand(true)
193179 fs.scrolledWin.SetHexpand(true)
194180 fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
181+ fs.scrolledWin.AddCssClass("sidebar-list")
195182
196183 fs.listBox = gtk.NewListBox()
197184 if fs.listBox == nil {
198185 return fmt.Errorf("favorites sidebar: list box creation failed")
199186 }
187+ fs.listBox.AddCssClass("sidebar-list")
200188 fs.listBox.AddCssClass("favorites-sidebar-list")
189+ fs.trackListFocus(&fs.listBox.Widget)
201190 fs.listBox.SetActivateOnSingleClick(true)
202191 fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
203192 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
@@ -220,6 +209,125 @@ func (fs *FavoritesSidebar) createWidgets() error {
220209 return nil
221210 }
222211
212+func (fs *FavoritesSidebar) createOuterAndSearch() error {
213+ fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
214+ if fs.outerBox == nil {
215+ return fmt.Errorf("favorites sidebar: outer box creation failed")
216+ }
217+ fs.outerBox.AddCssClass("sidebar-outer")
218+ fs.outerBox.AddCssClass("favorites-sidebar-outer")
219+ fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
220+ fs.outerBox.SetHexpand(false)
221+ fs.outerBox.SetVexpand(true)
222+ fs.outerBox.SetVisible(false)
223+
224+ searchBox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
225+ if searchBox == nil {
226+ return fmt.Errorf("favorites sidebar: search box creation failed")
227+ }
228+ searchBox.AddCssClass("sidebar-search-box")
229+ searchBox.SetHexpand(true)
230+ fs.searchEntry = gtk.NewSearchEntry()
231+ if fs.searchEntry == nil {
232+ return fmt.Errorf("favorites sidebar: search entry creation failed")
233+ }
234+ fs.searchEntry.AddCssClass("sidebar-search")
235+ fs.searchEntry.AddCssClass("favorites-sidebar-search")
236+ fs.trackTextInputFocus(&fs.searchEntry.Widget, &fs.retainedCallbacks)
237+ fs.searchEntry.SetHexpand(true)
238+ placeholder := "Search favorites..."
239+ fs.searchEntry.SetPlaceholderText(&placeholder)
240+ searchBox.Append(&fs.searchEntry.Widget)
241+ fs.outerBox.Append(&searchBox.Widget)
242+ return nil
243+}
244+
245+func (fs *FavoritesSidebar) createTagControls() error {
246+ fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
247+ if fs.tagBox == nil {
248+ return fmt.Errorf("favorites sidebar: tag box creation failed")
249+ }
250+ fs.tagBox.AddCssClass("favorites-sidebar-tags")
251+ fs.tagBox.SetHexpand(true)
252+ fs.outerBox.Append(&fs.tagBox.Widget)
253+ fs.tagPromptBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
254+ if fs.tagPromptBox == nil {
255+ return fmt.Errorf("favorites sidebar: tag prompt creation failed")
256+ }
257+ fs.tagPromptBox.AddCssClass("favorites-sidebar-tag-prompt")
258+ fs.tagPromptBox.SetVisible(false)
259+ fs.outerBox.Append(&fs.tagPromptBox.Widget)
260+ return nil
261+}
262+
263+func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget, callbacks *[]any) {
264+ if fs == nil || widget == nil || callbacks == nil {
265+ return
266+ }
267+ controller := gtk.NewEventControllerFocus()
268+ if controller == nil {
269+ return
270+ }
271+ entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) }
272+ left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) }
273+ *callbacks = append(*callbacks, entered, left)
274+ controller.ConnectEnter(&entered)
275+ controller.ConnectLeave(&left)
276+ widget.AddController(&controller.EventController)
277+}
278+
279+func (fs *FavoritesSidebar) setTextInputFocused(widget *gtk.Widget) {
280+ if fs == nil || widget == nil {
281+ return
282+ }
283+ fs.mu.Lock()
284+ if !fs.destroyed {
285+ fs.textInputWidget = widget
286+ if fs.searchEntry != nil && widget == &fs.searchEntry.Widget {
287+ fs.focusZone = favoritesSidebarFocusSearch
288+ } else {
289+ fs.focusZone = favoritesSidebarFocusForm
290+ }
291+ }
292+ fs.mu.Unlock()
293+}
294+
295+func (fs *FavoritesSidebar) trackListFocus(widget *gtk.Widget) {
296+ if fs == nil || widget == nil {
297+ return
298+ }
299+ controller := gtk.NewEventControllerFocus()
300+ if controller == nil {
301+ return
302+ }
303+ entered := func(gtk.EventControllerFocus) { fs.setFocusZone(favoritesSidebarFocusList) }
304+ fs.retainedCallbacks = append(fs.retainedCallbacks, entered)
305+ controller.ConnectEnter(&entered)
306+ widget.AddController(&controller.EventController)
307+}
308+
309+func (fs *FavoritesSidebar) setFocusZone(zone favoritesSidebarFocusZone) {
310+ if fs == nil {
311+ return
312+ }
313+ fs.mu.Lock()
314+ if !fs.destroyed {
315+ fs.focusZone = zone
316+ }
317+ fs.mu.Unlock()
318+}
319+
320+func (fs *FavoritesSidebar) clearTextInputFocus(widget *gtk.Widget) {
321+ if fs == nil || widget == nil {
322+ return
323+ }
324+ fs.mu.Lock()
325+ if !fs.destroyed && fs.textInputWidget == widget {
326+ fs.textInputWidget = nil
327+ }
328+ fs.mu.Unlock()
329+}
330+
223331 func (fs *FavoritesSidebar) focusSearch() {
224332 if fs == nil {
225333 return
A internal/ui/component/favorites_sidebar_form_tags.go +146 -0
@@ -0,0 +1,146 @@
1+package component
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func favoriteTags(fav *entity.Favorite) []entity.Tag {
12+ if fav == nil {
13+ return nil
14+ }
15+ return fav.Tags
16+}
17+
18+func tagIDSet(tags []entity.Tag) map[entity.TagID]struct{} {
19+ ids := make(map[entity.TagID]struct{}, len(tags))
20+ for _, tag := range tags {
21+ ids[tag.ID] = struct{}{}
22+ }
23+ return ids
24+}
25+
26+func (fs *FavoritesSidebar) setupFormTagSearch() {
27+ if fs == nil || fs.formTagSearch == nil {
28+ return
29+ }
30+ changed := func(_ gtk.SearchEntry) { fs.renderFormTagMatches(fs.formTagSearch.GetText()) }
31+ fs.formCallbacks = append(fs.formCallbacks, changed)
32+ fs.formTagSearch.ConnectSearchChanged(&changed)
33+}
34+
35+func formTagCandidates(tags []*entity.Tag, selected map[entity.TagID]struct{}, query string) []*entity.Tag {
36+ query = strings.ToLower(strings.TrimSpace(query))
37+ matches := make([]*entity.Tag, 0, len(tags))
38+ for _, tag := range tags {
39+ if tag == nil {
40+ continue
41+ }
42+ if query == "" {
43+ if _, ok := selected[tag.ID]; ok {
44+ matches = append(matches, tag)
45+ }
46+ continue
47+ }
48+ if strings.Contains(strings.ToLower(tag.Name), query) {
49+ matches = append(matches, tag)
50+ }
51+ }
52+ return matches
53+}
54+
55+func (fs *FavoritesSidebar) renderFormTagMatches(query string) {
56+ if fs == nil || fs.formTagMatches == nil {
57+ return
58+ }
59+ fs.mu.RLock()
60+ tags := append([]*entity.Tag(nil), fs.allTags...)
61+ selected := make(map[entity.TagID]struct{}, len(fs.formTagIDs))
62+ for id := range fs.formTagIDs {
63+ selected[id] = struct{}{}
64+ }
65+ fs.mu.RUnlock()
66+
67+ clearBoxChildren(fs.formTagMatches)
68+ callbacks := make([]any, 0)
69+ for _, tag := range formTagCandidates(tags, selected, query) {
70+ t := *tag
71+ label := t.Name
72+ if _, ok := selected[t.ID]; ok {
73+ label = "✓ " + label
74+ }
75+ button := gtk.NewButtonWithLabel(label)
76+ if button == nil {
77+ continue
78+ }
79+ button.AddCssClass("favorites-sidebar-form-tag-match")
80+ callback := func(_ gtk.Button) { fs.toggleFormTag(t.ID) }
81+ callbacks = append(callbacks, callback)
82+ button.ConnectClicked(&callback)
83+ fs.formTagMatches.Append(&button.Widget)
84+ }
85+ fs.mu.Lock()
86+ if !fs.destroyed {
87+ fs.formTagMatchCallbacks = callbacks
88+ }
89+ fs.mu.Unlock()
90+}
91+
92+func (fs *FavoritesSidebar) toggleFormTag(tagID entity.TagID) {
93+ if fs == nil {
94+ return
95+ }
96+ fs.mu.Lock()
97+ if fs.destroyed {
98+ fs.mu.Unlock()
99+ return
100+ }
101+ if fs.formTagIDs == nil {
102+ fs.formTagIDs = make(map[entity.TagID]struct{})
103+ }
104+ if _, ok := fs.formTagIDs[tagID]; ok {
105+ delete(fs.formTagIDs, tagID)
106+ } else {
107+ fs.formTagIDs[tagID] = struct{}{}
108+ }
109+ fs.mu.Unlock()
110+ query := ""
111+ if fs.formTagSearch != nil {
112+ query = fs.formTagSearch.GetText()
113+ }
114+ fs.renderFormTagMatches(query)
115+}
116+
117+func (fs *FavoritesSidebar) formTagIDsSnapshot() []entity.TagID {
118+ if fs == nil {
119+ return nil
120+ }
121+ fs.mu.RLock()
122+ defer fs.mu.RUnlock()
123+ ids := make([]entity.TagID, 0, len(fs.formTagIDs))
124+ for id := range fs.formTagIDs {
125+ ids = append(ids, id)
126+ }
127+ return ids
128+}
129+
130+func (fs *FavoritesSidebar) updateFavoriteTags(favoriteID entity.FavoriteID, wanted []entity.TagID) bool {
131+ if fs == nil {
132+ return false
133+ }
134+ fs.mu.RLock()
135+ uc, ctx := fs.favoritesUC, fs.ctx
136+ fs.mu.RUnlock()
137+ if uc == nil {
138+ return false
139+ }
140+ if err := uc.UpdateFavoriteTags(ctx, favoriteID, wanted); err != nil {
141+ fs.startLoad()
142+ fs.setNotice(err.Error())
143+ return false
144+ }
145+ return true
146+}
M internal/ui/component/favorites_sidebar_forms.go +34 -94
@@ -18,7 +18,8 @@ const (
1818 favoritesSidebarModeNone favoritesSidebarMode = iota
1919 favoritesSidebarModeAdd
2020 favoritesSidebarModeEdit
21- favoritesSidebarModeTag
21+ favoritesSidebarModeCreateTag
22+ favoritesSidebarModeBindTag
2223 favoritesSidebarModeShortcut
2324 )
2425
@@ -42,7 +43,7 @@ func (fs *FavoritesSidebar) beginAddForm() {
4243 fs.editingID = 0
4344 fs.confirmDelete = false
4445 fs.confirmDeleteID = 0
45- fs.setNoticeLocked("Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save.")
46+ fs.setNoticeLocked("Add favorite: URL, title, tags, shortcut 1-9. Press Ctrl+Enter or Save.")
4647 fs.mu.Unlock()
4748 fs.renderForm(nil)
4849 fs.rebuildList()
@@ -63,7 +64,7 @@ func (fs *FavoritesSidebar) beginEditForm() {
6364 fs.editingID = fav.ID
6465 fs.confirmDelete = false
6566 fs.confirmDeleteID = 0
66- fs.setNoticeLocked("Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save.")
67+ fs.setNoticeLocked("Edit favorite: URL is read-only; use + on the selected favorite to manage tags. Press Ctrl+Enter or Save.")
6768 fs.mu.Unlock()
6869 fs.renderForm(fav)
6970 fs.rebuildList()
@@ -74,20 +75,23 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
7475 return
7576 }
7677 clearBoxChildren(fs.formBox)
78+ fs.formCallbacks = nil
79+ fs.formTagMatchCallbacks = nil
7780 fs.formURLEntry = gtk.NewSearchEntry()
7881 fs.formTitleEntry = gtk.NewSearchEntry()
79- fs.formTagsEntry = nil
80- if fav == nil {
81- fs.formTagsEntry = gtk.NewSearchEntry()
82- }
82+ fs.formTagSearch = gtk.NewSearchEntry()
83+ fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3)
8384 fs.formShortcutEntry = gtk.NewSearchEntry()
85+ fs.mu.Lock()
86+ fs.formTagIDs = tagIDSet(favoriteTags(fav))
87+ fs.mu.Unlock()
8488 entries := []struct {
8589 entry *gtk.SearchEntry
8690 label string
8791 }{
8892 {entry: fs.formURLEntry, label: "URL"},
8993 {entry: fs.formTitleEntry, label: "Title"},
90- {entry: fs.formTagsEntry, label: "Tag IDs"},
94+ {entry: fs.formTagSearch, label: "Add tags..."},
9195 {entry: fs.formShortcutEntry, label: "Shortcut"},
9296 }
9397 for _, item := range entries {
@@ -96,21 +100,26 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
96100 }
97101 placeholder := item.label
98102 item.entry.SetPlaceholderText(&placeholder)
103+ fs.trackTextInputFocus(&item.entry.Widget, &fs.formCallbacks)
99104 fs.formBox.Append(&item.entry.Widget)
100105 }
106+ if fs.formTagMatches != nil {
107+ fs.formTagMatches.AddCssClass("favorites-sidebar-form-tag-matches")
108+ fs.formBox.Append(&fs.formTagMatches.Widget)
109+ }
110+ fs.setupFormTagSearch()
101111 fs.formSaveButton = gtk.NewButtonWithLabel("Save")
102112 if fs.formSaveButton != nil {
103113 cb := func(_ gtk.Button) {
104114 fs.submitForm()
105115 }
106- fs.retainedCallbacks = append(fs.retainedCallbacks, cb)
116+ fs.formCallbacks = append(fs.formCallbacks, cb)
107117 fs.formSaveButton.ConnectClicked(&cb)
108118 fs.formBox.Append(&fs.formSaveButton.Widget)
109119 }
110120 if fav != nil {
111121 fs.formURL = fav.URL
112122 fs.formTitle = fav.Title
113- fs.formTags = joinFavoriteTagIDs(fav.Tags)
114123 fs.formShortcut = ""
115124 if fav.ShortcutKey != nil {
116125 fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)
@@ -122,15 +131,13 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
122131 if fs.formTitleEntry != nil {
123132 fs.formTitleEntry.SetText(fs.formTitle)
124133 }
125- if fs.formTagsEntry != nil {
126- fs.formTagsEntry.SetText(fs.formTags)
127- }
128134 if fs.formShortcutEntry != nil {
129135 fs.formShortcutEntry.SetText(fs.formShortcut)
130136 }
131137 } else {
132- fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", ""
138+ fs.formURL, fs.formTitle, fs.formShortcut = "", "", ""
133139 }
140+ fs.renderFormTagMatches("")
134141 fs.formBox.SetVisible(true)
135142 fs.focusForm()
136143 }
@@ -176,6 +183,11 @@ func (fs *FavoritesSidebar) cancelManagement() bool {
176183 if fs.formBox != nil {
177184 fs.formBox.SetVisible(false)
178185 clearBoxChildren(fs.formBox)
186+ fs.formCallbacks = nil
187+ fs.formTagMatchCallbacks = nil
188+ }
189+ if fs.tagPromptBox != nil {
190+ fs.hideTagPrompt()
179191 }
180192 fs.rebuildList()
181193 return active
@@ -194,13 +206,9 @@ func (fs *FavoritesSidebar) submitForm() bool {
194206 if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {
195207 return false
196208 }
197- url, title, tagsText, shortcutText := fs.formValues()
209+ url, title, shortcutText := fs.formValues()
210+ tags := fs.formTagIDsSnapshot()
198211 if mode == favoritesSidebarModeAdd {
199- tags, err := parseTagIDs(tagsText)
200- if err != nil {
201- fs.setNotice(err.Error())
202- return true
203- }
204212 key, err := parseShortcut(shortcutText)
205213 if err != nil {
206214 fs.setNotice(err.Error())
@@ -231,15 +239,15 @@ func (fs *FavoritesSidebar) submitForm() bool {
231239 fs.setNotice(err.Error())
232240 return true
233241 }
242+ if !fs.updateFavoriteTags(id, tags) {
243+ return true
244+ }
234245 }
235246 fs.cancelManagement()
236247 fs.startLoad()
237248 return true
238249 }
239250
240-func (fs *FavoritesSidebar) enterTagMode() {
241- fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite")
242-}
243251 func (fs *FavoritesSidebar) enterShortcutMode() {
244252 fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")
245253 }
@@ -256,43 +264,6 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri
256264 fs.rebuildList()
257265 }
258266
259-func (fs *FavoritesSidebar) toggleFavoriteTagByOrdinal(ord int) bool {
260- fav := fs.selectedFavorite()
261- if fav == nil || ord < 1 {
262- fs.setNotice("Select a favorite and tag 1-9")
263- return true
264- }
265- fs.mu.RLock()
266- if ord > len(fs.allTags) || fs.favoritesUC == nil {
267- fs.mu.RUnlock()
268- fs.setNotice("No tag for key")
269- return true
270- }
271- tagID := fs.allTags[ord-1].ID
272- uc := fs.favoritesUC
273- ctx := fs.ctx
274- fs.mu.RUnlock()
275- has := false
276- for _, tag := range fav.Tags {
277- if tag.ID == tagID {
278- has = true
279- break
280- }
281- }
282- var err error
283- if has {
284- err = uc.UntagFavorite(ctx, fav.ID, tagID)
285- } else {
286- err = uc.TagFavorite(ctx, fav.ID, tagID)
287- }
288- if err != nil {
289- fs.setNotice(err.Error())
290- return true
291- }
292- fs.startLoad()
293- return true
294-}
295-
296267 func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {
297268 fav := fs.selectedFavorite()
298269 if fav == nil {
@@ -402,41 +373,18 @@ func (fs *FavoritesSidebar) setNotice(notice string) {
402373 fs.rebuildList()
403374 }
404375
405-func (fs *FavoritesSidebar) formValues() (string, string, string, string) {
406- url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut
376+func (fs *FavoritesSidebar) formValues() (string, string, string) {
377+ url, title, shortcut := fs.formURL, fs.formTitle, fs.formShortcut
407378 if fs.formURLEntry != nil {
408379 url = fs.formURLEntry.GetText()
409380 }
410381 if fs.formTitleEntry != nil {
411382 title = fs.formTitleEntry.GetText()
412383 }
413- if fs.formTagsEntry != nil {
414- tagsText = fs.formTagsEntry.GetText()
415- }
416384 if fs.formShortcutEntry != nil {
417385 shortcut = fs.formShortcutEntry.GetText()
418386 }
419- return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut)
420-}
421-
422-func parseTagIDs(text string) ([]entity.TagID, error) {
423- if text == "" {
424- return nil, nil
425- }
426- parts := strings.Split(text, ",")
427- ids := make([]entity.TagID, 0, len(parts))
428- for _, part := range parts {
429- trimmed := strings.TrimSpace(part)
430- if trimmed == "" {
431- return nil, fmt.Errorf("invalid tag ID %q", part)
432- }
433- v, err := strconv.Atoi(trimmed)
434- if err != nil || v <= 0 {
435- return nil, fmt.Errorf("invalid tag ID %q", trimmed)
436- }
437- ids = append(ids, entity.TagID(v))
438- }
439- return ids, nil
387+ return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(shortcut)
440388 }
441389
442390 func parseShortcut(text string) (*int, error) {
@@ -450,11 +398,3 @@ func parseShortcut(text string) (*int, error) {
450398 }
451399 return &v, nil
452400 }
453-
454-func joinFavoriteTagIDs(tags []entity.Tag) string {
455- parts := make([]string, 0, len(tags))
456- for _, tag := range tags {
457- parts = append(parts, fmt.Sprint(tag.ID))
458- }
459- return strings.Join(parts, ",")
460-}
M internal/ui/component/favorites_sidebar_keyboard.go +150 -17
@@ -17,6 +17,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
1717 if keyController == nil {
1818 return
1919 }
20+ // Directional controls must run before GtkSearchEntry and GtkListBox consume
21+ // arrows. Explicit text-input focus tracking below still defers printable
22+ // keys to their focused entry.
2023 keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
2124 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
2225 fs.mu.RLock()
@@ -29,6 +32,18 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
2932 fs.mu.RUnlock()
3033
3134 textEditing := fs.inTextEditContext()
35+ if fs.isTextInputFocused(fs.tagNameEntry) &&
36+ (keyval == uint(gdk.KEY_Return) || keyval == uint(gdk.KEY_KP_Enter)) {
37+ return fs.createTagFromFocusedEntry()
38+ }
39+ if keyval == uint(gdk.KEY_Up) || keyval == uint(gdk.KEY_Down) {
40+ if fs.routeDirectionalFocus(keyval) {
41+ return true
42+ }
43+ }
44+ if shouldDeferToTextInput(textEditing, keyval) {
45+ return false
46+ }
3247 switch keyval {
3348 case uint(gdk.KEY_Escape):
3449 if fs.cancelManagement() {
@@ -43,6 +58,12 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
4358 }
4459 return true
4560 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
61+ if fs.createTagFromFocusedEntry() {
62+ return true
63+ }
64+ if fs.focusedTagControlIndex() >= 0 {
65+ return false
66+ }
4667 return fs.handleReturnKey(state)
4768 case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
4869 fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
@@ -89,6 +110,11 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
89110 }
90111 fs.selectAdjacentRow(1)
91112 return true
113+ case uint(gdk.KEY_plus), uint(gdk.KEY_KP_Add):
114+ if !textEditing && fs.listHasFocus() {
115+ return fs.showTagBindingPicker()
116+ }
117+ return false
92118 case uint(gdk.KEY_slash):
93119 if textEditing {
94120 return false
@@ -279,21 +305,97 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) {
279305
280306 func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
281307
282-func (fs *FavoritesSidebar) inTextEditContext() bool {
308+// shouldDeferToTextInput keeps the sidebar controller from treating text typed
309+// into a descendant entry as a global sidebar command.
310+// Tab traverses focus and Escape cancels sidebar management while text is focused.
311+func shouldDeferToTextInput(textEditing bool, keyval uint) bool {
312+ return textEditing && keyval != uint(gdk.KEY_Tab) &&
313+ keyval != uint(gdk.KEY_ISO_Left_Tab) && keyval != uint(gdk.KEY_Escape)
314+}
315+
316+func (fs *FavoritesSidebar) routeDirectionalFocus(keyval uint) bool {
283317 if fs == nil {
284318 return false
285319 }
286- if fs.searchEntry != nil && fs.searchEntry.HasFocus() {
320+ fs.mu.RLock()
321+ zone := fs.focusZone
322+ fs.mu.RUnlock()
323+ switch {
324+ case zone == favoritesSidebarFocusTags:
325+ // Tag filters and creation are deliberately Tab-only controls.
287326 return true
288- }
289- for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} {
290- if entry != nil && entry.HasFocus() {
291- return true
327+ case keyval == uint(gdk.KEY_Down) && zone == favoritesSidebarFocusSearch:
328+ fs.focusListAndSelectFirst()
329+ return true
330+ case keyval == uint(gdk.KEY_Up) && zone == favoritesSidebarFocusList && fs.selectedRowIsFirst():
331+ fs.setFocusZone(favoritesSidebarFocusSearch)
332+ if fs.searchEntry != nil {
333+ fs.searchEntry.GrabFocus()
292334 }
335+ return true
293336 }
294337 return false
295338 }
296339
340+func (fs *FavoritesSidebar) focusListAndSelectFirst() {
341+ if fs == nil {
342+ return
343+ }
344+ fs.setFocusZone(favoritesSidebarFocusList)
345+ if fs.listBox == nil {
346+ return
347+ }
348+ fs.listBox.GrabFocus()
349+ fs.mu.RLock()
350+ index := firstSelectableIndex(fs.displayRows)
351+ fs.mu.RUnlock()
352+ fs.selectIndex(index)
353+}
354+
355+func (fs *FavoritesSidebar) selectedRowIsFirst() bool {
356+ if fs == nil {
357+ return false
358+ }
359+ if fs.listBox == nil {
360+ return true
361+ }
362+ row := fs.listBox.GetSelectedRow()
363+ if row == nil {
364+ return true
365+ }
366+ fs.mu.RLock()
367+ first := firstSelectableIndex(fs.displayRows)
368+ fs.mu.RUnlock()
369+ return row.GetIndex() == first
370+}
371+
372+func (fs *FavoritesSidebar) listHasFocus() bool {
373+ if fs == nil {
374+ return false
375+ }
376+ fs.mu.RLock()
377+ defer fs.mu.RUnlock()
378+ return fs.focusZone == favoritesSidebarFocusList
379+}
380+
381+func (fs *FavoritesSidebar) inTextEditContext() bool {
382+ if fs == nil {
383+ return false
384+ }
385+ fs.mu.RLock()
386+ defer fs.mu.RUnlock()
387+ return fs.textInputWidget != nil
388+}
389+
390+func (fs *FavoritesSidebar) isTextInputFocused(widget *gtk.Entry) bool {
391+ if fs == nil || widget == nil {
392+ return false
393+ }
394+ fs.mu.RLock()
395+ defer fs.mu.RUnlock()
396+ return fs.textInputWidget == &widget.Widget
397+}
398+
297399 func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
298400 shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
299401 if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
@@ -303,9 +405,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
303405 mode := fs.mode
304406 confirm := fs.confirmDelete
305407 fs.mu.RUnlock()
306- if mode == favoritesSidebarModeTag && keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
307- return fs.toggleFavoriteTagByOrdinal(int(keyval - uint(gdk.KEY_0)))
308- }
309408 if mode == favoritesSidebarModeShortcut {
310409 if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
311410 v := int(keyval - uint(gdk.KEY_0))
@@ -328,9 +427,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
328427 case uint(gdk.KEY_e):
329428 fs.beginEditForm()
330429 return true
331- case uint(gdk.KEY_t):
332- fs.enterTagMode()
333- return true
334430 case uint(gdk.KEY_s):
335431 fs.enterShortcutMode()
336432 return true
@@ -366,13 +462,21 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
366462 if fs == nil {
367463 return
368464 }
369- zones := fs.availableFocusZones()
370- if len(zones) == 0 {
465+ if fs.cycleTagControlFocus(reverse) {
371466 return
372467 }
373468 fs.mu.RLock()
374469 current := fs.focusZone
470+ tagCount := len(fs.tagControls)
375471 fs.mu.RUnlock()
472+ if reverse && current == favoritesSidebarFocusList && tagCount > 0 {
473+ fs.focusTagControl(tagCount - 1)
474+ return
475+ }
476+ zones := fs.availableFocusZones()
477+ if len(zones) == 0 {
478+ return
479+ }
376480 idx := -1
377481 for i, zone := range zones {
378482 if zone == current {
@@ -394,6 +498,37 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
394498 fs.focusZoneWidget(zones[idx])
395499 }
396500
501+func (fs *FavoritesSidebar) cycleTagControlFocus(reverse bool) bool {
502+ fs.mu.RLock()
503+ inTags := fs.focusZone == favoritesSidebarFocusTags
504+ controls := append([]*gtk.Button(nil), fs.tagControls...)
505+ fs.mu.RUnlock()
506+ if !inTags || len(controls) == 0 {
507+ return false
508+ }
509+ index := fs.focusedTagControlIndex()
510+ if index < 0 {
511+ if reverse {
512+ fs.focusTagControl(len(controls) - 1)
513+ } else {
514+ fs.focusTagControl(0)
515+ }
516+ return true
517+ }
518+ if reverse {
519+ if index > 0 {
520+ fs.focusTagControl(index - 1)
521+ return true
522+ }
523+ return false
524+ }
525+ if index < len(controls)-1 {
526+ fs.focusTagControl(index + 1)
527+ return true
528+ }
529+ return false
530+}
531+
397532 func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
398533 zones := []favoritesSidebarFocusZone{}
399534 if fs.searchEntry != nil {
@@ -428,9 +563,7 @@ func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
428563 case favoritesSidebarFocusSearch:
429564 fs.focusSearch()
430565 case favoritesSidebarFocusTags:
431- if fs.tagBox != nil {
432- fs.tagBox.GrabFocus()
433- }
566+ fs.focusTagControl(0)
434567 case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
435568 if fs.listBox != nil {
436569 fs.listBox.GrabFocus()
M internal/ui/component/favorites_sidebar_rows.go +74 -49
@@ -72,70 +72,90 @@ func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction in
7272 }
7373
7474 func (fs *FavoritesSidebar) renderTags() {
75- if fs == nil || fs.tagBox == nil {
75+ tagBox, tags, selectedTagIDs, ok := fs.tagRenderState()
76+ if !ok {
7677 return
7778 }
79+ clearBoxChildren(tagBox)
80+ callbacks := make([]any, 0, len(tags)+2)
81+ controls := make([]*gtk.Button, 0, len(tags)+2)
82+
83+ fs.appendTagFilter(tagBox, "All", len(selectedTagIDs) == 0, fs.clearTagFilters, &callbacks, &controls)
84+ for _, tag := range tags {
85+ if tag == nil {
86+ continue
87+ }
88+ t := tag
89+ _, selected := selectedTagIDs[t.ID]
90+ fs.appendTagFilter(tagBox, t.Name, selected, func() { fs.toggleTag(t.ID) }, &callbacks, &controls)
91+ }
92+ fs.appendTagFilter(tagBox, "+", false, fs.showCreateTagPrompt, &callbacks, &controls)
93+ if len(controls) > 0 {
94+ controls[len(controls)-1].AddCssClass("favorites-sidebar-tag-add")
95+ }
96+ fs.storeTagControls(callbacks, controls)
97+}
98+
99+func (fs *FavoritesSidebar) tagRenderState() (*gtk.Box, []*entity.Tag, map[entity.TagID]struct{}, bool) {
100+ if fs == nil || fs.tagBox == nil {
101+ return nil, nil, nil, false
102+ }
78103 fs.mu.RLock()
104+ defer fs.mu.RUnlock()
79105 if fs.destroyed {
80- fs.mu.RUnlock()
81- return
106+ return nil, nil, nil, false
82107 }
83- tags := append([]*entity.Tag(nil), fs.allTags...)
84- selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
108+ selected := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
85109 for id := range fs.selectedTagIDs {
86- selectedTagIDs[id] = struct{}{}
110+ selected[id] = struct{}{}
87111 }
88- tagBox := fs.tagBox
89- fs.mu.RUnlock()
90-
91- clearBoxChildren(tagBox)
92- callbacks := make([]any, 0, len(tags)+1)
112+ return fs.tagBox, append([]*entity.Tag(nil), fs.allTags...), selected, true
113+}
93114
94- allButton := gtk.NewButtonWithLabel("All")
95- if allButton != nil {
96- if len(selectedTagIDs) == 0 {
97- allButton.AddCssClass("suggested-action")
98- }
99- cb := func(_ gtk.Button) {
100- fs.mu.Lock()
101- if fs.destroyed {
102- fs.mu.Unlock()
103- return
104- }
105- fs.selectedTagIDs = make(map[entity.TagID]struct{})
106- fs.rebuildDisplayRowsLocked()
107- fs.mu.Unlock()
108- fs.renderTags()
109- fs.rebuildList()
110- }
111- callbacks = append(callbacks, cb)
112- allButton.ConnectClicked(&cb)
113- tagBox.Append(&allButton.Widget)
115+func (fs *FavoritesSidebar) appendTagFilter(
116+ box *gtk.Box,
117+ label string,
118+ selected bool,
119+ action func(),
120+ callbacks *[]any,
121+ controls *[]*gtk.Button,
122+) {
123+ button := gtk.NewButtonWithLabel(label)
124+ if button == nil {
125+ return
114126 }
127+ button.AddCssClass("favorites-sidebar-tag-filter")
128+ if selected {
129+ button.AddCssClass("favorites-sidebar-tag-filter-active")
130+ }
131+ callback := func(_ gtk.Button) { action() }
132+ *callbacks = append(*callbacks, callback)
133+ button.ConnectClicked(&callback)
134+ box.Append(&button.Widget)
135+ *controls = append(*controls, button)
136+}
115137
116- for _, tag := range tags {
117- if tag == nil {
118- continue
119- }
120- t := tag
121- button := gtk.NewButtonWithLabel(t.Name)
122- if button == nil {
123- continue
124- }
125- if _, ok := selectedTagIDs[t.ID]; ok {
126- button.AddCssClass("suggested-action")
127- }
128- cb := func(_ gtk.Button) {
129- fs.toggleTag(t.ID)
130- }
131- callbacks = append(callbacks, cb)
132- button.ConnectClicked(&cb)
133- tagBox.Append(&button.Widget)
138+func (fs *FavoritesSidebar) clearTagFilters() {
139+ if fs == nil {
140+ return
141+ }
142+ fs.mu.Lock()
143+ if fs.destroyed {
144+ fs.mu.Unlock()
145+ return
134146 }
147+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
148+ fs.rebuildDisplayRowsLocked()
149+ fs.mu.Unlock()
150+ fs.renderTags()
151+ fs.rebuildList()
152+}
135153
154+func (fs *FavoritesSidebar) storeTagControls(callbacks []any, controls []*gtk.Button) {
136155 fs.mu.Lock()
137156 if !fs.destroyed {
138157 fs.tagCallbacks = callbacks
158+ fs.tagControls = controls
139159 }
140160 fs.mu.Unlock()
141161 }
@@ -212,6 +232,7 @@ func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
212232 if label == nil {
213233 return
214234 }
235+ label.AddCssClass("sidebar-empty")
215236 label.AddCssClass("favorites-sidebar-empty")
216237 label.SetXalign(0.0)
217238 row := gtk.NewListBoxRow()
@@ -243,6 +264,7 @@ func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow f
243264 if row == nil {
244265 return
245266 }
267+ row.AddCssClass("sidebar-row")
246268 row.AddCssClass("favorites-sidebar-row")
247269 row.SetSelectable(displayRow.Selectable)
248270 row.SetActivatable(displayRow.Selectable)
@@ -257,6 +279,7 @@ func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) {
257279 if title == nil {
258280 return
259281 }
282+ title.AddCssClass("sidebar-row-title")
260283 title.AddCssClass("favorites-sidebar-row-title")
261284 title.SetText(safeSidebarString(fav.Title, fav.URL))
262285 title.SetXalign(0.0)
@@ -273,6 +296,7 @@ func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) {
273296 sub.SetHexpand(true)
274297 url := gtk.NewLabel(nil)
275298 if url != nil {
299+ url.AddCssClass("sidebar-row-subtitle")
276300 url.AddCssClass("favorites-sidebar-row-subtitle")
277301 url.SetText(readableURL(fav.URL))
278302 url.SetXalign(0.0)
@@ -299,6 +323,7 @@ func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) {
299323 if tags == nil {
300324 return
301325 }
326+ tags.AddCssClass("favorites-sidebar-row-tags")
302327 for _, tag := range favoriteTags {
303328 name := tag.Name
304329 label := gtk.NewLabel(&name)
A internal/ui/component/favorites_sidebar_tags.go +244 -0
@@ -0,0 +1,244 @@
1+package component
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func (fs *FavoritesSidebar) showCreateTagPrompt() {
12+ if fs == nil || fs.tagPromptBox == nil {
13+ return
14+ }
15+ fs.hideTagPrompt()
16+ entry := gtk.NewEntry()
17+ if entry == nil {
18+ return
19+ }
20+ placeholder := "New tag name"
21+ entry.SetPlaceholderText(&placeholder)
22+ entry.AddCssClass("favorites-sidebar-tag-name")
23+ callbacks := make([]any, 0, 4)
24+ fs.trackTextInputFocus(&entry.Widget, &callbacks)
25+ entry.SetHexpand(true)
26+ activateCb := func(gtk.Entry) { fs.submitTagName(entry.GetText()) }
27+ callbacks = append(callbacks, activateCb)
28+ entry.ConnectActivate(&activateCb)
29+ fs.storeTagPromptCallbacks(callbacks)
30+
31+ save := gtk.NewButtonWithLabel("Create")
32+ if save == nil {
33+ fs.hideTagPrompt()
34+ return
35+ }
36+ save.AddCssClass("favorites-sidebar-tag-create")
37+ cb := func(_ gtk.Button) { fs.createTag(entry.GetText()) }
38+ callbacks = append(callbacks, cb)
39+ save.ConnectClicked(&cb)
40+
41+ fs.tagNameEntry = entry
42+ fs.tagPromptSaveBtn = save
43+ fs.storeTagPromptCallbacks(callbacks)
44+ fs.tagPromptBox.Append(&entry.Widget)
45+ fs.tagPromptBox.Append(&save.Widget)
46+ fs.tagPromptBox.SetVisible(true)
47+ fs.mu.Lock()
48+ if !fs.destroyed {
49+ fs.mode = favoritesSidebarModeCreateTag
50+ }
51+ fs.mu.Unlock()
52+ entry.GrabFocus()
53+}
54+
55+func (fs *FavoritesSidebar) submitTagName(name string) bool {
56+ return fs.createTag(name)
57+}
58+
59+func (fs *FavoritesSidebar) createTag(name string) bool {
60+ if fs == nil {
61+ return false
62+ }
63+ name = strings.TrimSpace(name)
64+ if name == "" {
65+ fs.setNotice("Tag name is required")
66+ return true
67+ }
68+ fs.mu.RLock()
69+ uc, ctx := fs.favoritesUC, fs.ctx
70+ fs.mu.RUnlock()
71+ if uc == nil {
72+ return false
73+ }
74+ if _, err := uc.AddTag(ctx, name, ""); err != nil {
75+ fs.setNotice(err.Error())
76+ return true
77+ }
78+ fs.hideTagPrompt()
79+ fs.startLoad()
80+ return true
81+}
82+
83+func (fs *FavoritesSidebar) showTagBindingPicker() bool {
84+ if fs == nil || fs.tagPromptBox == nil {
85+ return false
86+ }
87+ fav := fs.selectedFavorite()
88+ if fav == nil {
89+ fs.setNotice("Select a favorite to manage tags")
90+ return true
91+ }
92+ fs.mu.RLock()
93+ tags := append([]*entity.Tag(nil), fs.allTags...)
94+ fs.mu.RUnlock()
95+ fs.clearTagPromptContent()
96+ callbacks := make([]any, 0, len(tags))
97+ for _, tag := range tags {
98+ if tag == nil {
99+ continue
100+ }
101+ t := *tag
102+ label := t.Name
103+ if favoriteHasTag(fav, t.ID) {
104+ label = "✓ " + label
105+ }
106+ button := gtk.NewButtonWithLabel(label)
107+ if button == nil {
108+ continue
109+ }
110+ button.AddCssClass("favorites-sidebar-tag-binding")
111+ cb := func(_ gtk.Button) { fs.toggleFavoriteTag(fav.ID, t.ID) }
112+ callbacks = append(callbacks, cb)
113+ button.ConnectClicked(&cb)
114+ fs.tagPromptBox.Append(&button.Widget)
115+ }
116+ fs.tagNameEntry = nil
117+ fs.tagPromptSaveBtn = nil
118+ fs.storeTagPromptCallbacks(callbacks)
119+ fs.tagPromptBox.SetVisible(true)
120+ fs.mu.Lock()
121+ if !fs.destroyed {
122+ fs.mode = favoritesSidebarModeBindTag
123+ }
124+ fs.mu.Unlock()
125+ return true
126+}
127+
128+func (fs *FavoritesSidebar) hideTagPrompt() {
129+ if fs == nil || fs.tagPromptBox == nil {
130+ return
131+ }
132+ fs.clearTagPromptContent()
133+ fs.tagPromptBox.SetVisible(false)
134+ fs.tagNameEntry = nil
135+ fs.tagPromptSaveBtn = nil
136+ fs.mu.Lock()
137+ if !fs.destroyed && (fs.mode == favoritesSidebarModeCreateTag || fs.mode == favoritesSidebarModeBindTag) {
138+ fs.mode = favoritesSidebarModeNone
139+ }
140+ fs.mu.Unlock()
141+}
142+
143+func (fs *FavoritesSidebar) clearTagPromptContent() {
144+ if fs == nil || fs.tagPromptBox == nil {
145+ return
146+ }
147+ clearBoxChildren(fs.tagPromptBox)
148+ fs.mu.Lock()
149+ fs.tagPromptCallbacks = nil
150+ fs.mu.Unlock()
151+}
152+
153+func (fs *FavoritesSidebar) storeTagPromptCallbacks(callbacks []any) {
154+ if fs == nil {
155+ return
156+ }
157+ fs.mu.Lock()
158+ if !fs.destroyed {
159+ fs.tagPromptCallbacks = callbacks
160+ }
161+ fs.mu.Unlock()
162+}
163+
164+func (fs *FavoritesSidebar) toggleFavoriteTag(favoriteID entity.FavoriteID, tagID entity.TagID) {
165+ fs.mu.RLock()
166+ uc, ctx := fs.favoritesUC, fs.ctx
167+ var has bool
168+ for _, fav := range fs.allFavorites {
169+ if fav == nil || fav.ID != favoriteID {
170+ continue
171+ }
172+ has = favoriteHasTag(fav, tagID)
173+ break
174+ }
175+ fs.mu.RUnlock()
176+ if uc == nil {
177+ return
178+ }
179+ var err error
180+ if has {
181+ err = uc.UntagFavorite(ctx, favoriteID, tagID)
182+ } else {
183+ err = uc.TagFavorite(ctx, favoriteID, tagID)
184+ }
185+ if err != nil {
186+ fs.setNotice(err.Error())
187+ return
188+ }
189+ fs.hideTagPrompt()
190+ fs.startLoad()
191+}
192+
193+func favoriteHasTag(fav *entity.Favorite, tagID entity.TagID) bool {
194+ if fav == nil {
195+ return false
196+ }
197+ for _, tag := range fav.Tags {
198+ if tag.ID == tagID {
199+ return true
200+ }
201+ }
202+ return false
203+}
204+
205+func (fs *FavoritesSidebar) focusTagControl(index int) {
206+ if fs == nil || index < 0 {
207+ return
208+ }
209+ fs.mu.RLock()
210+ controls := append([]*gtk.Button(nil), fs.tagControls...)
211+ fs.mu.RUnlock()
212+ if index >= len(controls) || controls[index] == nil {
213+ return
214+ }
215+ control := controls[index]
216+ fs.mu.Lock()
217+ if !fs.destroyed {
218+ fs.focusZone = favoritesSidebarFocusTags
219+ }
220+ fs.mu.Unlock()
221+ control.GrabFocus()
222+}
223+
224+func (fs *FavoritesSidebar) focusedTagControlIndex() int {
225+ if fs == nil {
226+ return -1
227+ }
228+ fs.mu.RLock()
229+ controls := append([]*gtk.Button(nil), fs.tagControls...)
230+ fs.mu.RUnlock()
231+ for index, control := range controls {
232+ if control != nil && control.HasFocus() {
233+ return index
234+ }
235+ }
236+ return -1
237+}
238+
239+func (fs *FavoritesSidebar) createTagFromFocusedEntry() bool {
240+ if fs == nil || fs.tagNameEntry == nil || !fs.tagNameEntry.HasFocus() {
241+ return false
242+ }
243+ return fs.createTag(fs.tagNameEntry.GetText())
244+}
M internal/ui/component/favorites_sidebar_test.go +230 -25
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/dumber/internal/domain/entity"
1212 "github.com/bnema/puregotk/v4/gdk"
1313 "github.com/bnema/puregotk/v4/glib"
14+ "github.com/bnema/puregotk/v4/gtk"
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/require"
1617 )
@@ -34,8 +35,15 @@ type fakeFavoritesSidebarUC struct {
3435 favID entity.FavoriteID
3536 tagID entity.TagID
3637 }
38+ createdTags []struct {
39+ name string
40+ color string
41+ }
3742 err error
3843 errOnSetShortcut error
44+ errOnTag map[entity.TagID]error
45+ errOnUntag map[entity.TagID]error
46+ failUntagOnCall int
3947 getAllCalled chan struct{}
4048 }
4149
@@ -51,6 +59,16 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er
5159 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
5260 return f.tags, f.err
5361 }
62+func (f *fakeFavoritesSidebarUC) AddTag(_ context.Context, name, color string) (*entity.Tag, error) {
63+ f.createdTags = append(f.createdTags, struct {
64+ name string
65+ color string
66+ }{name: name, color: color})
67+ if f.err != nil {
68+ return nil, f.err
69+ }
70+ return &entity.Tag{ID: entity.TagID(len(f.tags) + 1), Name: name, Color: color}, nil
71+}
5472 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
5573 f.addInputs = append(f.addInputs, input)
5674 if f.err != nil {
@@ -84,6 +102,9 @@ func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.Fav
84102 favID entity.FavoriteID
85103 tagID entity.TagID
86104 }{favID: favID, tagID: tagID})
105+ if err := f.errOnTag[tagID]; err != nil {
106+ return err
107+ }
87108 return f.err
88109 }
89110 func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
@@ -91,8 +112,45 @@ func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.F
91112 favID entity.FavoriteID
92113 tagID entity.TagID
93114 }{favID: favID, tagID: tagID})
115+ if err := f.errOnUntag[tagID]; err != nil {
116+ return err
117+ }
118+ if f.failUntagOnCall > 0 && len(f.untagged) == f.failUntagOnCall {
119+ return errors.New("untag failed")
120+ }
94121 return f.err
95122 }
123+func (f *fakeFavoritesSidebarUC) UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, wanted []entity.TagID) error {
124+ wantedSet := make(map[entity.TagID]struct{}, len(wanted))
125+ for _, id := range wanted {
126+ wantedSet[id] = struct{}{}
127+ }
128+ var current []entity.Tag
129+ for _, favorite := range f.favorites {
130+ if favorite != nil && favorite.ID == favID {
131+ current = favorite.Tags
132+ break
133+ }
134+ }
135+ currentSet := make(map[entity.TagID]struct{}, len(current))
136+ for _, tag := range current {
137+ currentSet[tag.ID] = struct{}{}
138+ if _, keep := wantedSet[tag.ID]; !keep {
139+ if err := f.UntagFavorite(ctx, favID, tag.ID); err != nil {
140+ return err
141+ }
142+ }
143+ }
144+ for _, id := range wanted {
145+ if _, exists := currentSet[id]; exists {
146+ continue
147+ }
148+ if err := f.TagFavorite(ctx, favID, id); err != nil {
149+ return err
150+ }
151+ }
152+ return nil
153+}
96154
97155 func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
98156 fs := &FavoritesSidebar{
@@ -340,20 +398,19 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
340398 fs.favoritesUC = uc
341399 fs.formURL = "https://add.test"
342400 fs.formTitle = "Added"
343- fs.formTags = "1, 2"
401+ fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}}
344402 fs.formShortcut = "3"
345403 fs.mode = favoritesSidebarModeAdd
346404
347405 assert.True(t, fs.submitForm())
348406 require.Len(t, uc.addInputs, 1)
349- assert.Equal(t, dto.FavoriteCreateInput{URL: "https://add.test", Title: "Added", Tags: []entity.TagID{1, 2}}, uc.addInputs[0])
407+ assert.ElementsMatch(t, []entity.TagID{1, 2}, uc.addInputs[0].Tags)
350408 require.Len(t, uc.shortcuts, 1)
351409 assert.Equal(t, 3, *uc.shortcuts[0].key)
352410
353411 fs.mode = favoritesSidebarModeEdit
354412 fs.editingID = 7
355413 fs.formTitle = "Edited"
356- fs.formTags = "999"
357414 fs.formShortcut = "4"
358415 assert.True(t, fs.submitForm())
359416 require.Len(t, uc.updateInputs, 1)
@@ -372,23 +429,15 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
372429 assert.Nil(t, uc.updateInputs[1].ShortcutKey)
373430 }
374431
375-func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
432+func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) {
376433 uc := &fakeFavoritesSidebarUC{}
377434 fs := newFavoritesSidebarHarness(nil, nil)
378435 fs.favoritesUC = uc
379436 fs.formURL = "https://add.test"
380437 fs.formTitle = "Added"
381- fs.formTags = "1, nope"
438+ fs.formShortcut = "10"
382439 fs.mode = favoritesSidebarModeAdd
383440
384- assert.True(t, fs.submitForm())
385- assert.Empty(t, uc.addInputs)
386- assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
387- assert.Contains(t, fs.notice, "invalid tag ID")
388- assert.Equal(t, "1, nope", fs.formTags)
389-
390- fs.formTags = "1"
391- fs.formShortcut = "10"
392441 assert.True(t, fs.submitForm())
393442 assert.Empty(t, uc.addInputs)
394443 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
@@ -396,6 +445,88 @@ func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
396445 assert.Equal(t, "10", fs.formShortcut)
397446 }
398447
448+func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T) {
449+ dev := &entity.Tag{ID: 10, Name: "dev"}
450+ news := &entity.Tag{ID: 11, Name: "news"}
451+ reading := &entity.Tag{ID: 12, Name: "reading"}
452+ selected := map[entity.TagID]struct{}{dev.ID: {}}
453+
454+ assert.Equal(t, []*entity.Tag{dev}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, ""))
455+ assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read"))
456+}
457+
458+func TestFavoritesSidebarReloadsAfterPartiallyApplyingTagChanges(t *testing.T) {
459+ first := entity.Tag{ID: 10, Name: "first"}
460+ second := entity.Tag{ID: 11, Name: "second"}
461+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{first, second}}
462+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
463+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
464+ uc.failUntagOnCall = 2
465+ fs.allFavorites = []*entity.Favorite{fav}
466+ fs.loadGen = 4
467+
468+ assert.False(t, fs.updateFavoriteTags(fav.ID, nil))
469+ assert.Len(t, uc.untagged, 2)
470+ assert.Equal(t, uint64(5), fs.loadGen, "a partial tag mutation must refresh stale favorites")
471+ assert.Equal(t, "untag failed", fs.notice)
472+}
473+
474+func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {
475+ dev := entity.Tag{ID: 10, Name: "dev"}
476+ news := entity.Tag{ID: 11, Name: "news"}
477+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
478+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
479+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
480+ fs.allFavorites = []*entity.Favorite{fav}
481+ fs.formTagIDs = tagIDSet(fav.Tags)
482+
483+ fs.toggleFormTag(news.ID)
484+ assert.ElementsMatch(t, []entity.TagID{dev.ID, news.ID}, fs.formTagIDsSnapshot())
485+ assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
486+ require.Len(t, uc.tagged, 1)
487+ assert.Equal(t, news.ID, uc.tagged[0].tagID)
488+ fav.Tags = []entity.Tag{dev, news}
489+
490+ fs.toggleFormTag(dev.ID)
491+ assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
492+ require.Len(t, uc.untagged, 1)
493+ assert.Equal(t, dev.ID, uc.untagged[0].tagID)
494+ assert.Len(t, uc.tagged, 1, "the synchronized persisted tags must not be added again")
495+}
496+
497+func TestFavoritesSidebarDirectionalFocusMovesDirectlyBetweenSearchAndFirstFavorite(t *testing.T) {
498+ fav := &entity.Favorite{ID: 1, URL: "https://go.dev"}
499+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
500+ if !gtk.InitCheck() {
501+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
502+ }
503+ listBox := gtk.NewListBox()
504+ require.NotNil(t, listBox)
505+ row := gtk.NewListBoxRow()
506+ require.NotNil(t, row)
507+ row.SetSelectable(true)
508+ listBox.Append(&row.Widget)
509+ fs.listBox = listBox
510+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
511+ fs.focusZone = favoritesSidebarFocusSearch
512+
513+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
514+ assert.Equal(t, favoritesSidebarFocusList, fs.focusZone)
515+ assert.Equal(t, row, fs.listBox.GetSelectedRow())
516+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
517+ assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone)
518+}
519+
520+func TestFavoritesSidebarDirectionalFocusConsumesArrowsOnTagControls(t *testing.T) {
521+ fs := newFavoritesSidebarHarness(nil, nil)
522+ fs.focusZone = favoritesSidebarFocusTags
523+
524+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
525+ assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
526+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
527+ assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
528+}
529+
399530 func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
400531 fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
401532 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
@@ -436,7 +567,6 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
436567 fs.mode = favoritesSidebarModeEdit
437568 fs.editingID = 7
438569 fs.formTitle = "Edited"
439- fs.formTags = "999"
440570
441571 assert.True(t, fs.submitForm())
442572 require.Len(t, uc.updateInputs, 1)
@@ -445,6 +575,90 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
445575 assert.Empty(t, uc.untagged)
446576 }
447577
578+func TestFavoritesSidebarCreateTagTrimsNameAndUsesApplicationBoundary(t *testing.T) {
579+ fs := newFavoritesSidebarHarness(nil, nil)
580+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
581+
582+ assert.True(t, fs.createTag(" reading "))
583+ require.Len(t, uc.createdTags, 1)
584+ assert.Equal(t, "reading", uc.createdTags[0].name)
585+ assert.Empty(t, uc.createdTags[0].color)
586+
587+ assert.True(t, fs.createTag(" \t "))
588+ assert.Len(t, uc.createdTags, 1)
589+ assert.Equal(t, "Tag name is required", fs.notice)
590+}
591+
592+func TestFavoritesSidebarTagNameSubmissionCreatesTag(t *testing.T) {
593+ fs := newFavoritesSidebarHarness(nil, nil)
594+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
595+
596+ assert.True(t, fs.submitTagName(" work "))
597+ require.Len(t, uc.createdTags, 1)
598+ assert.Equal(t, "work", uc.createdTags[0].name)
599+}
600+
601+func TestFavoritesSidebarTagBindingTogglesExistingTag(t *testing.T) {
602+ dev := entity.Tag{ID: 10, Name: "dev"}
603+ news := entity.Tag{ID: 11, Name: "news"}
604+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
605+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
606+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
607+ fs.allFavorites = []*entity.Favorite{fav}
608+
609+ fs.toggleFavoriteTag(fav.ID, dev.ID)
610+ require.Len(t, uc.untagged, 1)
611+ assert.Equal(t, dev.ID, uc.untagged[0].tagID)
612+ fs.toggleFavoriteTag(fav.ID, news.ID)
613+ require.Len(t, uc.tagged, 1)
614+ assert.Equal(t, news.ID, uc.tagged[0].tagID)
615+}
616+
617+func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testing.T) {
618+ fs := newFavoritesSidebarHarness(nil, nil)
619+ input := &gtk.Widget{}
620+
621+ fs.setTextInputFocused(input)
622+ assert.True(t, fs.inTextEditContext())
623+ assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
624+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
625+ fs.mode = favoritesSidebarModeAdd
626+ assert.True(t, fs.cancelManagement())
627+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
628+
629+ fs.clearTextInputFocus(input)
630+ assert.False(t, fs.inTextEditContext())
631+ assert.False(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
632+}
633+
634+func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) {
635+ for _, key := range []uint{
636+ uint(gdk.KEY_a),
637+ uint(gdk.KEY_e),
638+ uint(gdk.KEY_s),
639+ uint(gdk.KEY_r),
640+ uint(gdk.KEY_c),
641+ uint(gdk.KEY_t),
642+ uint(gdk.KEY_plus),
643+ uint(gdk.KEY_Up),
644+ uint(gdk.KEY_Return),
645+ } {
646+ assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key)
647+ }
648+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab)))
649+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab)))
650+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Escape)))
651+ assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e)))
652+}
653+
654+func TestFavoritesSidebarPlusAndTDoNotHijackTextInput(t *testing.T) {
655+ fs := newFavoritesSidebarHarness(nil, nil)
656+
657+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_t), 0))
658+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_plus), 0))
659+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
660+}
661+
448662 func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
449663 fs := newFavoritesSidebarHarness(nil, nil)
450664 fs.searchEntry = nil
@@ -505,23 +719,14 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
505719 assert.Contains(t, fs.notice, "failed to set shortcut")
506720 }
507721
508-func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
722+func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) {
509723 dev := entity.Tag{ID: 10, Name: "dev"}
510- news := entity.Tag{ID: 11, Name: "news"}
511724 fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
512- fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
725+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev})
513726 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
514727 fs.allTags = uc.tags
515728 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
516729
517- fs.mode = favoritesSidebarModeTag
518- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1), 0))
519- require.Len(t, uc.untagged, 1)
520- assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID)
521- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2), 0))
522- require.Len(t, uc.tagged, 1)
523- assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID)
524-
525730 fs.mode = favoritesSidebarModeShortcut
526731 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
527732 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0))
M internal/ui/component/history_sidebar_rendering.go +4 -0
@@ -103,6 +103,7 @@ func (hs *HistorySidebar) showEmptyState(listBox *gtk.ListBox, query string) {
103103 if label == nil {
104104 return
105105 }
106+ label.AddCssClass("sidebar-empty")
106107 label.AddCssClass("history-sidebar-empty")
107108
108109 if query != "" {
@@ -163,6 +164,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His
163164 if titleLabel == nil {
164165 return
165166 }
167+ titleLabel.AddCssClass("sidebar-row-title")
166168 titleLabel.AddCssClass("history-sidebar-row-title")
167169 titleLabel.SetText(safeSidebarString(entry.Title, entry.URL))
168170 titleLabel.SetXalign(0.0)
@@ -180,6 +182,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His
180182 if urlLabel == nil {
181183 return
182184 }
185+ urlLabel.AddCssClass("sidebar-row-subtitle")
183186 urlLabel.AddCssClass("history-sidebar-row-subtitle")
184187 urlLabel.SetText(readableURL(entry.URL))
185188 urlLabel.SetXalign(0.0)
@@ -205,6 +208,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His
205208 if row == nil {
206209 return
207210 }
211+ row.AddCssClass("sidebar-row")
208212 row.AddCssClass("history-sidebar-row")
209213 row.SetSelectable(true)
210214 row.SetActivatable(true)
M internal/ui/component/history_sidebar_widgets.go +5 -0
@@ -28,6 +28,7 @@ func (hs *HistorySidebar) initOuterBox() error {
2828 if hs.outerBox == nil {
2929 return fmt.Errorf("history sidebar: outer box creation failed")
3030 }
31+ hs.outerBox.AddCssClass("sidebar-outer")
3132 hs.outerBox.AddCssClass("history-sidebar-outer")
3233 hs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
3334 hs.outerBox.SetHexpand(false)
@@ -41,6 +42,7 @@ func (hs *HistorySidebar) initSearchBox() error {
4142 if hs.searchBox == nil {
4243 return fmt.Errorf("history sidebar: search box creation failed")
4344 }
45+ hs.searchBox.AddCssClass("sidebar-search-box")
4446 hs.searchBox.AddCssClass("history-sidebar-search-box")
4547 hs.searchBox.SetHexpand(true)
4648
@@ -48,6 +50,7 @@ func (hs *HistorySidebar) initSearchBox() error {
4850 if hs.searchEntry == nil {
4951 return fmt.Errorf("history sidebar: search entry creation failed")
5052 }
53+ hs.searchEntry.AddCssClass("sidebar-search")
5154 hs.searchEntry.AddCssClass("history-sidebar-search")
5255 hs.searchEntry.SetHexpand(true)
5356 placeholder := "Search history..."
@@ -66,12 +69,14 @@ func (hs *HistorySidebar) initListArea() error {
6669 hs.scrolledWin.SetVexpand(true)
6770 hs.scrolledWin.SetHexpand(true)
6871 hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
72+ hs.scrolledWin.AddCssClass("sidebar-list")
6973 hs.scrolledWin.AddCssClass("history-sidebar-groups")
7074
7175 hs.listBox = gtk.NewListBox()
7276 if hs.listBox == nil {
7377 return fmt.Errorf("history sidebar: list box creation failed")
7478 }
79+ hs.listBox.AddCssClass("sidebar-list")
7580 hs.listBox.AddCssClass("history-sidebar-groups")
7681 hs.listBox.SetActivateOnSingleClick(true)
7782 hs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
M internal/ui/component/omnibox_callback_pressure_stress_test.go +5 -1
@@ -8,6 +8,7 @@ import (
88 "time"
99
1010 "github.com/bnema/purego"
11+ "github.com/bnema/puregotk/v4/gdk"
1112 "github.com/bnema/puregotk/v4/glib"
1213 "github.com/bnema/puregotk/v4/gobject"
1314 "github.com/bnema/puregotk/v4/gtk"
@@ -32,7 +33,10 @@ func runGTKCallbackLifecycleStress(t *testing.T) {
3233 if !gtk.InitCheck() {
3334 t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
3435 }
35-
36+ display := gdk.DisplayGetDefault()
37+ if display == nil {
38+ t.Skip("GTK native display prerequisite unavailable (no default GDK display)")
39+ }
3640 mainContext := glib.MainContextDefault()
3741 require.NotNil(t, mainContext)
3842 done := make(chan struct{})
M internal/ui/component/omnibox_navigation_state_test.go +7 -7
@@ -73,7 +73,7 @@ func TestEffectiveSearchQuery(t *testing.T) {
7373 hasGhost bool
7474 want string
7575 }{
76- {name: "uses typed input when ghost visible", entryText: "dumber.bnema.dev", realInput: "dumb", hasGhost: true, want: "dumb"},
76+ {name: "uses typed input when ghost visible", entryText: "bnema.dev/dumber", realInput: "dumb", hasGhost: true, want: "dumb"},
7777 {name: "falls back to entry when no ghost", entryText: "dumb", realInput: "dumb", hasGhost: false, want: "dumb"},
7878 {name: "uses entry when real input unavailable", entryText: "dumb", realInput: "", hasGhost: true, want: "dumb"},
7979 }
@@ -136,8 +136,8 @@ func TestResolveTargetURLForSelection(t *testing.T) {
136136 {URL: "https://github.com/bnema/dumber/issues"},
137137 }
138138 favorites := []Favorite{
139- {URL: "https://dumber.bnema.dev"},
140- {URL: "https://docs.dumber.bnema.dev"},
139+ {URL: "https://bnema.dev/dumber"},
140+ {URL: "https://bnema.dev/dumber/docs"},
141141 }
142142
143143 tests := []struct {
@@ -148,7 +148,7 @@ func TestResolveTargetURLForSelection(t *testing.T) {
148148 wantURL string
149149 }{
150150 {name: "history index", mode: ViewModeHistory, index: 1, limit: 10, wantURL: "https://github.com/bnema/dumber/pulls"},
151- {name: "favorites index", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev"},
151+ {name: "favorites index", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://bnema.dev/dumber"},
152152 {name: "invalid index", mode: ViewModeHistory, index: 99, limit: 10, wantURL: ""},
153153 {name: "history index beyond visible limit is hidden", mode: ViewModeHistory, index: 2, limit: 2, wantURL: ""},
154154 {name: "favorites index beyond visible limit is hidden", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: ""},
@@ -170,8 +170,8 @@ func TestSelectedTargetURL(t *testing.T) {
170170 {URL: "https://github.com/bnema/dumber/pulls"},
171171 }
172172 favorites := []Favorite{
173- {URL: "https://dumber.bnema.dev"},
174- {URL: "https://docs.dumber.bnema.dev"},
173+ {URL: "https://bnema.dev/dumber"},
174+ {URL: "https://bnema.dev/dumber/docs"},
175175 }
176176
177177 tests := []struct {
@@ -184,7 +184,7 @@ func TestSelectedTargetURL(t *testing.T) {
184184 }{
185185 {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, limit: 10, wantURL: "", wantBool: false},
186186 {name: "history selection is explicit", mode: ViewModeHistory, index: 0, limit: 10, wantURL: "https://github.com/bnema/dumber", wantBool: true},
187- {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev", wantBool: true},
187+ {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://bnema.dev/dumber", wantBool: true},
188188 {name: "hidden history selection is ignored", mode: ViewModeHistory, index: 1, limit: 1, wantURL: "", wantBool: true},
189189 {name: "hidden favorites selection is ignored", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: "", wantBool: true},
190190 }
M internal/ui/component/pane_view.go +86 -0
@@ -14,6 +14,27 @@ import (
1414 const (
1515 // CSS class applied to active pane's border overlay
1616 activePaneClass = "pane-active"
17+
18+ // vimModeActiveClass is added to the pane overlay when Vim mode is active.
19+ // It produces a subtle local border accent using the pane mode color.
20+ vimModeActiveClass = "vim-mode-active"
21+
22+ // vimModePulseClass triggers a normal scroll pulse on the pane overlay.
23+ // Repeated calls must remove-then-add to restart the CSS animation.
24+ vimModePulseClass = "vim-mode-pulse"
25+
26+ // vimModeFastPulseClass triggers a fast/stronger scroll pulse.
27+ vimModeFastPulseClass = "vim-mode-pulse-fast"
28+
29+ // Alternate equivalent animations so repeated scrolls reliably restart
30+ // the GTK CSS animation.
31+ vimModePulseCycleClassA = "vim-mode-pulse-cycle-a"
32+ vimModePulseCycleClassB = "vim-mode-pulse-cycle-b"
33+
34+ // Exported aliases for tests and external inspection.
35+ VimModeActiveClass = vimModeActiveClass
36+ VimModePulseClass = vimModePulseClass
37+ VimModeFastPulseClass = vimModeFastPulseClass
1738 )
1839
1940 // PaneView is a container for a single WebView with active state indication.
@@ -31,6 +52,10 @@ type PaneView struct {
3152 paneID entity.PaneID
3253 isActive bool
3354
55+ // Vim Mode pane-local visual state.
56+ vimMode bool
57+ pulseCycle bool
58+
3459 onFocusIn func(paneID entity.PaneID)
3560 onFocusOut func(paneID entity.PaneID)
3661 onHover func(paneID entity.PaneID)
@@ -92,6 +117,7 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit
92117 loading: loading,
93118 paneID: paneID,
94119 isActive: false,
120+ vimMode: false,
95121 }
96122 }
97123
@@ -520,6 +546,66 @@ func (pv *PaneView) HideLinkStatus() {
520546 }
521547 }
522548
549+// SetVimMode activates or deactivates the pane-local Vim Mode accent.
550+func (pv *PaneView) SetVimMode(active bool) {
551+ pv.mu.Lock()
552+ defer pv.mu.Unlock()
553+
554+ if pv.vimMode == active {
555+ return
556+ }
557+
558+ pv.vimMode = active
559+
560+ if active {
561+ pv.overlay.AddCssClass(vimModeActiveClass)
562+ } else {
563+ pv.overlay.RemoveCssClass(vimModeActiveClass)
564+ }
565+}
566+
567+// IsVimMode returns whether Vim mode is currently active on this pane.
568+func (pv *PaneView) IsVimMode() bool {
569+ pv.mu.RLock()
570+ defer pv.mu.RUnlock()
571+
572+ return pv.vimMode
573+}
574+
575+// TriggerVimModePulse triggers a normal scroll pulse on the pane overlay.
576+func (pv *PaneView) TriggerVimModePulse() {
577+ pv.triggerOverlayPulse(false)
578+}
579+
580+// TriggerVimModePulseFast triggers a stronger, longer pane-overlay pulse.
581+func (pv *PaneView) TriggerVimModePulseFast() {
582+ pv.triggerOverlayPulse(true)
583+}
584+
585+// triggerOverlayPulse adds a transient pulse CSS class to the pane overlay.
586+// fast=true uses the stronger/longer pulse animation. Both pulse classes are
587+// removed first to reliably re-trigger the animation.
588+func (pv *PaneView) triggerOverlayPulse(fast bool) {
589+ pv.mu.Lock()
590+ defer pv.mu.Unlock()
591+
592+ pv.overlay.RemoveCssClass(vimModePulseClass)
593+ pv.overlay.RemoveCssClass(vimModeFastPulseClass)
594+ pv.overlay.RemoveCssClass(vimModePulseCycleClassA)
595+ pv.overlay.RemoveCssClass(vimModePulseCycleClassB)
596+ if fast {
597+ pv.overlay.AddCssClass(vimModeFastPulseClass)
598+ } else {
599+ pv.overlay.AddCssClass(vimModePulseClass)
600+ }
601+ cycle := vimModePulseCycleClassA
602+ if pv.pulseCycle {
603+ cycle = vimModePulseCycleClassB
604+ }
605+ pv.pulseCycle = !pv.pulseCycle
606+ pv.overlay.AddCssClass(cycle)
607+}
608+
523609 // Cleanup removes the WebView widget from the overlay and clears references.
524610 // This must be called before destroying the WebView to ensure proper GTK cleanup.
525611 // After calling Cleanup, the PaneView should not be reused.
M internal/ui/component/pane_view_test.go +139 -0
@@ -681,3 +681,142 @@ func setupPaneViewMocksNoWebView(
681681 mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
682682 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
683683 }
684+
685+func TestSetVimMode_TrueAddsPaneAccentWithoutCreatingLabel(t *testing.T) {
686+ mockFactory := mocks.NewMockWidgetFactory(t)
687+ mockOverlay := mocks.NewMockOverlayWidget(t)
688+ mockBorderBox := mocks.NewMockBoxWidget(t)
689+ mockWebView := mocks.NewMockWidget(t)
690+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
691+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
692+
693+ mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
694+ pv.SetVimMode(true)
695+
696+ require.True(t, pv.IsVimMode())
697+}
698+
699+func TestSetVimMode_FalseRemovesPaneAccent(t *testing.T) {
700+ mockFactory := mocks.NewMockWidgetFactory(t)
701+ mockOverlay := mocks.NewMockOverlayWidget(t)
702+ mockBorderBox := mocks.NewMockBoxWidget(t)
703+ mockWebView := mocks.NewMockWidget(t)
704+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
705+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
706+
707+ mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
708+ pv.SetVimMode(true)
709+ mockOverlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
710+ pv.SetVimMode(false)
711+
712+ require.False(t, pv.IsVimMode())
713+}
714+
715+func TestSetVimMode_NoChangeWhenSameState(t *testing.T) {
716+ mockFactory := mocks.NewMockWidgetFactory(t)
717+ mockOverlay := mocks.NewMockOverlayWidget(t)
718+ mockBorderBox := mocks.NewMockBoxWidget(t)
719+ mockWebView := mocks.NewMockWidget(t)
720+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
721+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
722+
723+ pv.SetVimMode(false)
724+
725+ require.False(t, pv.IsVimMode())
726+}
727+
728+func TestSetVimMode_TrueThenTrueIsIdempotent(t *testing.T) {
729+ mockFactory := mocks.NewMockWidgetFactory(t)
730+ mockOverlay := mocks.NewMockOverlayWidget(t)
731+ mockBorderBox := mocks.NewMockBoxWidget(t)
732+ mockWebView := mocks.NewMockWidget(t)
733+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
734+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
735+
736+ mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
737+ pv.SetVimMode(true)
738+ pv.SetVimMode(true)
739+
740+ require.True(t, pv.IsVimMode())
741+}
742+
743+func expectVimModeOverlayPulse(overlay *mocks.MockOverlayWidget, fast bool, cycle string) {
744+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
745+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
746+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
747+ overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
748+ if fast {
749+ overlay.EXPECT().AddCssClass("vim-mode-pulse-fast").Once()
750+ } else {
751+ overlay.EXPECT().AddCssClass("vim-mode-pulse").Once()
752+ }
753+ overlay.EXPECT().AddCssClass(cycle).Once()
754+}
755+
756+func TestTriggerVimModePulse_PulsesOnlyOverlay(t *testing.T) {
757+ mockFactory := mocks.NewMockWidgetFactory(t)
758+ mockOverlay := mocks.NewMockOverlayWidget(t)
759+ mockBorderBox := mocks.NewMockBoxWidget(t)
760+ mockWebView := mocks.NewMockWidget(t)
761+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
762+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
763+
764+ expectVimModeOverlayPulse(mockOverlay, false, "vim-mode-pulse-cycle-a")
765+ pv.TriggerVimModePulse()
766+}
767+
768+func TestTriggerVimModePulseFast_PulsesOnlyOverlay(t *testing.T) {
769+ mockFactory := mocks.NewMockWidgetFactory(t)
770+ mockOverlay := mocks.NewMockOverlayWidget(t)
771+ mockBorderBox := mocks.NewMockBoxWidget(t)
772+ mockWebView := mocks.NewMockWidget(t)
773+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
774+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
775+
776+ expectVimModeOverlayPulse(mockOverlay, true, "vim-mode-pulse-cycle-a")
777+ pv.TriggerVimModePulseFast()
778+}
779+
780+func TestTriggerVimModePulse_RepeatedCallsReTriggerOverlayAnimation(t *testing.T) {
781+ mockFactory := mocks.NewMockWidgetFactory(t)
782+ mockOverlay := mocks.NewMockOverlayWidget(t)
783+ mockBorderBox := mocks.NewMockBoxWidget(t)
784+ mockWebView := mocks.NewMockWidget(t)
785+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
786+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
787+
788+ expectVimModeOverlayPulse(mockOverlay, false, "vim-mode-pulse-cycle-a")
789+ pv.TriggerVimModePulse()
790+ expectVimModeOverlayPulse(mockOverlay, true, "vim-mode-pulse-cycle-b")
791+ pv.TriggerVimModePulseFast()
792+ expectVimModeOverlayPulse(mockOverlay, false, "vim-mode-pulse-cycle-a")
793+ pv.TriggerVimModePulse()
794+}
795+
796+func TestVimMode_CleanupHasNoIndicatorOverlay(t *testing.T) {
797+ mockFactory := mocks.NewMockWidgetFactory(t)
798+ mockOverlay := mocks.NewMockOverlayWidget(t)
799+ mockBorderBox := mocks.NewMockBoxWidget(t)
800+ mockWebView := mocks.NewMockWidget(t)
801+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
802+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
803+
804+ mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
805+ pv.SetVimMode(true)
806+ mockOverlay.EXPECT().RemoveOverlay(mock.Anything).Once()
807+ mockOverlay.EXPECT().SetChild(nil).Once()
808+
809+ pv.Cleanup()
810+}
811+
812+func TestVimMode_NewPaneViewInactiveByDefault(t *testing.T) {
813+ mockFactory := mocks.NewMockWidgetFactory(t)
814+ mockOverlay := mocks.NewMockOverlayWidget(t)
815+ mockBorderBox := mocks.NewMockBoxWidget(t)
816+ mockWebView := mocks.NewMockWidget(t)
817+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
818+
819+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
820+
821+ require.False(t, pv.IsVimMode())
822+}
M internal/ui/component/toaster.go +12 -0
@@ -276,6 +276,18 @@ func (t *Toaster) Hide() {
276276 t.hide()
277277 }
278278
279+// CancelAutoDismiss removes a pending auto-dismiss source without changing the
280+// toast's visibility. It is useful when the owner is being torn down.
281+func (t *Toaster) CancelAutoDismiss() {
282+ t.mu.Lock()
283+ defer t.mu.Unlock()
284+
285+ if t.dismissTimer != 0 {
286+ glib.SourceRemove(t.dismissTimer)
287+ t.dismissTimer = 0
288+ }
289+}
290+
279291 // hide performs the actual hide operation (must be called with lock held).
280292 func (t *Toaster) hide() {
281293 if !t.visible {
M internal/ui/component/workspace_view.go +10 -0
@@ -73,6 +73,16 @@ type WorkspaceView struct {
7373 mu sync.RWMutex
7474 }
7575
76+// SetPaneViewsForTest injects pane views into the workspace view for testing.
77+// This is a narrow testability seam; production code populates pane views via
78+// SetWorkspace and the tree renderer. The workspace field must be set
79+// separately on the entity if the tests exercise code paths that read it.
80+func (wv *WorkspaceView) SetPaneViewsForTest(paneViews map[entity.PaneID]*PaneView) {
81+ wv.mu.Lock()
82+ defer wv.mu.Unlock()
83+ wv.paneViews = paneViews
84+}
85+
7686 // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
7787 // so TreeRenderer can create PaneViews.
7888 type paneViewFactoryAdapter struct {
M internal/ui/coordinator/content/callbacks.go +6 -0
@@ -179,6 +179,12 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
179179 }
180180 }
181181
182+ callbacks.OnEditableFocusChanged = func(editable bool) {
183+ if c.onEditableFocusChanged != nil {
184+ c.onEditableFocusChanged(paneID, editable)
185+ }
186+ }
187+
182188 // Add popup create handler if popup handling is configured
183189 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
184190
M internal/ui/coordinator/content/coordinator.go +8 -0
@@ -109,6 +109,9 @@ type Coordinator struct {
109109 // Callback when WebView gains focus (for accent picker text input targeting)
110110 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
111111
112+ // Callback when a page editable target gains or loses focus.
113+ onEditableFocusChanged func(paneID entity.PaneID, editable bool)
114+
112115 // Callback for first load_started event (triggers deferred initialization)
113116 onFirstLoadStarted func()
114117 loadStartedOnce sync.Once
@@ -243,6 +246,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.
243246 c.onWebViewFocused = fn
244247 }
245248
249+// SetOnEditableFocusChanged sets the callback for page editable focus changes.
250+func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
251+ c.onEditableFocusChanged = fn
252+}
253+
246254 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.
247255 // This is used to trigger deferred initialization after the initial load_uri()
248256 // has been processed by the GTK main loop.
A internal/ui/coordinator/content/editable_focus_test.go +84 -0
@@ -0,0 +1,84 @@
1+package content
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/application/port/mocks"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+func TestEditableFocusCallback_ForwardsPaneID(t *testing.T) {
17+ c := newMinimalCoordinator()
18+ wv := mocks.NewMockWebView(t)
19+
20+ var callbacks *port.WebViewCallbacks
21+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
22+ wv.EXPECT().Generation().Return(uint64(0)).Twice()
23+ wv.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) {
24+ callbacks = cb
25+ }).Once()
26+
27+ var gotPaneID entity.PaneID
28+ var gotEditable bool
29+ c.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
30+ gotPaneID = paneID
31+ gotEditable = editable
32+ })
33+
34+ c.setupWebViewCallbacks(context.Background(), entity.PaneID("pane-1"), wv)
35+ require.NotNil(t, callbacks)
36+ require.NotNil(t, callbacks.OnEditableFocusChanged)
37+
38+ callbacks.OnEditableFocusChanged(true)
39+
40+ assert.Equal(t, entity.PaneID("pane-1"), gotPaneID)
41+ assert.True(t, gotEditable)
42+}
43+
44+func TestEditableFocusCallback_BackgroundPaneDoesNotConfusePaneOwnership(t *testing.T) {
45+ c := newMinimalCoordinator()
46+ c.SetActivePaneOverride(entity.PaneID("pane-a"))
47+
48+ wvA := mocks.NewMockWebView(t)
49+ wvB := mocks.NewMockWebView(t)
50+
51+ var callbacksA *port.WebViewCallbacks
52+ var callbacksB *port.WebViewCallbacks
53+ wvA.EXPECT().ID().Return(port.WebViewID(1)).Once()
54+ wvA.EXPECT().Generation().Return(uint64(0)).Twice()
55+ wvA.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) {
56+ callbacksA = cb
57+ }).Once()
58+ wvB.EXPECT().ID().Return(port.WebViewID(2)).Once()
59+ wvB.EXPECT().Generation().Return(uint64(0)).Twice()
60+ wvB.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) {
61+ callbacksB = cb
62+ }).Once()
63+
64+ type event struct {
65+ paneID entity.PaneID
66+ editable bool
67+ }
68+ var events []event
69+ c.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
70+ events = append(events, event{paneID: paneID, editable: editable})
71+ })
72+
73+ c.setupWebViewCallbacks(context.Background(), entity.PaneID("pane-a"), wvA)
74+ c.setupWebViewCallbacks(context.Background(), entity.PaneID("pane-b"), wvB)
75+ require.NotNil(t, callbacksA)
76+ require.NotNil(t, callbacksB)
77+
78+ callbacksB.OnEditableFocusChanged(true)
79+ callbacksA.OnEditableFocusChanged(false)
80+
81+ require.Len(t, events, 2)
82+ assert.Equal(t, event{paneID: entity.PaneID("pane-b"), editable: true}, events[0])
83+ assert.Equal(t, event{paneID: entity.PaneID("pane-a"), editable: false}, events[1])
84+}
M internal/ui/coordinator/navigation.go +51 -0
@@ -23,6 +23,7 @@ type NavigationCoordinator struct {
2323 contextProvider func() context.Context
2424 navigateUC *usecase.NavigateUseCase
2525 historyRecorder *usecase.HistoryRecorderUseCase
26+ pageScrollUC *usecase.PageScrollUseCase
2627 contentCoord *content.Coordinator
2728 omniboxProvider OmniboxProvider
2829 }
@@ -275,6 +276,56 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
275276 c.historyRecorder.ClearPaneHistory(string(paneID))
276277 }
277278
279+// SetPageScrollUseCase sets the PageScrollUseCase for page scrolling operations.
280+// This is an optional dependency: ScrollWebView will return an error if not set.
281+func (c *NavigationCoordinator) SetPageScrollUseCase(uc *usecase.PageScrollUseCase) {
282+ c.pageScrollUC = uc
283+}
284+
285+// ScrollWebView applies a semantic page scroll command to the provided WebView.
286+// It delegates to PageScrollUseCase for delta computation and Scrollable dispatch.
287+// The PageScrollUseCase must be set via SetPageScrollUseCase before calling this.
288+func (c *NavigationCoordinator) ScrollWebView(ctx context.Context, wv port.WebView, cmd usecase.PageScrollCommand) error {
289+ log := logging.FromContext(ctx)
290+
291+ if err := requireWebView(wv); err != nil {
292+ log.Warn().Str("command", cmd.String()).Msg("ScrollWebView called with nil webview")
293+ return err
294+ }
295+
296+ log.Debug().Str("command", cmd.String()).Uint64("webview_id", uint64(wv.ID())).Msg("scrolling webview")
297+
298+ if c.pageScrollUC == nil {
299+ err := fmt.Errorf("page scroll usecase not set")
300+ log.Error().Msg(err.Error())
301+ return err
302+ }
303+
304+ return c.pageScrollUC.Scroll(ctx, wv, cmd)
305+}
306+
307+// ScrollWebViewContinuous applies one autonomous held-key vim-scroll step.
308+func (c *NavigationCoordinator) ScrollWebViewContinuous(ctx context.Context, wv port.WebView, cmd usecase.PageScrollCommand) error {
309+ if err := requireWebView(wv); err != nil {
310+ return err
311+ }
312+ if c.pageScrollUC == nil {
313+ return fmt.Errorf("page scroll usecase not set")
314+ }
315+ return c.pageScrollUC.ScrollContinuous(ctx, wv, cmd)
316+}
317+
318+// StopPageScroll discards queued continuous deltas for adapters that support it.
319+func (c *NavigationCoordinator) StopPageScroll(ctx context.Context, wv port.WebView) error {
320+ if err := requireWebView(wv); err != nil {
321+ return err
322+ }
323+ if c.pageScrollUC == nil {
324+ return fmt.Errorf("page scroll usecase not set")
325+ }
326+ return c.pageScrollUC.Stop(ctx, wv)
327+}
328+
278329 // NotifyZoomChanged updates the omnibox zoom indicator.
279330 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {
280331 if c.omniboxProvider != nil {
M internal/ui/coordinator/navigation_webview_targeted_test.go +74 -0
@@ -6,9 +6,15 @@ import (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/bnema/dumber/internal/application/usecase"
910 "github.com/bnema/dumber/internal/domain/entity"
1011 )
1112
13+type mockScrollableWebView struct {
14+ *mocks.MockWebView
15+ *mocks.MockPageScrollable
16+}
17+
1218 type mockDevToolsWebView struct {
1319 *mocks.MockWebView
1420 *mocks.MockDevToolsOpener
@@ -76,6 +82,15 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi
7682 t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {
7783 testPrintWebViewTargetsProvidedWebView(t, ctx)
7884 })
85+ t.Run("ScrollWebView with nil webview returns error", func(t *testing.T) {
86+ testScrollWebViewNilWebView(t, ctx)
87+ })
88+ t.Run("ScrollWebView delegates each scroll command through usecase", func(t *testing.T) {
89+ testScrollWebViewDelegatesEachCommand(t, ctx)
90+ })
91+ t.Run("ScrollWebView usecase not set returns error", func(t *testing.T) {
92+ testScrollWebViewUsecaseNotSet(t, ctx)
93+ })
7994 }
8095
8196 func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {
@@ -268,3 +283,62 @@ func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
268283 t.Fatalf("unexpected error: %v", err)
269284 }
270285 }
286+
287+func testScrollWebViewNilWebView(t *testing.T, ctx context.Context) {
288+ t.Helper()
289+ c := &NavigationCoordinator{}
290+ c.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
291+ err := c.ScrollWebView(ctx, nil, usecase.PageScrollDown)
292+ if err == nil {
293+ t.Fatal("expected error for nil webview, got nil")
294+ }
295+}
296+
297+func testScrollWebViewDelegatesEachCommand(t *testing.T, ctx context.Context) {
298+ t.Helper()
299+ commands := []struct {
300+ cmd usecase.PageScrollCommand
301+ dx int
302+ dy int
303+ }{
304+ {usecase.PageScrollLeft, -80, 0},
305+ {usecase.PageScrollRight, 80, 0},
306+ {usecase.PageScrollUp, 0, -80},
307+ {usecase.PageScrollDown, 0, 80},
308+ {usecase.PageScrollUpFast, 0, -320},
309+ {usecase.PageScrollDownFast, 0, 320},
310+ }
311+
312+ for _, tc := range commands {
313+ t.Run(tc.cmd.String(), func(t *testing.T) {
314+ base := mocks.NewMockWebView(t)
315+ scroller := mocks.NewMockPageScrollable(t)
316+ wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}
317+ base.EXPECT().ID().Return(port.WebViewID(42)).Once()
318+ req := port.PageScrollRequest{Command: port.PageScrollCommand(tc.cmd), FallbackDX: tc.dx, FallbackDY: tc.dy}
319+ scroller.EXPECT().ScrollPage(ctx, req).Return(nil).Once()
320+
321+ c := &NavigationCoordinator{}
322+ c.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
323+ err := c.ScrollWebView(ctx, wv, tc.cmd)
324+ if err != nil {
325+ t.Fatalf("unexpected error for %s: %v", tc.cmd, err)
326+ }
327+ })
328+ }
329+}
330+
331+func testScrollWebViewUsecaseNotSet(t *testing.T, ctx context.Context) {
332+ t.Helper()
333+ base := mocks.NewMockWebView(t)
334+ wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: mocks.NewMockPageScrollable(t)}
335+ base.EXPECT().ID().Return(port.WebViewID(1)).Once()
336+ c := &NavigationCoordinator{}
337+ err := c.ScrollWebView(ctx, wv, usecase.PageScrollDown)
338+ if err == nil {
339+ t.Fatal("expected error for missing usecase, got nil")
340+ }
341+ if got, want := err.Error(), "page scroll usecase not set"; got != want {
342+ t.Fatalf("error = %q, want %q", got, want)
343+ }
344+}
M internal/ui/coordinator/workspace.go +181 -100
@@ -163,20 +163,30 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com
163163
164164 // Split splits the active pane in the given direction.
165165 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
166- return c.splitWithInitialURL(ctx, direction, c.newPaneURL)
166+ return c.splitWithInitialURL(ctx, direction, c.newPaneURL, true)
167167 }
168168
169169 // SplitWithURL splits the active pane in the given direction and loads initialURL.
170170 func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
171- return c.splitWithInitialURL(ctx, direction, initialURL)
171+ return c.splitWithInitialURL(ctx, direction, initialURL, true)
172172 }
173173
174-func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
174+// SplitWithURLWithoutOmnibox splits the active pane without opening the omnibox.
175+func (c *WorkspaceCoordinator) SplitWithURLWithoutOmnibox(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
176+ return c.splitWithInitialURL(ctx, direction, initialURL, false)
177+}
178+
179+func (c *WorkspaceCoordinator) splitWithInitialURL(
180+ ctx context.Context,
181+ direction usecase.SplitDirection,
182+ initialURL string,
183+ notifyNewPane bool,
184+) error {
175185 log := logging.FromContext(ctx)
176186
177- splitCtx, ok := c.prepareSplit(ctx, direction)
178- if !ok {
179- return nil
187+ splitCtx, err := c.prepareSplit(ctx, direction)
188+ if err != nil {
189+ return err
180190 }
181191
182192 output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
@@ -198,11 +208,20 @@ func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, directio
198208
199209 // Update the workspace view
200210 if splitCtx.wsView != nil {
201- c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
202- }
203-
204- if splitCtx.wsView != nil {
205- splitCtx.wsView.NotifyNewPaneCreated(ctx)
211+ if err := c.applySplitToView(
212+ ctx, splitCtx.wsView, splitCtx.ws, output, direction,
213+ splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID,
214+ ); err != nil {
215+ if rollbackErr := c.ClosePaneByID(ctx, output.NewPaneNode.Pane.ID); rollbackErr != nil {
216+ log.Warn().Err(rollbackErr).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to roll back split pane")
217+ }
218+ splitCtx.ws.ActivePaneID = oldActivePaneID
219+ c.notifyStateChanged()
220+ return err
221+ }
222+ if notifyNewPane {
223+ splitCtx.wsView.NotifyNewPaneCreated(ctx)
224+ }
206225 }
207226
208227 // Notify state change for session snapshots
@@ -332,23 +351,27 @@ func setActiveStackIndexForChild(parent, child *entity.PaneNode) {
332351 func (c *WorkspaceCoordinator) prepareSplit(
333352 ctx context.Context,
334353 direction usecase.SplitDirection,
335-) (*splitContext, bool) {
354+) (*splitContext, error) {
336355 log := logging.FromContext(ctx)
337356 if c.panesUC == nil {
338357 log.Warn().Msg("panes use case not available")
339- return nil, false
358+ return nil, fmt.Errorf("panes use case not available")
359+ }
360+ if c.getActiveWS == nil {
361+ log.Warn().Msg("active workspace provider not configured")
362+ return nil, fmt.Errorf("active workspace provider not configured")
340363 }
341364
342365 ws, wsView := c.getActiveWS()
343366 if ws == nil {
344367 log.Warn().Msg("no active workspace")
345- return nil, false
368+ return nil, fmt.Errorf("no active workspace")
346369 }
347370
348371 activePane := ws.ActivePane()
349- if activePane == nil {
372+ if activePane == nil || activePane.Pane == nil {
350373 log.Warn().Msg("no active pane to split")
351- return nil, false
374+ return nil, fmt.Errorf("no active pane to split")
352375 }
353376
354377 // Check if active pane is inside a stack
@@ -367,7 +390,7 @@ func (c *WorkspaceCoordinator) prepareSplit(
367390 activePane: activePane,
368391 existingWidget: existingWidget,
369392 isStackSplit: isStackSplit,
370- }, true
393+ }, nil
371394 }
372395
373396 func (c *WorkspaceCoordinator) resolveSplitWidget(
@@ -407,7 +430,7 @@ func (c *WorkspaceCoordinator) applySplitToView(
407430 existingWidget layout.Widget,
408431 isStackSplit bool,
409432 oldActivePaneID entity.PaneID,
410-) {
433+) error {
411434 log := logging.FromContext(ctx)
412435 needsAttach := false
413436
@@ -423,12 +446,14 @@ func (c *WorkspaceCoordinator) applySplitToView(
423446 log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
424447 if err := wsView.Rebuild(ctx); err != nil {
425448 log.Error().Err(err).Msg("failed to rebuild workspace view")
449+ return fmt.Errorf("rebuild workspace view after split: %w", err)
426450 }
427451 needsAttach = true
428452 }
429453 } else {
430454 if err := wsView.Rebuild(ctx); err != nil {
431455 log.Error().Err(err).Msg("failed to rebuild workspace view")
456+ return fmt.Errorf("rebuild workspace view after split: %w", err)
432457 }
433458 needsAttach = true
434459 }
@@ -439,8 +464,10 @@ func (c *WorkspaceCoordinator) applySplitToView(
439464 }
440465 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
441466 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
467+ return fmt.Errorf("activate split pane: %w", err)
442468 }
443469 wsView.FocusPane(ws.ActivePaneID)
470+ return nil
444471 }
445472
446473 // doIncrementalStackSplit performs an incremental split around an existing stacked pane.
@@ -582,6 +609,13 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
582609 wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
583610 if err != nil {
584611 log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
612+ if rollbackErr := c.ClosePaneByID(ctx, output.NewPaneNode.Pane.ID); rollbackErr != nil {
613+ log.Warn().Err(rollbackErr).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to roll back stack split pane")
614+ }
615+ if ws := wsView.Workspace(); ws != nil {
616+ ws.ActivePaneID = oldActivePaneID
617+ }
618+ c.notifyStateChanged()
585619 return err
586620 }
587621
@@ -722,6 +756,11 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
722756 wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
723757 if err != nil {
724758 log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
759+ if rollbackErr := c.ClosePaneByID(ctx, output.NewPaneNode.Pane.ID); rollbackErr != nil {
760+ log.Warn().Err(rollbackErr).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to roll back incremental split pane")
761+ }
762+ ws.ActivePaneID = oldActivePaneID
763+ c.notifyStateChanged()
725764 return err
726765 }
727766
@@ -1453,71 +1492,32 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView
14531492 // StackPane adds a new pane stacked on top of the active pane.
14541493 // Uses CreateStack use case for new stacks, AddToStack for existing stacks.
14551494 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1456- log := logging.FromContext(ctx)
1495+ return c.stackPaneWithURL(ctx, c.newPaneURL, true)
1496+}
14571497
1458- stackCtx, ok := c.prepareStackPane(ctx)
1459- if !ok {
1460- return nil
1461- }
1498+// StackPaneWithURL adds a new pane containing initialURL to the active stack.
1499+func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL string) error {
1500+ return c.stackPaneWithURL(ctx, initialURL, true)
1501+}
14621502
1463- // Determine if we need to create a new stack or add to existing.
1464- var stackNode *entity.PaneNode
1465- var newPane *entity.Pane
1466- var newPaneID entity.PaneID
1467- var needsFirstPaneTitleUpdate bool
1503+// StackPaneWithURLWithoutOmnibox adds a stacked pane without opening the omnibox.
1504+func (c *WorkspaceCoordinator) StackPaneWithURLWithoutOmnibox(ctx context.Context, initialURL string) error {
1505+ return c.stackPaneWithURL(ctx, initialURL, false)
1506+}
14681507
1469- if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
1470- // Already in a stack - use AddToStack use case
1471- stackNode = stackCtx.activeNode.Parent
1472- output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL)
1473- if err != nil {
1474- log.Error().Err(err).Msg("failed to add pane to stack via use case")
1475- return err
1476- }
1477- newPane = output.NewPaneNode.Pane
1478- newPaneID = newPane.ID
1479- newPane.Title = defaultPaneTitle
1480- log.Debug().
1481- Int("stack_size", len(stackNode.Children)).
1482- Int("insert_index", output.StackIndex).
1483- Msg("added to existing stack via use case")
1484- } else if stackCtx.activeNode.IsStacked {
1485- // Active node is already a stack container - add to it
1486- stackNode = stackCtx.activeNode
1487- output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL)
1488- if err != nil {
1489- log.Error().Err(err).Msg("failed to add pane to stack via use case")
1490- return err
1491- }
1492- newPane = output.NewPaneNode.Pane
1493- newPaneID = newPane.ID
1494- newPane.Title = defaultPaneTitle
1495- log.Debug().
1496- Int("stack_size", len(stackNode.Children)).
1497- Int("insert_index", output.StackIndex).
1498- Msg("added to stack container via use case")
1499- } else {
1500- // Need to create a new stack - use CreateStack use case.
1501- output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode, c.newPaneURL)
1502- if err != nil {
1503- log.Error().Err(err).Msg("failed to create stack via use case")
1504- return err
1505- }
1506- stackNode = output.StackNode
1507- newPane = output.NewPane
1508- newPaneID = newPane.ID
1509- newPane.Title = defaultPaneTitle
1510- needsFirstPaneTitleUpdate = true
1508+func (c *WorkspaceCoordinator) stackPaneWithURL(ctx context.Context, initialURL string, notifyNewPane bool) error {
1509+ log := logging.FromContext(ctx)
15111510
1512- // Update the original pane's title in the domain
1513- if output.OriginalNode != nil && output.OriginalNode.Pane != nil {
1514- output.OriginalNode.Pane.Title = stackCtx.originalTitle
1515- }
1511+ stackCtx, err := c.prepareStackPane(ctx)
1512+ if err != nil {
1513+ return err
1514+ }
15161515
1517- log.Debug().
1518- Int("stack_size", len(stackNode.Children)).
1519- Msg("created new stack via use case")
1516+ stackNode, newPane, needsFirstPaneTitleUpdate, err := c.createOrAddStackPane(ctx, stackCtx, initialURL)
1517+ if err != nil {
1518+ return err
15201519 }
1520+ newPaneID := newPane.ID
15211521
15221522 // Create PaneView for the new pane
15231523 newPaneView := component.NewPaneView(ctx, c.widgetFactory, newPaneID, nil)
@@ -1525,9 +1525,11 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
15251525 stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
15261526
15271527 // Add to the UI StackedView
1528- if err := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle); err != nil {
1529- log.Error().Err(err).Msg("failed to add pane to stack")
1530- return err
1528+ addErr := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle)
1529+ if addErr != nil {
1530+ log.Error().Err(addErr).Msg("failed to add pane to stack")
1531+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1532+ return addErr
15311533 }
15321534
15331535 // Update the first pane's title if we just converted from leaf to stacked
@@ -1538,26 +1540,36 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
15381540 // Get WebView and attach
15391541 wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
15401542 if err != nil {
1541- log.Warn().Err(err).Msg("failed to get webview for new pane")
1542- } else {
1543- widget := c.contentCoord.WrapWidget(ctx, wv)
1544- if widget != nil {
1545- if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
1546- log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1547- }
1548- }
1549- // Load initial page for the new pane
1550- if err := wv.LoadURI(ctx, newPane.URI); err != nil {
1551- log.Warn().Err(err).Str("uri", newPane.URI).Msg("failed to load initial page")
1552- }
1543+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1544+ return err
1545+ }
1546+ widget := c.contentCoord.WrapWidget(ctx, wv)
1547+ if widget == nil {
1548+ err := fmt.Errorf("wrap webview for stacked pane")
1549+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1550+ return err
1551+ }
1552+ if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
1553+ log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1554+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1555+ return err
1556+ }
1557+ // Loading failures leave the attached pane available for a later navigation.
1558+ if err := wv.LoadURI(ctx, newPane.URI); err != nil {
1559+ log.Warn().Err(err).Str("uri_host", logging.SafeURLHost(newPane.URI)).Msg("failed to load initial page")
15531560 }
15541561
15551562 // Update workspace view
15561563 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
15571564 log.Warn().Err(err).Msg("failed to set active pane")
1565+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1566+ return err
15581567 }
1568+ stackCtx.wsView.FocusPane(newPaneID)
15591569
1560- stackCtx.wsView.NotifyNewPaneCreated(ctx)
1570+ if notifyNewPane {
1571+ stackCtx.wsView.NotifyNewPaneCreated(ctx)
1572+ }
15611573
15621574 // Set up title bar click and close callbacks
15631575 tr := stackCtx.wsView.TreeRenderer()
@@ -1586,30 +1598,91 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
15861598 return nil
15871599 }
15881600
1601+func (c *WorkspaceCoordinator) rollbackStackPane(ctx context.Context, stackCtx *stackPaneContext, paneID entity.PaneID) {
1602+ if err := c.ClosePaneByID(ctx, paneID); err != nil {
1603+ logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).
1604+ Msg("failed to roll back stacked pane")
1605+ }
1606+ stackCtx.ws.ActivePaneID = stackCtx.activePaneID
1607+ c.notifyStateChanged()
1608+}
1609+
1610+func (c *WorkspaceCoordinator) createOrAddStackPane(
1611+ ctx context.Context,
1612+ stackCtx *stackPaneContext,
1613+ initialURL string,
1614+) (*entity.PaneNode, *entity.Pane, bool, error) {
1615+ log := logging.FromContext(ctx)
1616+ stackNode := stackCtx.activeNode
1617+ if stackNode.Parent != nil && stackNode.Parent.IsStacked {
1618+ stackNode = stackNode.Parent
1619+ }
1620+ if stackNode.IsStacked {
1621+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, initialURL)
1622+ if err != nil {
1623+ log.Error().Err(err).Msg("failed to add pane to stack via use case")
1624+ return nil, nil, false, err
1625+ }
1626+ newPane := output.NewPaneNode.Pane
1627+ newPane.Title = defaultPaneTitle
1628+ log.Debug().Int("stack_size", len(stackNode.Children)).Int("insert_index", output.StackIndex).
1629+ Msg("added to stack via use case")
1630+ return stackNode, newPane, false, nil
1631+ }
1632+
1633+ output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode, initialURL)
1634+ if err != nil {
1635+ log.Error().Err(err).Msg("failed to create stack via use case")
1636+ return nil, nil, false, err
1637+ }
1638+ output.NewPane.Title = defaultPaneTitle
1639+ if output.OriginalNode != nil && output.OriginalNode.Pane != nil {
1640+ output.OriginalNode.Pane.Title = stackCtx.originalTitle
1641+ }
1642+ log.Debug().Int("stack_size", len(output.StackNode.Children)).Msg("created new stack via use case")
1643+ return output.StackNode, output.NewPane, true, nil
1644+}
1645+
15891646 func (c *WorkspaceCoordinator) prepareStackPane(
15901647 ctx context.Context,
1591-) (*stackPaneContext, bool) {
1648+) (*stackPaneContext, error) {
15921649 log := logging.FromContext(ctx)
15931650 if c.stackedPaneMgr == nil {
15941651 log.Warn().Msg("stacked pane manager not available")
1595- return nil, false
1652+ return nil, fmt.Errorf("stacked pane manager not available")
1653+ }
1654+ if c.panesUC == nil {
1655+ log.Warn().Msg("panes use case not available")
1656+ return nil, fmt.Errorf("panes use case not available")
1657+ }
1658+ if c.contentCoord == nil {
1659+ log.Warn().Msg("content coordinator not available")
1660+ return nil, fmt.Errorf("content coordinator not available")
1661+ }
1662+ if c.widgetFactory == nil {
1663+ log.Warn().Msg("widget factory not available")
1664+ return nil, fmt.Errorf("widget factory not available")
1665+ }
1666+ if c.getActiveWS == nil {
1667+ log.Warn().Msg("active workspace provider not configured")
1668+ return nil, fmt.Errorf("active workspace provider not configured")
15961669 }
15971670
15981671 ws, wsView := c.getActiveWS()
15991672 if ws == nil {
16001673 log.Warn().Msg("no active workspace")
1601- return nil, false
1674+ return nil, fmt.Errorf("no active workspace")
16021675 }
16031676
16041677 activeNode := ws.ActivePane()
16051678 if activeNode == nil || activeNode.Pane == nil {
16061679 log.Warn().Msg("no active pane")
1607- return nil, false
1680+ return nil, fmt.Errorf("no active pane")
16081681 }
16091682
16101683 if wsView == nil {
16111684 log.Warn().Msg("no workspace view")
1612- return nil, false
1685+ return nil, fmt.Errorf("no workspace view")
16131686 }
16141687
16151688 activePaneID := activeNode.Pane.ID
@@ -1627,7 +1700,7 @@ func (c *WorkspaceCoordinator) prepareStackPane(
16271700 activeNode: activeNode,
16281701 activePaneID: activePaneID,
16291702 originalTitle: originalTitle,
1630- }, true
1703+ }, nil
16311704 }
16321705
16331706 func (c *WorkspaceCoordinator) updateFirstStackTitle(
@@ -1942,7 +2015,14 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input conte
19422015
19432016 // Update UI
19442017 if wsView != nil {
1945- c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID)
2018+ if err := c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID); err != nil {
2019+ if rollbackErr := c.ClosePaneByID(ctx, input.PopupPane.ID); rollbackErr != nil {
2020+ log.Warn().Err(rollbackErr).Str("pane_id", string(input.PopupPane.ID)).Msg("failed to roll back popup split pane")
2021+ }
2022+ ws.ActivePaneID = input.ParentPaneID
2023+ c.notifyStateChanged()
2024+ return err
2025+ }
19462026 c.attachPopupWebView(ctx, wsView, input)
19472027 }
19482028
@@ -2057,6 +2137,7 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input con
20572137
20582138 // Revert stack conversion if we created a new stack
20592139 conversionInfo.revert()
2140+ c.notifyStateChanged()
20602141
20612142 return err
20622143 }
A internal/ui/coordinator/workspace_external_url_test.go +98 -0
@@ -0,0 +1,98 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/component"
10+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/bnema/puregotk/v4/gdk"
13+ "github.com/bnema/puregotk/v4/gtk"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestWorkspaceCoordinator_SplitWithURLUsesExplicitURLAndActivatesPane(t *testing.T) {
19+ ctx := context.Background()
20+ ids := []string{"pane-2", "split-1"}
21+ index := 0
22+ ws := entity.NewWorkspace("ws-1", entity.NewPane("pane-1"))
23+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
24+ PanesUC: usecase.NewManagePanesUseCase(func() string {
25+ id := ids[index]
26+ index++
27+ return id
28+ }, nil),
29+ NewPaneURL: "https://legacy.example/",
30+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
31+ return ws, nil
32+ },
33+ })
34+
35+ const explicitURL = "https://explicit.example/path?token=secret"
36+ require.NoError(t, coord.SplitWithURL(ctx, usecase.SplitLeft, explicitURL))
37+
38+ active := ws.ActivePane()
39+ require.NotNil(t, active)
40+ require.NotNil(t, active.Pane)
41+ assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID)
42+ assert.Equal(t, explicitURL, active.Pane.URI)
43+ assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
44+ assert.Equal(t, entity.PaneID("pane-2"), ws.Root.Children[0].Pane.ID)
45+}
46+
47+func TestWorkspaceCoordinator_SplitReturnsErrorWhenCreationTargetMissing(t *testing.T) {
48+ coord := NewWorkspaceCoordinator(context.Background(), WorkspaceCoordinatorConfig{})
49+ require.Error(t, coord.SplitWithURL(context.Background(), usecase.SplitRight, "https://example.com"))
50+}
51+
52+func TestWorkspaceCoordinator_StackPaneRollsBackAfterWebViewFailure(t *testing.T) {
53+ coord, ws := stackURLTestCoordinator(t, "https://legacy.example/")
54+
55+ require.Error(t, coord.StackPane(context.Background()))
56+ assert.Equal(t, entity.PaneID("pane-1"), ws.ActivePaneID)
57+ assert.Equal(t, 1, ws.PaneCount())
58+ assert.Empty(t, ws.ActivePane().Pane.URI)
59+}
60+
61+func TestWorkspaceCoordinator_StackPaneWithURLWithoutOmniboxRollsBackAfterWebViewFailure(t *testing.T) {
62+ coord, ws := stackURLTestCoordinator(t, "https://legacy.example/")
63+
64+ require.Error(t, coord.StackPaneWithURLWithoutOmnibox(context.Background(), "https://explicit.example/path?token=secret"))
65+ assert.Equal(t, entity.PaneID("pane-1"), ws.ActivePaneID)
66+ assert.Equal(t, 1, ws.PaneCount())
67+ assert.Empty(t, ws.ActivePane().Pane.URI)
68+}
69+
70+func stackURLTestCoordinator(t *testing.T, legacyURL string) (*WorkspaceCoordinator, *entity.Workspace) {
71+ t.Helper()
72+ if !gtk.InitCheck() || gdk.DisplayGetDefault() == nil {
73+ t.Skip("GTK native display prerequisite unavailable")
74+ }
75+ ctx := context.Background()
76+ factory := layout.NewGtkWidgetFactory()
77+ ws := entity.NewWorkspace("ws-1", entity.NewPane("pane-1"))
78+ wsView := component.NewWorkspaceView(ctx, factory)
79+ require.NoError(t, wsView.SetWorkspace(ctx, ws))
80+ ids := []string{"pane-2", "stack-1"}
81+ index := 0
82+ content := contentcoord.NewCoordinator(ctx, nil, nil, factory, nil, nil, nil, nil)
83+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
84+ PanesUC: usecase.NewManagePanesUseCase(func() string {
85+ id := ids[index]
86+ index++
87+ return id
88+ }, nil),
89+ WidgetFactory: factory,
90+ StackedPaneMgr: component.NewStackedPaneManager(factory),
91+ ContentCoord: content,
92+ NewPaneURL: legacyURL,
93+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
94+ return ws, wsView
95+ },
96+ })
97+ return coord, ws
98+}
M internal/ui/dispatcher/keyboard.go +92 -1
@@ -52,6 +52,7 @@ type KeyboardDispatcher struct {
5252 onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
5353 onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
5454 onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55+ onVimModePulse func(ctx context.Context, fast bool)
5556 onToggleHistorySidebar func(ctx context.Context) error
5657 onToggleFavoritesSidebar func(ctx context.Context) error
5758 onToggleCurrentFavorite func(ctx context.Context) error
@@ -156,6 +157,22 @@ func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context
156157 d.onOpenFloating = fn
157158 }
158159
160+// SetOnVimModePulse registers a callback for pane-local Vim mode pulse
161+// visual feedback triggered after a page scroll action.
162+// The cb receives a bool indicating whether the pulse should be "fast"
163+// (stronger/longer for up-fast/down-fast).
164+// If nil, pulse callbacks are no-ops.
165+func (d *KeyboardDispatcher) SetOnVimModePulse(cb func(ctx context.Context, fast bool)) {
166+ d.onVimModePulse = cb
167+}
168+
169+// triggerPulse calls the registered pulse callback if set, or no-ops otherwise.
170+func (d *KeyboardDispatcher) triggerPulse(ctx context.Context, fast bool) {
171+ if d.onVimModePulse != nil {
172+ d.onVimModePulse(ctx, fast)
173+ }
174+}
175+
159176 func (d *KeyboardDispatcher) initActionHandlers() {
160177 const (
161178 firstTabIndex = 0
@@ -249,6 +266,13 @@ func (d *KeyboardDispatcher) initActionHandlers() {
249266 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
250267 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
251268 input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
269+ // Vim mode scroll actions — single mapping drives tap pulse + command.
270+ input.ActionVimScrollLeft: d.vimScrollTapHandler(input.ActionVimScrollLeft),
271+ input.ActionVimScrollRight: d.vimScrollTapHandler(input.ActionVimScrollRight),
272+ input.ActionVimScrollUp: d.vimScrollTapHandler(input.ActionVimScrollUp),
273+ input.ActionVimScrollDown: d.vimScrollTapHandler(input.ActionVimScrollDown),
274+ input.ActionVimScrollUpFast: d.vimScrollTapHandler(input.ActionVimScrollUpFast),
275+ input.ActionVimScrollDownFast: d.vimScrollTapHandler(input.ActionVimScrollDownFast),
252276 // UI
253277 input.ActionOpenOmnibox: d.navCoord.OpenOmnibox,
254278 input.ActionOpenFind: d.handleFindOpen,
@@ -271,7 +295,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
271295 }
272296 return d.onToggleHistorySidebar(ctx)
273297 },
274- input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
298+ input.ActionToggleFavoritesSidebar: func(ctx context.Context) error {
275299 if d.onToggleFavoritesSidebar == nil {
276300 return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
277301 }
@@ -298,6 +322,73 @@ func (d *KeyboardDispatcher) initActionHandlers() {
298322 }
299323 }
300324
325+// DispatchVimScrollLifecycle routes autonomous held-key page scroll events.
326+// Stop deliberately avoids the normal action map so it emits neither another
327+// scroll tap nor visual pulse.
328+func (d *KeyboardDispatcher) DispatchVimScrollLifecycle(ctx context.Context, action input.Action, phase input.VimScrollPhase) error {
329+ spec, ok := vimScrollSpec(action)
330+ if !ok {
331+ return nil
332+ }
333+ return d.withActiveWebView(ctx, "page scroll lifecycle", func(wv port.WebView) error {
334+ if phase == input.VimScrollStop {
335+ return d.navCoord.StopPageScroll(ctx, wv)
336+ }
337+ return d.navCoord.ScrollWebViewContinuous(ctx, wv, spec.cmd)
338+ })
339+}
340+
341+type vimScrollActionSpec struct {
342+ cmd usecase.PageScrollCommand
343+ fast bool
344+}
345+
346+// vimScrollSpec is the single source of truth for Vim Mode action→command
347+// and fast/slow pulse used by tap handlers and held lifecycle routing.
348+func vimScrollSpec(action input.Action) (vimScrollActionSpec, bool) {
349+ switch action {
350+ case input.ActionVimScrollLeft:
351+ return vimScrollActionSpec{cmd: usecase.PageScrollLeft, fast: false}, true
352+ case input.ActionVimScrollRight:
353+ return vimScrollActionSpec{cmd: usecase.PageScrollRight, fast: false}, true
354+ case input.ActionVimScrollUp:
355+ return vimScrollActionSpec{cmd: usecase.PageScrollUp, fast: false}, true
356+ case input.ActionVimScrollDown:
357+ return vimScrollActionSpec{cmd: usecase.PageScrollDown, fast: false}, true
358+ case input.ActionVimScrollUpFast:
359+ return vimScrollActionSpec{cmd: usecase.PageScrollUpFast, fast: true}, true
360+ case input.ActionVimScrollDownFast:
361+ return vimScrollActionSpec{cmd: usecase.PageScrollDownFast, fast: true}, true
362+ default:
363+ return vimScrollActionSpec{}, false
364+ }
365+}
366+
367+func (d *KeyboardDispatcher) vimScrollTapHandler(action input.Action) func(context.Context) error {
368+ spec, ok := vimScrollSpec(action)
369+ if !ok {
370+ return func(context.Context) error { return nil }
371+ }
372+ label := "page scroll " + string(action)
373+ return func(ctx context.Context) error {
374+ return d.withActiveWebView(ctx, label, func(wv port.WebView) error {
375+ if err := d.navCoord.ScrollWebView(ctx, wv, spec.cmd); err != nil {
376+ return err
377+ }
378+ d.triggerPulse(ctx, spec.fast)
379+ return nil
380+ })
381+ }
382+}
383+
384+func vimScrollCommand(action input.Action) (usecase.PageScrollCommand, bool) {
385+ spec, ok := vimScrollSpec(action)
386+ if !ok {
387+ return 0, false
388+ }
389+ return spec.cmd, true
390+}
391+
301392 // Dispatch routes a keyboard action to the appropriate coordinator.
302393 func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
303394 log := logging.FromContext(ctx)
M internal/ui/dispatcher/keyboard_test.go +185 -3
@@ -5,6 +5,9 @@ import (
55 "fmt"
66 "testing"
77
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/bnema/dumber/internal/application/usecase"
811 "github.com/bnema/dumber/internal/domain/entity"
912 "github.com/bnema/dumber/internal/ui/coordinator"
1013 "github.com/bnema/dumber/internal/ui/input"
@@ -119,19 +122,19 @@ func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErro
119122 ctx := context.Background()
120123 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
121124
122- missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
125+ missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
123126 require.Error(t, missingErr)
124127 require.ErrorContains(t, missingErr, "favorites sidebar unavailable")
125128
126129 wantErr := fmt.Errorf("favorites failed")
127130 d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
128- err := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
131+ err := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
129132 require.Error(t, err)
130133 require.ErrorIs(t, err, wantErr)
131134
132135 var called bool
133136 d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })
134- require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSystemView))
137+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSidebar))
135138 assert.True(t, called)
136139 }
137140
@@ -202,3 +205,182 @@ func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
202205 })
203206 }
204207 }
208+
209+type mockScrollableWebView struct {
210+ *mocks.MockWebView
211+ *mocks.MockPageScrollable
212+}
213+
214+type lifecycleScrollableWebView struct {
215+ *mockScrollableWebView
216+ cancelCalls int
217+}
218+
219+func (wv *lifecycleScrollableWebView) CancelPageScroll(context.Context) {
220+ wv.cancelCalls++
221+}
222+
223+func TestKeyboardDispatcher_VimModeActionsRouteToCorrectScrollCommand(t *testing.T) {
224+ ctx := context.Background()
225+
226+ tests := []struct {
227+ action input.Action
228+ command port.PageScrollCommand
229+ expectedDx int
230+ expectedDy int
231+ }{
232+ {input.ActionVimScrollLeft, port.PageScrollCommandLeft, -80, 0},
233+ {input.ActionVimScrollRight, port.PageScrollCommandRight, 80, 0},
234+ {input.ActionVimScrollUp, port.PageScrollCommandUp, 0, -80},
235+ {input.ActionVimScrollDown, port.PageScrollCommandDown, 0, 80},
236+ {input.ActionVimScrollUpFast, port.PageScrollCommandUpFast, 0, -320},
237+ {input.ActionVimScrollDownFast, port.PageScrollCommandDownFast, 0, 320},
238+ }
239+
240+ for _, tc := range tests {
241+ t.Run(string(tc.action), func(t *testing.T) {
242+ base := mocks.NewMockWebView(t)
243+ scroller := mocks.NewMockPageScrollable(t)
244+ wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}
245+
246+ navCoord := &coordinator.NavigationCoordinator{}
247+ navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
248+
249+ d := NewKeyboardDispatcher(
250+ ctx,
251+ &coordinator.WorkspaceCoordinator{},
252+ navCoord,
253+ nil,
254+ nil,
255+ KeyboardActions{
256+ ActiveWebView: func(context.Context) port.WebView { return wv },
257+ },
258+ func(context.Context) entity.PaneID { return "" },
259+ )
260+
261+ base.EXPECT().ID().Return(port.WebViewID(42)).Once()
262+ req := port.PageScrollRequest{Command: tc.command, FallbackDX: tc.expectedDx, FallbackDY: tc.expectedDy}
263+ scroller.EXPECT().ScrollPage(ctx, req).Return(nil).Once()
264+
265+ err := d.Dispatch(ctx, tc.action)
266+ require.NoError(t, err)
267+ })
268+ }
269+}
270+
271+func TestKeyboardDispatcher_VimScrollLifecycleRoutesContinuousAndStop(t *testing.T) {
272+ ctx := context.Background()
273+ base := mocks.NewMockWebView(t)
274+ scroller := mocks.NewMockPageScrollable(t)
275+ wv := &lifecycleScrollableWebView{mockScrollableWebView: &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}}
276+ navCoord := &coordinator.NavigationCoordinator{}
277+ navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
278+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, navCoord, nil, nil, KeyboardActions{
279+ ActiveWebView: func(context.Context) port.WebView { return wv },
280+ }, func(context.Context) entity.PaneID { return "" })
281+
282+ scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{
283+ Command: port.PageScrollCommandDown, FallbackDY: 80, Continuous: true,
284+ }).Return(nil).Once()
285+ require.NoError(t, d.DispatchVimScrollLifecycle(ctx, input.ActionVimScrollDown, input.VimScrollContinuous))
286+ require.NoError(t, d.DispatchVimScrollLifecycle(ctx, input.ActionVimScrollDown, input.VimScrollStop))
287+ assert.Equal(t, 1, wv.cancelCalls)
288+}
289+
290+func TestKeyboardDispatcher_VimModeNoopWhenNoActiveWebView(t *testing.T) {
291+ ctx := context.Background()
292+
293+ navCoord := &coordinator.NavigationCoordinator{}
294+ navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
295+
296+ d := NewKeyboardDispatcher(
297+ ctx,
298+ &coordinator.WorkspaceCoordinator{},
299+ navCoord,
300+ nil,
301+ nil,
302+ KeyboardActions{},
303+ func(context.Context) entity.PaneID { return "" },
304+ )
305+
306+ // No ActiveWebView set — dispatcher should no-op cleanly
307+ err := d.Dispatch(ctx, input.ActionVimScrollDown)
308+ require.NoError(t, err)
309+}
310+
311+func TestKeyboardDispatcher_VimModeNoopWhenActiveWebViewReturnsNil(t *testing.T) {
312+ ctx := context.Background()
313+
314+ navCoord := &coordinator.NavigationCoordinator{}
315+ navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
316+
317+ d := NewKeyboardDispatcher(
318+ ctx,
319+ &coordinator.WorkspaceCoordinator{},
320+ navCoord,
321+ nil,
322+ nil,
323+ KeyboardActions{
324+ ActiveWebView: func(context.Context) port.WebView { return nil },
325+ },
326+ func(context.Context) entity.PaneID { return "" },
327+ )
328+
329+ err := d.Dispatch(ctx, input.ActionVimScrollDown)
330+ require.NoError(t, err)
331+}
332+
333+func TestVimScrollSpec_IsSingleSourceForCommandAndPulse(t *testing.T) {
334+ tests := []struct {
335+ action input.Action
336+ cmd usecase.PageScrollCommand
337+ fast bool
338+ }{
339+ {input.ActionVimScrollLeft, usecase.PageScrollLeft, false},
340+ {input.ActionVimScrollRight, usecase.PageScrollRight, false},
341+ {input.ActionVimScrollUp, usecase.PageScrollUp, false},
342+ {input.ActionVimScrollDown, usecase.PageScrollDown, false},
343+ {input.ActionVimScrollUpFast, usecase.PageScrollUpFast, true},
344+ {input.ActionVimScrollDownFast, usecase.PageScrollDownFast, true},
345+ }
346+ for _, tc := range tests {
347+ spec, ok := vimScrollSpec(tc.action)
348+ require.True(t, ok, string(tc.action))
349+ assert.Equal(t, tc.cmd, spec.cmd)
350+ assert.Equal(t, tc.fast, spec.fast)
351+ cmd, ok := vimScrollCommand(tc.action)
352+ require.True(t, ok)
353+ assert.Equal(t, tc.cmd, cmd)
354+ }
355+ _, ok := vimScrollSpec(input.ActionQuit)
356+ assert.False(t, ok)
357+}
358+
359+func TestKeyboardDispatcher_VimScrollTapUsesSharedSpecPulse(t *testing.T) {
360+ ctx := context.Background()
361+ base := mocks.NewMockWebView(t)
362+ scroller := mocks.NewMockPageScrollable(t)
363+ wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}
364+ navCoord := &coordinator.NavigationCoordinator{}
365+ navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
366+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, navCoord, nil, nil, KeyboardActions{
367+ ActiveWebView: func(context.Context) port.WebView { return wv },
368+ }, func(context.Context) entity.PaneID { return "" })
369+
370+ var pulses []bool
371+ d.SetOnVimModePulse(func(_ context.Context, fast bool) {
372+ pulses = append(pulses, fast)
373+ })
374+
375+ base.EXPECT().ID().Return(port.WebViewID(7)).Twice()
376+ scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{
377+ Command: port.PageScrollCommandDown, FallbackDY: 80,
378+ }).Return(nil).Once()
379+ scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{
380+ Command: port.PageScrollCommandDownFast, FallbackDY: 320,
381+ }).Return(nil).Once()
382+
383+ require.NoError(t, d.Dispatch(ctx, input.ActionVimScrollDown))
384+ require.NoError(t, d.Dispatch(ctx, input.ActionVimScrollDownFast))
385+ assert.Equal(t, []bool{false, true}, pulses)
386+}
A internal/ui/external_link_routing_test.go +137 -0
@@ -0,0 +1,137 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port/mocks"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/shared/syncdispatch"
12+ "github.com/bnema/dumber/internal/ui/component"
13+ "github.com/bnema/dumber/internal/ui/coordinator"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestExternalLinkSplitDirectionPlacements(t *testing.T) {
19+ tests := map[entity.ExternalLinkPlacement]usecase.SplitDirection{
20+ "": usecase.SplitRight,
21+ entity.ExternalLinkPlacementLeft: usecase.SplitLeft,
22+ entity.ExternalLinkPlacementRight: usecase.SplitRight,
23+ entity.ExternalLinkPlacementTop: usecase.SplitUp,
24+ entity.ExternalLinkPlacementBottom: usecase.SplitDown,
25+ }
26+ for placement, want := range tests {
27+ t.Run(string(placement), func(t *testing.T) {
28+ assert.Equal(t, want, externalLinkSplitDirection(placement))
29+ })
30+ }
31+}
32+
33+func TestApp_OpenExternalURLTabbedTargetsLastFocusedWindowAndPreservesURL(t *testing.T) {
34+ ctx := context.Background()
35+ const originalURL = "https://example.com/path?token=secret#fragment"
36+ active := entity.NewTab("tab-1", "ws-1", entity.NewPane("pane-1"))
37+ tabs := entity.NewTabList()
38+ tabs.Add(active)
39+ focused := &browserWindow{id: "focused", tabs: tabs}
40+
41+ ids := []string{"tab-2", "ws-2", "pane-2"}
42+ index := 0
43+ app := &App{
44+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}),
45+ tabsUC: usecase.NewManageTabsUseCase(func() string { id := ids[index]; index++; return id }, nil),
46+ browserWindows: map[string]*browserWindow{
47+ focused.id: focused,
48+ },
49+ browserWindowOrder: []string{focused.id},
50+ lastFocusedWindowID: focused.id,
51+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
52+ windowForTab: map[entity.TabID]*browserWindow{},
53+ }
54+ app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC})
55+ app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
56+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
57+ app.setBrowserWindowForTab(tab.ID, focused)
58+ })
59+
60+ require.NoError(t, app.OpenExternalURL(ctx, originalURL))
61+ require.Len(t, tabs.Tabs, 2)
62+ created := tabs.ActiveTab()
63+ require.NotNil(t, created)
64+ require.NotNil(t, created.Workspace)
65+ assert.Equal(t, originalURL, created.Workspace.ActivePane().Pane.URI)
66+ assert.Same(t, focused, app.browserWindowForTab(created.ID))
67+}
68+
69+func TestApp_OpenExternalURLSnapshotsRuntimeConfigOnce(t *testing.T) {
70+ ctx := context.Background()
71+ provider := mocks.NewMockRuntimeConfigProvider(t)
72+ provider.EXPECT().Current().Return(entity.RuntimeConfigSnapshot{UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorWindowed}}}}).Once()
73+ var factoryCalls int
74+ var dispatchCalls int
75+ app := &App{
76+ runtimeConfig: newRuntimeConfigState(provider),
77+ dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
78+ dispatchCalls++
79+ fn()
80+ return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchInline}
81+ },
82+ browserWindowFactory: func(_ context.Context, _ string) (*browserWindow, error) {
83+ factoryCalls++
84+ return &browserWindow{id: "windowed", tabs: entity.NewTabList()}, nil
85+ },
86+ browserWindows: map[string]*browserWindow{},
87+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
88+ windowForTab: map[entity.TabID]*browserWindow{},
89+ }
90+
91+ require.NoError(t, app.OpenExternalURL(ctx, "https://example.com"))
92+ assert.Equal(t, 1, dispatchCalls)
93+ assert.Equal(t, 1, factoryCalls)
94+}
95+
96+func TestApp_OpenExternalURLFallsBackWhenLastFocusedWindowIsStale(t *testing.T) {
97+ ctx := context.Background()
98+ const originalURL = "https://example.com/stale?token=secret"
99+ staleTab := entity.NewTab("stale-tab", "stale-workspace", entity.NewPane("stale-pane"))
100+ staleTabs := entity.NewTabList()
101+ staleTabs.Add(staleTab)
102+ stale := &browserWindow{id: "stale", tabs: staleTabs}
103+ var factoryURL string
104+ app := &App{
105+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}),
106+ browserWindows: map[string]*browserWindow{},
107+ lastFocusedWindowID: stale.id,
108+ windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: stale},
109+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
110+ browserWindowFactory: func(_ context.Context, url string) (*browserWindow, error) {
111+ factoryURL = url
112+ return &browserWindow{id: "fallback", tabs: entity.NewTabList()}, nil
113+ },
114+ }
115+
116+ require.NoError(t, app.OpenExternalURL(ctx, originalURL))
117+ assert.Equal(t, originalURL, factoryURL)
118+}
119+
120+func TestApp_OpenExternalURLFallbackPreservesOriginalURL(t *testing.T) {
121+ ctx := context.Background()
122+ const originalURL = "https://example.com/path?token=secret#fragment"
123+ var factoryURL string
124+ app := &App{
125+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorSplit, Placement: entity.ExternalLinkPlacementLeft}}}),
126+ browserWindowFactory: func(_ context.Context, url string) (*browserWindow, error) {
127+ factoryURL = url
128+ return &browserWindow{id: "fallback", tabs: entity.NewTabList()}, nil
129+ },
130+ browserWindows: map[string]*browserWindow{},
131+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
132+ windowForTab: map[entity.TabID]*browserWindow{},
133+ }
134+
135+ require.NoError(t, app.OpenExternalURL(ctx, originalURL))
136+ assert.Equal(t, originalURL, factoryURL)
137+}
M internal/ui/focus/borders.go +8 -0
@@ -61,6 +61,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
6161 bm.borderOverlay.RemoveCssClass(tabModeClass)
6262 case input.ModeSession:
6363 bm.borderOverlay.RemoveCssClass(sessionModeClass)
64+ case input.ModeVim:
65+ // Vim mode never activates the global border overlay.
66+ // No class to remove.
6467 }
6568
6669 // Add new mode class and set visibility
@@ -74,6 +77,11 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
7477 case input.ModeSession:
7578 bm.borderOverlay.AddCssClass(sessionModeClass)
7679 bm.borderOverlay.SetVisible(true)
80+ case input.ModeVim:
81+ // Vim mode must not activate the workspace-wide border overlay.
82+ // The pane-local vim-mode-active CSS class and indicator are
83+ // managed by PaneView.SetVimMode / app handleModeChange.
84+ bm.borderOverlay.SetVisible(false)
7785 case input.ModeNormal:
7886 bm.borderOverlay.SetVisible(false)
7987 default:
M internal/ui/input/global_shortcuts.go +41 -17
@@ -31,7 +31,6 @@ type GlobalShortcutHandler struct {
3131 shortcutRefs map[string]globalShortcutRegistration
3232 lastDispatchAt map[Action]time.Time
3333 heldShortcuts map[globalShortcutHoldKey]struct{}
34- shortcutAction *gtk.NamedAction
3534 globalAction *gio.SimpleAction
3635 globalActionCb func(gio.SimpleAction, uintptr)
3736 globalActionHandlerID uint
@@ -113,10 +112,16 @@ func NewGlobalShortcutHandler(
113112 }
114113 }
115114
116- // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.)
115+ // Register app-reserved shortcuts (Ctrl+L, Ctrl+F, other mode activations, etc.)
117116 // so they work even when WebView has focus.
117+ // Vim mode activation is intentionally excluded here: it must flow through
118+ // the normal KeyboardHandler path so editable page contexts can pass Ctrl+Y
119+ // through instead of having a global shortcut controller consume it first.
118120 shortcuts := NewShortcutSet(ctx, workspace, session)
119121 for binding, action := range shortcuts.Global {
122+ if !shouldRegisterGTKGlobalShortcut(action) {
123+ continue
124+ }
120125 if _, exists := h.registered[binding]; exists {
121126 continue
122127 }
@@ -139,7 +144,6 @@ func (h *GlobalShortcutHandler) configureGlobalControllers() {
139144 // Set global scope - this is the key to making shortcuts work
140145 // even when WebView has focus.
141146 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
142- h.shortcutAction = gtk.NewNamedAction(globalShortcutActionFullName)
143147 h.globalAction = gio.NewSimpleAction(globalShortcutActionName, glib.NewVariantType("s"))
144148 if h.globalAction != nil {
145149 h.globalActionCb = func(_ gio.SimpleAction, parameter uintptr) {
@@ -212,23 +216,26 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
212216 return false
213217 }
214218
215- if h.shortcutAction == nil {
219+ // Create trigger for this key combination.
220+ trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
221+ if trigger == nil {
216222 logging.FromContext(h.ctx).Error().
217223 Uint("keyval", keyval).
218- Msg("global shortcut action is unavailable")
224+ Msg("failed to create keyval trigger")
219225 return false
220226 }
221227
222- // Create trigger for this key combination.
223- trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
224- if trigger == nil {
228+ // GtkShortcut takes ownership of its action. Each shortcut needs its own
229+ // named action even though they all dispatch through the same window action.
230+ shortcutAction := gtk.NewNamedAction(globalShortcutActionFullName)
231+ if shortcutAction == nil {
225232 logging.FromContext(h.ctx).Error().
226233 Uint("keyval", keyval).
227- Msg("failed to create keyval trigger")
234+ Msg("failed to create global shortcut action")
228235 return false
229236 }
230237
231- shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &h.shortcutAction.ShortcutAction)
238+ shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
232239 if shortcut == nil {
233240 logging.FromContext(h.ctx).Error().
234241 Uint("keyval", keyval).
@@ -292,6 +299,14 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action,
292299 Msg("inactive window global shortcut callback ignored")
293300 return false
294301 }
302+ if h.kbHandler != nil && shouldIgnoreGlobalShortcutInMode(h.kbHandler.Mode(), actionToDispatch) {
303+ log.Trace().
304+ Str("action", string(actionToDispatch)).
305+ Str("shortcut", formatBinding(bindingForLog)).
306+ Str("mode", h.kbHandler.Mode().String()).
307+ Msg("global shortcut ignored in current modal mode")
308+ return true
309+ }
295310 eventInfo := h.inspectCurrentShortcutEvent()
296311 if !shouldDispatchGlobalShortcutEvent(eventInfo) {
297312 appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
@@ -318,8 +333,7 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action,
318333 // Mode-enter/exit actions go through KeyboardHandler for modal state.
319334 if isModeAction(actionToDispatch) {
320335 if h.kbHandler != nil {
321- h.kbHandler.DispatchAction(actionToDispatch)
322- return true
336+ return h.kbHandler.DispatchAction(actionToDispatch)
323337 }
324338 log.Warn().
325339 Str("action", string(actionToDispatch)).
@@ -481,6 +495,14 @@ func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, act
481495 return evt
482496 }
483497
498+func shouldRegisterGTKGlobalShortcut(action Action) bool {
499+ return action != ActionEnterVimMode
500+}
501+
502+func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool {
503+ return mode == ModeVim && action != ActionEnterVimMode
504+}
505+
484506 func globalShortcutActionMap() map[string]Action {
485507 return map[string]Action{
486508 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -495,8 +517,8 @@ func globalShortcutActionMap() map[string]Action {
495517 "consume-or-expel-down": ActionConsumeOrExpelDown,
496518 "toggle_history_systemview": ActionToggleHistorySystemView,
497519 "toggle-history-systemview": ActionToggleHistorySystemView,
498- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
499- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
520+ "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
521+ "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
500522 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
501523 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
502524 "toggle_config_systemview": ActionToggleConfigSystemView,
@@ -563,6 +585,9 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
563585
564586 shortcuts := NewShortcutSet(ctx, workspace, session)
565587 for binding, action := range shortcuts.Global {
588+ if !shouldRegisterGTKGlobalShortcut(action) {
589+ continue
590+ }
566591 if _, exists := h.registered[binding]; exists {
567592 continue
568593 }
@@ -610,7 +635,6 @@ func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
610635 }
611636 h.controller = nil
612637 h.releaseController = nil
613- h.shortcutAction = nil
614638 h.globalAction = nil
615639 h.globalActionCb = nil
616640 h.globalActionHandlerID = 0
@@ -709,7 +733,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
709733 ActionToggleFullscreen,
710734 ActionToggleFloatingPane,
711735 ActionToggleHistorySystemView,
712- ActionToggleFavoritesSystemView,
736+ ActionToggleFavoritesSidebar,
713737 ActionToggleCurrentPageFavorite,
714738 ActionToggleConfigSystemView,
715739 ActionCopyURL,
@@ -816,7 +840,7 @@ func formatEventType(eventType gdk.EventType) string {
816840
817841 func isModeAction(action Action) bool {
818842 switch action {
819- case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:
843+ case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionExitMode:
820844 return true
821845 default:
822846 return false
M internal/ui/input/global_shortcuts_test.go +85 -4
@@ -1,6 +1,7 @@
11 package input
22
33 import (
4+ "context"
45 "testing"
56 "time"
67
@@ -9,6 +10,27 @@ import (
910 "github.com/bnema/puregotk/v4/gtk"
1011 )
1112
13+func TestShouldRegisterGTKGlobalShortcut_ExcludesVimModeActivation(t *testing.T) {
14+ if shouldRegisterGTKGlobalShortcut(ActionEnterVimMode) {
15+ t.Fatal("ActionEnterVimMode should stay off the GTK global shortcut controller")
16+ }
17+ if !shouldRegisterGTKGlobalShortcut(ActionOpenOmnibox) {
18+ t.Fatal("non-vim-mode global shortcuts should remain registerable")
19+ }
20+}
21+
22+func TestShouldIgnoreGlobalShortcutInMode_VimModeBlocksNonToggleActions(t *testing.T) {
23+ if !shouldIgnoreGlobalShortcutInMode(ModeVim, ActionOpenOmnibox) {
24+ t.Fatal("Vim mode should ignore app-global shortcuts like omnibox")
25+ }
26+ if shouldIgnoreGlobalShortcutInMode(ModeNormal, ActionOpenOmnibox) {
27+ t.Fatal("normal mode should not ignore app-global shortcuts")
28+ }
29+ if shouldIgnoreGlobalShortcutInMode(ModeVim, ActionEnterVimMode) {
30+ t.Fatal("vim mode toggle should not be blocked by the mode guard")
31+ }
32+}
33+
1234 func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) {
1335 h := &GlobalShortcutHandler{
1436 registered: make(map[KeyBinding]Action),
@@ -33,6 +55,68 @@ func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *test
3355 }
3456 }
3557
58+func TestGlobalShortcutHandlerUsesDistinctNamedActionsAcrossReloads(t *testing.T) {
59+ if !gtk.InitCheck() {
60+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
61+ }
62+ display := gdk.DisplayGetDefault()
63+ if display == nil {
64+ t.Skip("GTK native display prerequisite unavailable (no default GDK display)")
65+ }
66+ appID := "com.dumber.GlobalShortcutHandlerTest"
67+ app := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
68+ if app == nil {
69+ t.Fatal("gtk application creation failed")
70+ }
71+ registered, err := app.Register(nil)
72+ if err != nil || !registered {
73+ t.Fatalf("gtk application registration failed: registered=%t err=%v", registered, err)
74+ }
75+ window := gtk.NewApplicationWindow(app)
76+ if window == nil {
77+ t.Fatal("gtk application window creation failed")
78+ }
79+ h := NewGlobalShortcutHandler(context.Background(), window, nil, nil, nil, nil)
80+ if h == nil {
81+ t.Fatal("global shortcut handler creation failed")
82+ }
83+ t.Cleanup(func() {
84+ h.Detach()
85+ window.Destroy()
86+ app.Unref()
87+ })
88+
89+ assertDistinctShortcutActions := func() {
90+ t.Helper()
91+ shortcutCount := h.controller.GetNItems()
92+ if shortcutCount < 2 {
93+ t.Fatalf("shortcut count = %d, want at least 2", shortcutCount)
94+ }
95+ actions := make(map[uintptr]struct{}, shortcutCount)
96+ for index := uint(0); index < shortcutCount; index++ {
97+ shortcut := gtk.ShortcutNewFromInternalPtr(h.controller.GetItem(index))
98+ if shortcut == nil {
99+ t.Fatalf("shortcut %d was nil", index)
100+ }
101+ action := shortcut.GetAction()
102+ if action == nil {
103+ t.Fatalf("shortcut %d action was nil", index)
104+ }
105+ actions[action.GoPointer()] = struct{}{}
106+ shortcut.Unref()
107+ }
108+ if got := uint(len(actions)); got != shortcutCount {
109+ t.Fatalf("distinct shortcut actions = %d, want %d", got, shortcutCount)
110+ }
111+ }
112+
113+ assertDistinctShortcutActions()
114+ h.ReloadShortcuts(context.Background(), nil, nil)
115+ assertDistinctShortcutActions()
116+ h.ReloadShortcuts(context.Background(), nil, nil)
117+ assertDistinctShortcutActions()
118+}
119+
36120 func TestGlobalShortcutIDIncludesBinding(t *testing.T) {
37121 first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
38122 second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
@@ -153,9 +237,6 @@ func TestGlobalShortcutHandlerDetachForDestroyClearsRetainedState(t *testing.T)
153237 if h.releaseController != nil {
154238 t.Fatal("releaseController should be cleared during destroy detach")
155239 }
156- if h.shortcutAction != nil {
157- t.Fatal("shortcutAction should be cleared during destroy detach")
158- }
159240 if h.globalAction != nil {
160241 t.Fatal("globalAction should be cleared during destroy detach")
161242 }
@@ -230,7 +311,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
230311 ActionFindPrev,
231312 ActionOpenDevTools,
232313 ActionToggleFloatingPane,
233- ActionToggleFavoritesSystemView,
314+ ActionToggleFavoritesSidebar,
234315 ActionToggleCurrentPageFavorite,
235316 ActionToggleConfigSystemView,
236317 ActionCopyURL,
M internal/ui/input/keyboard.go +373 -22
@@ -36,6 +36,28 @@ import (
3636 // Return an error if the action fails.
3737 type ActionHandler func(ctx context.Context, action Action) error
3838
39+// VimScrollPhase identifies held-scroll lifecycle events. It is internal input
40+// state, not a configurable action or shortcut.
41+type VimScrollPhase uint8
42+
43+const (
44+ VimScrollContinuous VimScrollPhase = iota
45+ VimScrollStop
46+)
47+
48+// VimScrollLifecycleHandler receives autonomous held-scroll ticks and the
49+// matching stop event without changing the normal configurable action API.
50+type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error
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+
56+const (
57+ vimScrollHoldDelay = 250 * time.Millisecond
58+ vimScrollCadence = 16 * time.Millisecond
59+)
60+
3961 // AccentHandler handles long-press accent detection.
4062 // Called for character keys that may have accent variants.
4163 type AccentHandler interface {
@@ -58,8 +80,10 @@ type KeyboardHandler struct {
5880 workspace *entity.WorkspaceConfig
5981 session *entity.SessionConfig
6082
61- // Action handler callback
62- onAction ActionHandler
83+ // Action handler callbacks.
84+ onAction ActionHandler
85+ onVimScrollLifecycle VimScrollLifecycleHandler
86+ onPageFocusNavigation PageFocusNavigationHandler
6387 // Optional routing callback that determines how a key should be handled.
6488 // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused
6589 // widget handle it), or RouteAccentDetection (long-press accent for GTK entries).
@@ -68,6 +92,9 @@ type KeyboardHandler struct {
6892 accentHandler AccentHandler
6993 // Optional escape hook for app-level overlays
7094 onEscape func(ctx context.Context) bool
95+ // Optional hook that blocks Vim mode activation and lets the original key
96+ // event pass through to the focused widget/page instead.
97+ vimModeActivationPassthrough func() bool
7198
7299 // GTK controller (nil until attached)
73100 controller *gtk.EventControllerKey
@@ -82,6 +109,18 @@ type KeyboardHandler struct {
82109 activePressedActions map[Action]uint
83110 ctx context.Context
84111 mu sync.RWMutex
112+
113+ vimScrollRepeatAction Action
114+ vimScrollRepeatKeyval uint
115+ vimScrollRepeatTimer uint
116+ vimScrollRepeatGeneration uint64
117+ vimScrollRepeatPressedAt time.Time
118+ vimScrollRepeatCb glib.SourceFunc
119+ vimScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint
120+ vimScrollRepeatRemove func(id uint) bool
121+ vimScrollNow func() time.Time
122+
123+ seq vimModeSequenceState
85124 }
86125
87126 // NewKeyboardHandler creates a new keyboard handler.
@@ -90,14 +129,26 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
90129
91130 log.Debug().Msg("creating keyboard handler")
92131
132+ shortcuts := NewShortcutSet(ctx, workspace, session)
133+ seqTimeout := time.Duration(0)
134+ if workspace != nil {
135+ seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond
136+ }
93137 h := &KeyboardHandler{
94- shortcuts: NewShortcutSet(ctx, workspace, session),
138+ shortcuts: shortcuts,
95139 modal: NewModalState(ctx),
96140 workspace: workspace,
97141 session: session,
98142 activePressedActions: make(map[Action]uint),
99143 ctx: ctx,
144+ vimScrollRepeatAdd: func(intervalMS uint, cb *glib.SourceFunc) uint {
145+ return glib.TimeoutAdd(intervalMS, cb, 0)
146+ },
147+ vimScrollRepeatRemove: glib.SourceRemove,
148+ vimScrollNow: time.Now,
149+ seq: newVimModeSequenceState(shortcuts.VimModeSequences(), seqTimeout),
100150 }
151+ h.SetOnModeChange(nil)
101152
102153 return h
103154 }
@@ -109,11 +160,35 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
109160 h.onAction = fn
110161 }
111162
163+// SetOnVimScrollLifecycle sets the callback for autonomous held-scroll ticks
164+// and stop notification. Tap actions continue through SetOnAction.
165+func (h *KeyboardHandler) SetOnVimScrollLifecycle(fn VimScrollLifecycleHandler) {
166+ h.mu.Lock()
167+ defer h.mu.Unlock()
168+ h.onVimScrollLifecycle = fn
169+}
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+
179+// SetVimModeActivationPassthrough sets the callback that can block Vim mode
180+// activation and let the original key event propagate instead.
181+func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) {
182+ h.mu.Lock()
183+ defer h.mu.Unlock()
184+ h.vimModeActivationPassthrough = fn
185+}
186+
112187 // ReloadShortcuts rebuilds the shortcut set from new config values.
113188 // This enables hot-reloading of keybindings without restarting.
114189 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
190+ h.stopVimScrollRepeat()
115191 h.mu.Lock()
116- defer h.mu.Unlock()
117192
118193 log := logging.FromContext(ctx)
119194 log.Debug().Msg("reloading shortcuts from config")
@@ -121,6 +196,14 @@ func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity
121196 h.shortcuts = NewShortcutSet(ctx, workspace, session)
122197 h.workspace = workspace
123198 h.session = session
199+ seqTimeout := time.Duration(0)
200+ if workspace != nil {
201+ seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond
202+ }
203+ h.mu.Unlock()
204+
205+ h.InvalidateSequenceMatcher()
206+ h.SetSequenceTimeout(seqTimeout)
124207 }
125208
126209 // SetOnModeChange sets the callback for mode changes (for UI updates).
@@ -134,6 +217,11 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
134217 } else if from == ModeNormal {
135218 h.setControllerPhase(gtk.PhaseCaptureValue)
136219 }
220+ if to != ModeVim {
221+ h.stopVimScrollRepeat()
222+ // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry).
223+ h.resetPendingSequence(false)
224+ }
137225 // Forward to app-level callback
138226 if fn != nil {
139227 fn(from, to)
@@ -212,16 +300,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
212300 // so app shortcuts still work.
213301 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)
214302
215- // Wire GTK main thread scheduler for modal timeouts. Timer goroutines
216- // must dispatch ExitMode to the GTK thread because onModeChange may
217- // call setControllerPhase (a GTK operation).
218- h.modal.SetMainThreadScheduler(func(fn func()) {
219- cb := glib.SourceFunc(func(_ uintptr) bool {
220- fn()
221- return false
222- })
223- glib.IdleAdd(&cb, 0)
224- })
303+ // Wire GTK main thread schedulers for modal and sequence ambiguity timeouts.
304+ // Timer goroutines must dispatch back to the GTK thread because callbacks
305+ // may touch GTK widgets / EventController phase.
306+ h.installGTKMainThreadSchedulers()
225307
226308 // Connect key pressed handler (retain callback to prevent GC).
227309 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
@@ -243,6 +325,24 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
243325 log.Debug().Msg("keyboard handler attached to window")
244326 }
245327
328+// installGTKMainThreadSchedulers routes modal and sequence timer callbacks
329+// through glib.IdleAdd so they run on the GTK main thread.
330+func (h *KeyboardHandler) installGTKMainThreadSchedulers() {
331+ if h == nil {
332+ return
333+ }
334+ schedule := func(fn func()) {
335+ cb := glib.SourceFunc(func(_ uintptr) bool {
336+ fn()
337+ return false
338+ })
339+ glib.IdleAdd(&cb, 0)
340+ }
341+ h.modal.SetMainThreadScheduler(schedule)
342+ h.SetSequenceMainThreadScheduler(schedule)
343+ logging.FromContext(h.ctx).Debug().Msg("gtk main-thread schedulers installed for modal and vim-mode sequences")
344+}
345+
246346 // Detach removes the keyboard handler from a live GTK window and releases its
247347 // retained GTK signal callbacks.
248348 func (h *KeyboardHandler) Detach() {
@@ -257,8 +357,11 @@ func (h *KeyboardHandler) DetachForDestroy() {
257357 }
258358
259359 func (h *KeyboardHandler) detach(removeController bool) {
360+ h.stopVimScrollRepeat()
361+ // Sequence teardown uses seq.mu only; keep it outside h.mu to preserve
362+ // the existing h.mu → (never seq) / seq.mu standalone lock order.
363+ h.teardownSequenceState()
260364 h.mu.Lock()
261- defer h.mu.Unlock()
262365 if h.controller != nil && h.keyPressedHandlerID != 0 {
263366 h.controller.DisconnectSignal(h.keyPressedHandlerID)
264367 }
@@ -275,6 +378,7 @@ func (h *KeyboardHandler) detach(removeController bool) {
275378 h.keyPressedHandlerID = 0
276379 h.keyReleasedHandlerID = 0
277380 h.activePressedActions = nil
381+ h.mu.Unlock()
278382 }
279383
280384 // handleKeyPress processes a key press event.
@@ -294,17 +398,38 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
294398 }
295399
296400 modifiers := Modifier(state) & modifierMask
401+ mode := h.modal.Mode()
297402
298403 // Escape in normal mode: check app-level escape hook first
299- if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
404+ if mode == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
300405 if onEscape != nil && onEscape(h.ctx) {
301406 return true
302407 }
303408 }
304409
410+ // Escape / Enter should always leave modal modes even if the current config
411+ // omits explicit confirm/cancel bindings.
412+ if mode != ModeNormal && modifiers == 0 {
413+ switch keyval {
414+ case uint(gdk.KEY_Escape), uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
415+ if mode == ModeVim {
416+ h.stopVimScrollRepeat()
417+ }
418+ h.modal.ExitMode(h.ctx)
419+ return true
420+ }
421+ }
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+
305430 // Determine routing for this key event
306431 route := RouteHandleShortcuts // default: process through shortcut system
307- if routeKey != nil && h.modal.Mode() == ModeNormal {
432+ if routeKey != nil && mode == ModeNormal {
308433 route = routeKey(KeyContext{
309434 Keyval: keyval,
310435 Keycode: keycode,
@@ -332,28 +457,153 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
332457 // Fall through to shortcut processing below
333458 }
334459
460+ // Vim-mode multi-key sequences run before legacy single-chord lookup.
461+ if h.feedVimModeSequence(keyval, state) {
462+ return true
463+ }
464+
335465 // --- Shortcut processing path ---
336466
337467 // Normalize uppercase letters for consistent binding lookup
338468 keyval = normalizeKeyval(keyval)
339469
340470 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
341- mode := h.modal.Mode()
342471 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
472+ return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers)
473+}
343474
475+func (h *KeyboardHandler) handleShortcutLookupResult(
476+ log *zerolog.Logger,
477+ action Action,
478+ found bool,
479+ mode Mode,
480+ keyval uint,
481+ modifiers Modifier,
482+) bool {
344483 if !found {
484+ if shouldPassthroughNativeVimModeNavigation(mode, keyval, modifiers) {
485+ log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode")
486+ return false
487+ }
345488 return mode != ModeNormal // Consume unrecognized keys in modal mode
346489 }
490+ if h.shouldPassthroughVimModeActivation(action, mode) {
491+ return false
492+ }
493+ if mode == ModeVim && isVimScrollAction(action) {
494+ return h.handleVimScrollAction(log, action, mode, keyval)
495+ }
347496 if h.suppressHeldAction(action, keyval) {
348497 log.Trace().
349498 Str("action", string(action)).
350499 Msg("held keyboard action repeat suppressed")
351500 return true
352501 }
353-
354502 return h.dispatchAction(action, mode)
355503 }
356504
505+func (h *KeyboardHandler) handleVimScrollAction(log *zerolog.Logger, action Action, mode Mode, keyval uint) bool {
506+ h.mu.RLock()
507+ sameHeldKey := h.vimScrollRepeatAction == action &&
508+ h.vimScrollRepeatKeyval == keyval && h.vimScrollRepeatTimer != 0
509+ h.mu.RUnlock()
510+ if sameHeldKey {
511+ log.Trace().Str("action", string(action)).Msg("vim scroll OS repeat suppressed")
512+ return true
513+ }
514+
515+ h.stopVimScrollRepeat()
516+ if !h.dispatchAction(action, mode) {
517+ return false
518+ }
519+ h.startVimScrollRepeat(action, keyval)
520+ return true
521+}
522+
523+func (h *KeyboardHandler) startVimScrollRepeat(action Action, keyval uint) {
524+ h.mu.Lock()
525+ if h.vimScrollRepeatTimer != 0 || h.vimScrollRepeatAdd == nil {
526+ h.mu.Unlock()
527+ return
528+ }
529+ h.vimScrollRepeatGeneration++
530+ generation := h.vimScrollRepeatGeneration
531+ h.vimScrollRepeatAction = action
532+ h.vimScrollRepeatKeyval = keyval
533+ h.vimScrollRepeatPressedAt = h.vimScrollNow()
534+ cb := glib.SourceFunc(func(_ uintptr) bool {
535+ return h.vimScrollRepeatTick(action, keyval, generation)
536+ })
537+ h.vimScrollRepeatCb = cb
538+ add := h.vimScrollRepeatAdd
539+ id := add(uint(vimScrollCadence/time.Millisecond), &h.vimScrollRepeatCb)
540+ if id == 0 {
541+ h.clearVimScrollRepeatLocked()
542+ h.mu.Unlock()
543+ return
544+ }
545+ h.vimScrollRepeatTimer = id
546+ h.mu.Unlock()
547+}
548+
549+func (h *KeyboardHandler) vimScrollRepeatTick(action Action, keyval uint, generation uint64) bool {
550+ h.mu.RLock()
551+ active := h.vimScrollRepeatAction == action && h.vimScrollRepeatKeyval == keyval &&
552+ h.vimScrollRepeatTimer != 0 && h.vimScrollRepeatGeneration == generation
553+ pressedAt := h.vimScrollRepeatPressedAt
554+ now := h.vimScrollNow
555+ h.mu.RUnlock()
556+ if !active || h.modal.Mode() != ModeVim {
557+ return false
558+ }
559+ if now().Sub(pressedAt) < vimScrollHoldDelay {
560+ return true
561+ }
562+ // Continuous dispatch bypasses dispatchAction, so refresh Vim Mode timeout here.
563+ h.modal.ResetTimeout(h.ctx)
564+ h.dispatchVimScrollLifecycle(action, VimScrollContinuous)
565+ return true
566+}
567+
568+func (h *KeyboardHandler) stopVimScrollRepeat() {
569+ h.mu.Lock()
570+ action := h.vimScrollRepeatAction
571+ timerID := h.vimScrollRepeatTimer
572+ remove := h.vimScrollRepeatRemove
573+ h.clearVimScrollRepeatLocked()
574+ h.mu.Unlock()
575+ if timerID != 0 && remove != nil {
576+ remove(timerID)
577+ }
578+ if action != "" {
579+ h.dispatchVimScrollLifecycle(action, VimScrollStop)
580+ }
581+}
582+
583+func (h *KeyboardHandler) clearVimScrollRepeatLocked() {
584+ h.vimScrollRepeatGeneration++
585+ h.vimScrollRepeatAction = ""
586+ h.vimScrollRepeatKeyval = 0
587+ h.vimScrollRepeatTimer = 0
588+ h.vimScrollRepeatPressedAt = time.Time{}
589+ h.vimScrollRepeatCb = nil
590+}
591+
592+func (h *KeyboardHandler) dispatchVimScrollLifecycle(action Action, phase VimScrollPhase) {
593+ h.mu.RLock()
594+ handler := h.onVimScrollLifecycle
595+ h.mu.RUnlock()
596+ if handler == nil {
597+ if phase == VimScrollContinuous {
598+ h.dispatchAction(action, ModeVim)
599+ }
600+ return
601+ }
602+ if err := handler(h.ctx, action, phase); err != nil {
603+ logging.FromContext(h.ctx).Error().Err(err).Str("action", string(action)).Msg("vim scroll lifecycle handler error")
604+ }
605+}
606+
357607 // tryAccentDetection starts long-press detection for accent-eligible keys.
358608 // It is used only for the RouteAccentDetection route (GTK Entry widgets such as
359609 // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;
@@ -382,6 +632,43 @@ func normalizeKeyval(keyval uint) uint {
382632 return keyval
383633 }
384634
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+
658+func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool {
659+ if mode != ModeVim {
660+ return false
661+ }
662+ switch keyval {
663+ case uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down):
664+ return modifiers == ModNone
665+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
666+ return modifiers == ModNone || modifiers == ModShift
667+ default:
668+ return false
669+ }
670+}
671+
385672 // dispatchAction dispatches the action and handles mode-related logic.
386673 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
387674 if h.handleModeAction(action) {
@@ -402,6 +689,9 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
402689 if mode == ModeResize && isResizeAction(action) {
403690 h.modal.ResetTimeout(h.ctx)
404691 }
692+ if mode == ModeVim && isVimScrollAction(action) {
693+ h.modal.ResetTimeout(h.ctx)
694+ }
405695
406696 if mode != ModeNormal && ShouldAutoExitMode(action) {
407697 h.modal.ExitMode(h.ctx)
@@ -436,6 +726,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool {
436726 ActionEnterPaneMode,
437727 ActionEnterSessionMode,
438728 ActionEnterResizeMode,
729+ ActionEnterVimMode,
439730 ActionNewTab,
440731 ActionRenameTab,
441732 ActionSplitRight,
@@ -512,6 +803,20 @@ func isResizeAction(action Action) bool {
512803 }
513804 }
514805
806+func isVimScrollAction(action Action) bool {
807+ switch action {
808+ case ActionVimScrollLeft,
809+ ActionVimScrollDown,
810+ ActionVimScrollUp,
811+ ActionVimScrollRight,
812+ ActionVimScrollDownFast,
813+ ActionVimScrollUpFast:
814+ return true
815+ default:
816+ return false
817+ }
818+}
819+
515820 func (h *KeyboardHandler) handleModeAction(action Action) bool {
516821 h.mu.RLock()
517822 workspace := h.workspace
@@ -551,7 +856,22 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
551856 }
552857 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
553858 return true
859+ case ActionEnterVimMode:
860+ if h.modal.Mode() == ModeVim {
861+ h.stopVimScrollRepeat()
862+ h.modal.ExitMode(h.ctx)
863+ return true
864+ }
865+ var pgms int
866+ if workspace != nil {
867+ pgms = workspace.VimMode.TimeoutMilliseconds
868+ }
869+ h.modal.EnterVimMode(h.ctx, time.Duration(pgms)*time.Millisecond)
870+ return true
554871 case ActionExitMode:
872+ if h.modal.Mode() == ModeVim {
873+ h.stopVimScrollRepeat()
874+ }
555875 h.modal.ExitMode(h.ctx)
556876 return true
557877 default:
@@ -598,33 +918,64 @@ func (h *KeyboardHandler) EnterSessionMode() {
598918 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
599919 }
600920
921+// EnterVimMode programmatically enters Vim-style scrolling mode.
922+// Useful for testing or programmatic mode changes.
923+func (h *KeyboardHandler) EnterVimMode() {
924+ h.mu.RLock()
925+ workspace := h.workspace
926+ h.mu.RUnlock()
927+ var ms int
928+ if workspace != nil {
929+ ms = workspace.VimMode.TimeoutMilliseconds
930+ }
931+ h.modal.EnterVimMode(h.ctx, time.Duration(ms)*time.Millisecond)
932+}
933+
601934 // ExitMode programmatically exits modal mode.
602935 // Useful for testing or programmatic mode changes.
603936 func (h *KeyboardHandler) ExitMode() {
604937 h.modal.ExitMode(h.ctx)
605938 }
606939
940+func (h *KeyboardHandler) shouldPassthroughVimModeActivation(action Action, mode Mode) bool {
941+ if action != ActionEnterVimMode || mode != ModeNormal {
942+ return false
943+ }
944+ h.mu.RLock()
945+ passthrough := h.vimModeActivationPassthrough
946+ h.mu.RUnlock()
947+ return passthrough != nil && passthrough()
948+}
949+
607950 // DispatchAction processes an action externally triggered (e.g., by a global
608951 // shortcut). Mode-enter actions update modal state; other actions are forwarded
609-// to the registered action handler.
610-func (h *KeyboardHandler) DispatchAction(action Action) {
952+// to the registered action handler. It returns whether the action was consumed.
953+func (h *KeyboardHandler) DispatchAction(action Action) bool {
611954 mode := h.modal.Mode()
955+ if h.shouldPassthroughVimModeActivation(action, mode) {
956+ return false
957+ }
612958 h.dispatchAction(action, mode)
959+ return true
613960 }
614961
615962 // handleKeyRelease processes a key release event for accent detection.
616963 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
964+ keyval = normalizeKeyval(keyval)
617965 h.mu.Lock()
618966 accentHandler := h.accentHandler
619967 if len(h.activePressedActions) > 0 {
620- keyval = normalizeKeyval(keyval)
621968 for action, pressedKeyval := range h.activePressedActions {
622969 if pressedKeyval == keyval {
623970 delete(h.activePressedActions, action)
624971 }
625972 }
626973 }
974+ stopVimScroll := h.vimScrollRepeatKeyval == keyval
627975 h.mu.Unlock()
976+ if stopVimScroll {
977+ h.stopVimScrollRepeat()
978+ }
628979
629980 if accentHandler == nil {
630981 return
M internal/ui/input/keyboard_test.go +787 -0
@@ -3,9 +3,11 @@ package input
33 import (
44 "context"
55 "testing"
6+ "time"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/bnema/puregotk/v4/gdk"
10+ "github.com/bnema/puregotk/v4/glib"
911 "github.com/bnema/puregotk/v4/gtk"
1012 "github.com/stretchr/testify/assert"
1113 )
@@ -27,6 +29,10 @@ func newTestSession() *entity.SessionConfig {
2729 }
2830 }
2931
32+type fakeModalTimer struct{}
33+
34+func (*fakeModalTimer) Stop() bool { return true }
35+
3036 // stubAccentHandler is a test stub for the AccentHandler interface.
3137 type stubAccentHandler struct {
3238 onKeyPressedResult bool
@@ -362,3 +368,784 @@ func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T
362368 t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
363369 }
364370 }
371+
372+func TestHandleKeyPress_EnterVimMode(t *testing.T) {
373+ ctx := context.Background()
374+ workspace := newTestWorkspace()
375+ workspace.VimMode = entity.VimModeConfig{
376+ ActivationShortcut: "ctrl+y",
377+ }
378+
379+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
380+
381+ h.SetOnAction(func(ctx context.Context, action Action) error {
382+ return nil
383+ })
384+
385+ // Ctrl+Y should enter vim mode
386+ result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
387+ if !result {
388+ t.Fatal("Ctrl+Y should be consumed")
389+ }
390+ if h.Mode() != ModeVim {
391+ t.Fatalf("mode = %v, want ModeVim", h.Mode())
392+ }
393+}
394+
395+func TestHandleKeyPress_VimModeStaysActiveAfterScroll(t *testing.T) {
396+ ctx := context.Background()
397+ workspace := newTestWorkspace()
398+ workspace.VimMode = entity.VimModeConfig{
399+ ActivationShortcut: "ctrl+y",
400+ Actions: map[string]entity.ActionBinding{
401+ "vim-scroll-down": {Keys: []string{"j"}},
402+ "vim-scroll-up": {Keys: []string{"k"}},
403+ "cancel": {Keys: []string{"escape"}},
404+ },
405+ }
406+
407+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
408+ actionCalls := 0
409+ h.SetOnAction(func(ctx context.Context, action Action) error {
410+ actionCalls++
411+ return nil
412+ })
413+
414+ // Enter vim mode
415+ result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
416+ if !result || h.Mode() != ModeVim {
417+ t.Fatal("failed to enter vim mode")
418+ }
419+
420+ var repeatTick glib.SourceFunc
421+ repeatNow := time.Unix(2_000, 0)
422+ h.vimScrollNow = func() time.Time { return repeatNow }
423+ h.vimScrollRepeatAdd = func(_ uint, cb *glib.SourceFunc) uint {
424+ repeatTick = *cb
425+ return 1
426+ }
427+ h.vimScrollRepeatRemove = func(uint) bool { return true }
428+
429+ // Scroll actions stay in vim mode; the first press arms the smooth
430+ // repeater and later physical auto-repeat events are ignored.
431+ for i := 0; i < 2; i++ {
432+ scrolled := h.handleKeyPress(uint('j'), 0, 0)
433+ if !scrolled {
434+ t.Fatalf("scroll down iteration %d: key not consumed", i)
435+ }
436+ if h.Mode() != ModeVim {
437+ t.Fatalf("scroll down iteration %d: mode = %v, want ModeVim", i, h.Mode())
438+ }
439+ }
440+ if repeatTick == nil {
441+ t.Fatal("expected held vim scroll to register smooth repeater")
442+ }
443+ repeatNow = repeatNow.Add(vimScrollHoldDelay)
444+ for i := 0; i < 3; i++ {
445+ if !repeatTick(0) {
446+ t.Fatalf("repeat tick %d stopped unexpectedly", i)
447+ }
448+ if h.Mode() != ModeVim {
449+ t.Fatalf("repeat tick %d: mode = %v, want ModeVim", i, h.Mode())
450+ }
451+ }
452+ h.handleKeyRelease(uint('j'))
453+
454+ // Scroll up also stays in vim mode
455+ result = h.handleKeyPress(uint('k'), 0, 0)
456+ if !result || h.Mode() != ModeVim {
457+ t.Fatal("scroll up should stay in vim mode")
458+ }
459+
460+ // Ensure scroll actions were dispatched
461+ if actionCalls != 5 {
462+ t.Fatalf("action calls = %d, want 5 (4 down + 1 up)", actionCalls)
463+ }
464+}
465+
466+func TestHandleKeyPress_VimModeTimeoutReset(t *testing.T) {
467+ ctx := context.Background()
468+ workspace := newTestWorkspace()
469+ workspace.VimMode = entity.VimModeConfig{
470+ ActivationShortcut: "ctrl+y",
471+ TimeoutMilliseconds: 100,
472+ Actions: map[string]entity.ActionBinding{
473+ "vim-scroll-down": {Keys: []string{"j"}},
474+ "cancel": {Keys: []string{"escape"}},
475+ },
476+ }
477+
478+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
479+ timerStarts := 0
480+ h.modal.afterFunc = func(d time.Duration, fn func()) modalTimer {
481+ timerStarts++
482+ return &fakeModalTimer{}
483+ }
484+ h.SetOnAction(func(ctx context.Context, action Action) error {
485+ return nil
486+ })
487+
488+ // Enter vim mode with timeout.
489+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
490+ if h.Mode() != ModeVim {
491+ t.Fatal("failed to enter vim mode")
492+ }
493+ if timerStarts != 1 {
494+ t.Fatalf("timer starts after entering vim mode = %d, want 1", timerStarts)
495+ }
496+
497+ // Scroll action should reset the timeout by starting a replacement timer.
498+ h.handleKeyPress(uint('j'), 0, 0)
499+
500+ if h.Mode() != ModeVim {
501+ t.Fatalf("mode after scroll = %v, want ModeVim", h.Mode())
502+ }
503+ if timerStarts != 2 {
504+ t.Fatalf("timer starts after scroll reset = %d, want 2", timerStarts)
505+ }
506+}
507+
508+func TestVimScrollRepeatTick_RefreshesConfiguredVimModeTimeout(t *testing.T) {
509+ ctx := context.Background()
510+ workspace := newTestWorkspace()
511+ workspace.VimMode = entity.VimModeConfig{
512+ ActivationShortcut: "ctrl+y",
513+ TimeoutMilliseconds: 100,
514+ Actions: map[string]entity.ActionBinding{
515+ "vim-scroll-down": {Keys: []string{"j"}},
516+ },
517+ }
518+
519+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
520+ timerStarts := 0
521+ h.modal.afterFunc = func(time.Duration, func()) modalTimer {
522+ timerStarts++
523+ return &fakeModalTimer{}
524+ }
525+ var continuous int
526+ h.SetOnVimScrollLifecycle(func(_ context.Context, _ Action, phase VimScrollPhase) error {
527+ if phase == VimScrollContinuous {
528+ continuous++
529+ }
530+ return nil
531+ })
532+
533+ var repeatTick glib.SourceFunc
534+ repeatNow := time.Unix(3_000, 0)
535+ h.vimScrollNow = func() time.Time { return repeatNow }
536+ h.vimScrollRepeatAdd = func(_ uint, cb *glib.SourceFunc) uint {
537+ repeatTick = *cb
538+ return 1
539+ }
540+ h.vimScrollRepeatRemove = func(uint) bool { return true }
541+
542+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
543+ if h.Mode() != ModeVim {
544+ t.Fatal("failed to enter vim mode")
545+ }
546+ enterStarts := timerStarts
547+ h.handleKeyPress(uint('j'), 0, 0)
548+ if repeatTick == nil {
549+ t.Fatal("expected repeater registration")
550+ }
551+ afterTap := timerStarts
552+ if afterTap <= enterStarts {
553+ t.Fatalf("tap should refresh timeout: enter=%d afterTap=%d", enterStarts, afterTap)
554+ }
555+
556+ repeatNow = repeatNow.Add(vimScrollHoldDelay)
557+ if !repeatTick(0) {
558+ t.Fatal("continuous tick stopped unexpectedly")
559+ }
560+ if continuous != 1 {
561+ t.Fatalf("continuous dispatches=%d, want 1", continuous)
562+ }
563+ if timerStarts <= afterTap {
564+ t.Fatalf("continuous tick must refresh vim-mode timeout: afterTap=%d afterTick=%d", afterTap, timerStarts)
565+ }
566+ if h.Mode() != ModeVim {
567+ t.Fatalf("mode after continuous tick = %v, want ModeVim", h.Mode())
568+ }
569+}
570+
571+func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) {
572+ ctx := context.Background()
573+ workspace := newTestWorkspace()
574+ workspace.VimMode = entity.VimModeConfig{
575+ ActivationShortcut: "ctrl+y",
576+ Actions: map[string]entity.ActionBinding{
577+ "vim-scroll-down": {Keys: []string{"j"}},
578+ "cancel": {Keys: []string{"escape"}},
579+ },
580+ }
581+
582+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
583+ h.SetOnAction(func(ctx context.Context, action Action) error {
584+ return nil
585+ })
586+
587+ // Enter vim mode
588+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
589+ if h.Mode() != ModeVim {
590+ t.Fatal("failed to enter vim mode")
591+ }
592+
593+ // Escape should exit vim mode
594+ result := h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0)
595+ if !result {
596+ t.Fatal("Escape should be consumed")
597+ }
598+ if h.Mode() != ModeNormal {
599+ t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode())
600+ }
601+}
602+
603+func TestHandleKeyPress_VimModeEnterExits(t *testing.T) {
604+ ctx := context.Background()
605+ workspace := newTestWorkspace()
606+ workspace.VimMode = entity.VimModeConfig{
607+ ActivationShortcut: "ctrl+y",
608+ Actions: map[string]entity.ActionBinding{
609+ "confirm": {Keys: []string{"enter"}},
610+ },
611+ }
612+
613+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
614+ h.SetOnAction(func(ctx context.Context, action Action) error {
615+ return nil
616+ })
617+
618+ // Enter vim mode
619+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
620+ if h.Mode() != ModeVim {
621+ t.Fatal("failed to enter vim mode")
622+ }
623+
624+ // Enter should exit vim mode
625+ result := h.handleKeyPress(uint(gdk.KEY_Return), 0, 0)
626+ if !result {
627+ t.Fatal("Enter should be consumed")
628+ }
629+ if h.Mode() != ModeNormal {
630+ t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode())
631+ }
632+}
633+
634+func TestHandleKeyPress_VimModeActivationTogglesOff(t *testing.T) {
635+ ctx := context.Background()
636+ workspace := newTestWorkspace()
637+ workspace.VimMode = entity.VimModeConfig{
638+ ActivationShortcut: "ctrl+y",
639+ }
640+
641+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
642+
643+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
644+ t.Fatal("first Ctrl+Y should be consumed")
645+ }
646+ if h.Mode() != ModeVim {
647+ t.Fatalf("mode after first Ctrl+Y = %v, want ModeVim", h.Mode())
648+ }
649+
650+ h.handleKeyRelease(uint('y'))
651+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
652+ t.Fatal("second Ctrl+Y should be consumed")
653+ }
654+ if h.Mode() != ModeNormal {
655+ t.Fatalf("mode after second Ctrl+Y = %v, want ModeNormal", h.Mode())
656+ }
657+}
658+
659+func TestHandleKeyPress_VimModeEscapeExitsWithoutCancelBinding(t *testing.T) {
660+ ctx := context.Background()
661+ workspace := newTestWorkspace()
662+ workspace.VimMode = entity.VimModeConfig{
663+ ActivationShortcut: "ctrl+y",
664+ Actions: map[string]entity.ActionBinding{
665+ "vim-scroll-down": {Keys: []string{"j"}},
666+ },
667+ }
668+
669+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
670+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
671+ t.Fatal("Ctrl+Y should be consumed")
672+ }
673+ if h.Mode() != ModeVim {
674+ t.Fatal("failed to enter vim mode")
675+ }
676+
677+ if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
678+ t.Fatal("Escape should still be consumed without explicit cancel binding")
679+ }
680+ if h.Mode() != ModeNormal {
681+ t.Fatalf("mode after Escape fallback = %v, want ModeNormal", h.Mode())
682+ }
683+}
684+
685+func TestHandleKeyPress_VimModeActivationNotInPassThrough(t *testing.T) {
686+ ctx := context.Background()
687+ workspace := newTestWorkspace()
688+ workspace.VimMode = entity.VimModeConfig{
689+ ActivationShortcut: "ctrl+y",
690+ }
691+
692+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
693+
694+ // Route ALL keys to widget (editable pass-through context)
695+ h.SetRouteKey(func(kc KeyContext) KeyRoute {
696+ return RoutePassToWidget
697+ })
698+
699+ // Ctrl+Y should NOT activate vim mode because route says pass to widget
700+ result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
701+ if result {
702+ t.Fatal("Ctrl+Y should NOT be consumed when routed to widget")
703+ }
704+ if h.Mode() != ModeNormal {
705+ t.Fatalf("mode = %v, want ModeNormal (should not enter vim mode)", h.Mode())
706+ }
707+}
708+
709+func TestHandleKeyPress_VimModeActivationPassesThroughWhenBlocked(t *testing.T) {
710+ ctx := context.Background()
711+ workspace := newTestWorkspace()
712+ workspace.VimMode = entity.VimModeConfig{
713+ ActivationShortcut: "ctrl+y",
714+ }
715+
716+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
717+ h.SetVimModeActivationPassthrough(func() bool { return true })
718+
719+ result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
720+ if result {
721+ t.Fatal("Ctrl+Y should pass through when vim mode activation is blocked")
722+ }
723+ if h.Mode() != ModeNormal {
724+ t.Fatalf("mode = %v, want ModeNormal", h.Mode())
725+ }
726+}
727+
728+func TestDispatchAction_VimModeActivationPassesThroughWhenBlocked(t *testing.T) {
729+ ctx := context.Background()
730+ workspace := newTestWorkspace()
731+ workspace.VimMode = entity.VimModeConfig{
732+ ActivationShortcut: "ctrl+y",
733+ }
734+
735+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
736+ h.SetVimModeActivationPassthrough(func() bool { return true })
737+
738+ consumed := h.DispatchAction(ActionEnterVimMode)
739+ if consumed {
740+ t.Fatal("DispatchAction should not consume blocked vim mode activation")
741+ }
742+ if h.Mode() != ModeNormal {
743+ t.Fatalf("mode = %v, want ModeNormal", h.Mode())
744+ }
745+}
746+
747+func TestHandleKeyPress_VimModeFastScrollLookup(t *testing.T) {
748+ tests := []struct {
749+ name string
750+ configAction string
751+ key uint
752+ expected Action
753+ }{
754+ {
755+ name: "scroll down fast",
756+ configAction: "vim-scroll-down-fast",
757+ key: uint('d'),
758+ expected: ActionVimScrollDownFast,
759+ },
760+ {
761+ name: "scroll up fast",
762+ configAction: "vim-scroll-up-fast",
763+ key: uint('u'),
764+ expected: ActionVimScrollUpFast,
765+ },
766+ }
767+
768+ for _, tt := range tests {
769+ t.Run(tt.name, func(t *testing.T) {
770+ ctx := context.Background()
771+ workspace := newTestWorkspace()
772+ workspace.VimMode = entity.VimModeConfig{
773+ ActivationShortcut: "ctrl+y",
774+ Actions: map[string]entity.ActionBinding{
775+ tt.configAction: {Keys: []string{"ctrl+" + string(rune(tt.key))}},
776+ },
777+ }
778+
779+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
780+ actionCalls := 0
781+ var lastAction Action
782+ h.SetOnAction(func(ctx context.Context, action Action) error {
783+ actionCalls++
784+ lastAction = action
785+ return nil
786+ })
787+
788+ // Enter vim mode.
789+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
790+ if h.Mode() != ModeVim {
791+ t.Fatal("failed to enter vim mode")
792+ }
793+
794+ // Fast-scroll shortcut in vim mode should dispatch the expected action.
795+ result := h.handleKeyPress(tt.key, 0, gdk.ControlMaskValue)
796+ if !result {
797+ t.Fatal("fast scroll shortcut in vim mode should be consumed")
798+ }
799+ if actionCalls != 1 || lastAction != tt.expected {
800+ t.Fatalf("expected %s, got action=%s calls=%d", tt.expected, lastAction, actionCalls)
801+ }
802+ if h.Mode() != ModeVim {
803+ t.Fatal("should stay in vim mode after scroll action")
804+ }
805+ })
806+ }
807+}
808+
809+func TestHandleKeyPress_VimModeNoAutoExitForScrollActions(t *testing.T) {
810+ ctx := context.Background()
811+ workspace := newTestWorkspace()
812+ workspace.VimMode = entity.VimModeConfig{
813+ ActivationShortcut: "ctrl+y",
814+ Actions: map[string]entity.ActionBinding{
815+ "vim-scroll-left": {Keys: []string{"h"}},
816+ "vim-scroll-right": {Keys: []string{"l"}},
817+ },
818+ }
819+
820+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
821+ actionCalls := 0
822+ h.SetOnAction(func(ctx context.Context, action Action) error {
823+ actionCalls++
824+ return nil
825+ })
826+
827+ // Enter vim mode
828+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
829+ if h.Mode() != ModeVim {
830+ t.Fatal("failed to enter vim mode")
831+ }
832+
833+ // Scroll left should not auto-exit
834+ h.handleKeyPress(uint('h'), 0, 0)
835+ if h.Mode() != ModeVim {
836+ t.Fatal("scroll left should not exit vim mode")
837+ }
838+
839+ // Scroll right should not auto-exit
840+ h.handleKeyPress(uint('l'), 0, 0)
841+ if h.Mode() != ModeVim {
842+ t.Fatal("scroll right should not exit vim mode")
843+ }
844+
845+ if actionCalls != 2 {
846+ t.Fatalf("action calls = %d, want 2", actionCalls)
847+ }
848+}
849+
850+func TestHandleKeyPress_VimModeArrowKeysPassThroughNatively(t *testing.T) {
851+ ctx := context.Background()
852+ workspace := newTestWorkspace()
853+ workspace.VimMode = entity.VimModeConfig{
854+ ActivationShortcut: "ctrl+y",
855+ Actions: map[string]entity.ActionBinding{
856+ "vim-scroll-down": {Keys: []string{"j"}},
857+ },
858+ }
859+
860+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
861+ actionCalls := 0
862+ h.SetOnAction(func(ctx context.Context, action Action) error {
863+ actionCalls++
864+ return nil
865+ })
866+
867+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
868+ if h.Mode() != ModeVim {
869+ t.Fatal("failed to enter vim mode")
870+ }
871+
872+ for _, keyval := range []uint{uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down)} {
873+ consumed := h.handleKeyPress(keyval, 0, 0)
874+ if consumed {
875+ t.Fatalf("arrow key %d should pass through to native page handling in vim mode", keyval)
876+ }
877+ if h.Mode() != ModeVim {
878+ t.Fatalf("arrow key %d should keep vim mode active", keyval)
879+ }
880+ }
881+
882+ if actionCalls != 0 {
883+ t.Fatalf("arrow key passthrough should not dispatch vim-mode action, got %d calls", actionCalls)
884+ }
885+}
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+
963+func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) {
964+ ctx := context.Background()
965+ workspace := newTestWorkspace()
966+ workspace.VimMode = entity.VimModeConfig{
967+ ActivationShortcut: "ctrl+y",
968+ Actions: map[string]entity.ActionBinding{
969+ "vim-scroll-down": {Keys: []string{"j"}},
970+ },
971+ }
972+
973+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
974+ actionCalls := 0
975+ var lastAction Action
976+ h.SetOnAction(func(ctx context.Context, action Action) error {
977+ actionCalls++
978+ lastAction = action
979+ return nil
980+ })
981+
982+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
983+ t.Fatal("Ctrl+Y should be consumed")
984+ }
985+ if h.Mode() != ModeVim {
986+ t.Fatal("failed to enter vim mode")
987+ }
988+
989+ if !h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue) {
990+ t.Fatal("Ctrl+L should be consumed inside vim mode")
991+ }
992+ if actionCalls != 0 {
993+ t.Fatalf("Ctrl+L should not dispatch a global action in vim mode, got %d calls (%s)", actionCalls, lastAction)
994+ }
995+ if h.Mode() != ModeVim {
996+ t.Fatalf("mode after blocked Ctrl+L = %v, want ModeVim", h.Mode())
997+ }
998+}
999+
1000+func TestVimScrollRepeat_FirstKeyDownDispatchesTapAndArmsHold(t *testing.T) {
1001+ h, now, tick := newVimScrollRepeatTestHandler(t)
1002+ actionCalls := 0
1003+ h.SetOnAction(func(_ context.Context, action Action) error {
1004+ actionCalls++
1005+ if action != ActionVimScrollDown {
1006+ t.Fatalf("action = %s, want %s", action, ActionVimScrollDown)
1007+ }
1008+ return nil
1009+ })
1010+
1011+ if !h.handleKeyPress(uint('j'), 0, 0) {
1012+ t.Fatal("first J press should be consumed")
1013+ }
1014+ if actionCalls != 1 {
1015+ t.Fatalf("tap dispatches = %d, want 1", actionCalls)
1016+ }
1017+ if *tick == nil {
1018+ t.Fatal("first keydown must arm the hold source without an OS repeat")
1019+ }
1020+
1021+ *now = now.Add(vimScrollHoldDelay - time.Millisecond)
1022+ if !(*tick)(0) {
1023+ t.Fatal("hold source should remain armed before the delay")
1024+ }
1025+ if actionCalls != 1 {
1026+ t.Fatalf("dispatches before hold delay = %d, want 1 tap", actionCalls)
1027+ }
1028+}
1029+
1030+func TestVimScrollRepeat_HoldStartsWithoutOSRepeat(t *testing.T) {
1031+ h, now, tick := newVimScrollRepeatTestHandler(t)
1032+ continuous := 0
1033+ h.SetOnAction(func(context.Context, Action) error { return nil })
1034+ h.SetOnVimScrollLifecycle(func(_ context.Context, action Action, phase VimScrollPhase) error {
1035+ if action != ActionVimScrollDown || phase != VimScrollContinuous {
1036+ t.Fatalf("lifecycle = (%s, %v), want (%s, continuous)", action, phase, ActionVimScrollDown)
1037+ }
1038+ continuous++
1039+ return nil
1040+ })
1041+
1042+ h.handleKeyPress(uint('j'), 0, 0)
1043+ *now = now.Add(vimScrollHoldDelay)
1044+ if !(*tick)(0) {
1045+ t.Fatal("hold source stopped at the delay")
1046+ }
1047+ if continuous != 1 {
1048+ t.Fatalf("continuous dispatches = %d, want 1 without a second keydown", continuous)
1049+ }
1050+}
1051+
1052+func TestVimScrollRepeat_FixedCadenceIgnoresDisplayAndOSRepeatRates(t *testing.T) {
1053+ const simulatedHold = time.Second
1054+ simulate := func(t *testing.T, externalHz int) int {
1055+ t.Helper()
1056+ h, now, tick := newVimScrollRepeatTestHandler(t)
1057+ tapDistance := 0
1058+ continuousDistance := 0
1059+ h.SetOnAction(func(context.Context, Action) error {
1060+ tapDistance += 80
1061+ return nil
1062+ })
1063+ h.SetOnVimScrollLifecycle(func(_ context.Context, _ Action, phase VimScrollPhase) error {
1064+ if phase == VimScrollContinuous {
1065+ continuousDistance += 80
1066+ }
1067+ return nil
1068+ })
1069+ h.handleKeyPress(uint('j'), 0, 0)
1070+
1071+ start := *now
1072+ nextExternal := time.Second / time.Duration(externalHz)
1073+ for elapsed := vimScrollCadence; elapsed <= simulatedHold; elapsed += vimScrollCadence {
1074+ *now = start.Add(elapsed)
1075+ for elapsed >= nextExternal {
1076+ h.handleKeyPress(uint('j'), 0, 0) // ignored OS auto-repeat
1077+ nextExternal += time.Second / time.Duration(externalHz)
1078+ }
1079+ if !(*tick)(0) {
1080+ t.Fatalf("source stopped at %s", elapsed)
1081+ }
1082+ }
1083+ return tapDistance + continuousDistance
1084+ }
1085+
1086+ distance60 := simulate(t, 60)
1087+ distance144 := simulate(t, 144)
1088+ t.Logf("simulated one-second distances: 60 Hz=%d px, 144 Hz=%d px; tolerance=80 px", distance60, distance144)
1089+ // A fixed 16 ms source makes the results exact in this deterministic model;
1090+ // the documented runtime tolerance is one 80 px cadence step at timer edges.
1091+ if diff := distance60 - distance144; diff < -80 || diff > 80 {
1092+ t.Fatalf("60 Hz distance=%d, 144 Hz distance=%d; tolerance=80 px", distance60, distance144)
1093+ }
1094+}
1095+
1096+func TestVimScrollRepeat_ReleaseAndEscapeStopImmediately(t *testing.T) {
1097+ for _, tc := range []struct {
1098+ name string
1099+ stop func(*KeyboardHandler)
1100+ }{
1101+ {name: "release", stop: func(h *KeyboardHandler) { h.handleKeyRelease(uint('j')) }},
1102+ {name: "escape", stop: func(h *KeyboardHandler) { h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) }},
1103+ } {
1104+ t.Run(tc.name, func(t *testing.T) {
1105+ h, now, tick := newVimScrollRepeatTestHandler(t)
1106+ removed := 0
1107+ stops := 0
1108+ h.vimScrollRepeatRemove = func(uint) bool { removed++; return true }
1109+ h.SetOnAction(func(context.Context, Action) error { return nil })
1110+ h.SetOnVimScrollLifecycle(func(_ context.Context, _ Action, phase VimScrollPhase) error {
1111+ if phase == VimScrollStop {
1112+ stops++
1113+ }
1114+ return nil
1115+ })
1116+ h.handleKeyPress(uint('j'), 0, 0)
1117+ *now = now.Add(vimScrollHoldDelay)
1118+ (*tick)(0)
1119+
1120+ tc.stop(h)
1121+ if removed != 1 || stops != 1 {
1122+ t.Fatalf("removed=%d stops=%d, want 1/1", removed, stops)
1123+ }
1124+ if (*tick)(0) {
1125+ t.Fatal("retained callback must stop immediately")
1126+ }
1127+ })
1128+ }
1129+}
1130+
1131+func newVimScrollRepeatTestHandler(t *testing.T) (*KeyboardHandler, *time.Time, *glib.SourceFunc) {
1132+ t.Helper()
1133+ workspace := newTestWorkspace()
1134+ workspace.VimMode = entity.VimModeConfig{
1135+ Actions: map[string]entity.ActionBinding{"vim-scroll-down": {Keys: []string{"j"}}},
1136+ }
1137+ h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
1138+ h.EnterVimMode()
1139+ now := time.Unix(1_000, 0)
1140+ var tick glib.SourceFunc
1141+ h.vimScrollNow = func() time.Time { return now }
1142+ h.vimScrollRepeatAdd = func(intervalMS uint, cb *glib.SourceFunc) uint {
1143+ if intervalMS != uint(vimScrollCadence/time.Millisecond) {
1144+ t.Fatalf("cadence = %dms, want %dms", intervalMS, vimScrollCadence/time.Millisecond)
1145+ }
1146+ tick = *cb
1147+ return 99
1148+ }
1149+ h.vimScrollRepeatRemove = func(uint) bool { return true }
1150+ return h, &now, &tick
1151+}
M internal/ui/input/modal.go +20 -2
@@ -8,6 +8,10 @@ import (
88 "github.com/bnema/dumber/internal/logging"
99 )
1010
11+type modalTimer interface {
12+ Stop() bool
13+}
14+
1115 // Mode represents the current input mode.
1216 type Mode int
1317
@@ -22,6 +26,8 @@ const (
2226 ModeSession
2327 // ModeResize is the modal pane resizing mode.
2428 ModeResize
29+ // ModeVim is the modal Vim-style scrolling mode.
30+ ModeVim
2531 )
2632
2733 // String returns a human-readable mode name.
@@ -37,6 +43,8 @@ func (m Mode) String() string {
3743 return "session"
3844 case ModeResize:
3945 return "resize"
46+ case ModeVim:
47+ return "vim"
4048 default:
4149 return "unknown"
4250 }
@@ -53,6 +61,8 @@ func (m Mode) DisplayName() string {
5361 return "SESSION MODE"
5462 case ModeResize:
5563 return "RESIZE MODE"
64+ case ModeVim:
65+ return "VIM MODE"
5666 default:
5767 return ""
5868 }
@@ -62,9 +72,11 @@ func (m Mode) DisplayName() string {
6272 type ModalState struct {
6373 mode Mode
6474 timeout time.Duration
65- timer *time.Timer
75+ timer modalTimer
6676 timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks
6777
78+ afterFunc func(time.Duration, func()) modalTimer
79+
6880 // Callback for mode changes (called synchronously under lock).
6981 onModeChange func(from, to Mode)
7082
@@ -84,10 +96,16 @@ func NewModalState(ctx context.Context) *ModalState {
8496 log.Debug().Msg("creating modal state")
8597 return &ModalState{
8698 mode: ModeNormal,
99+ afterFunc: func(d time.Duration, fn func()) modalTimer { return time.AfterFunc(d, fn) },
87100 scheduleOnMainThread: func(fn func()) { fn() },
88101 }
89102 }
90103
104+// EnterVimMode switches to Vim-style scrolling mode with an optional timeout.
105+func (m *ModalState) EnterVimMode(ctx context.Context, timeout time.Duration) {
106+ m.enterMode(ctx, ModeVim, timeout)
107+}
108+
91109 // SetMainThreadScheduler sets the function used to dispatch timer callbacks
92110 // to the GTK main thread. In the real app this should be a glib.IdleAdd
93111 // wrapper. In tests, the default (direct execution) is used.
@@ -223,7 +241,7 @@ func (m *ModalState) startTimeoutLocked(ctx context.Context, timeout time.Durati
223241 m.timerGen++
224242 gen := m.timerGen
225243 schedule := m.scheduleOnMainThread
226- m.timer = time.AfterFunc(timeout, func() {
244+ m.timer = m.afterFunc(timeout, func() {
227245 schedule(func() {
228246 // Ignore stale callback: time.Timer.Stop can race with the
229247 // timer firing, so a canceled timer's callback may still run.
M internal/ui/input/shortcuts.go +76 -4
@@ -8,6 +8,7 @@ import (
88 "strings"
99
1010 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/vimkeys"
1112 "github.com/bnema/dumber/internal/logging"
1213 "github.com/bnema/puregotk/v4/gdk"
1314 )
@@ -190,7 +191,7 @@ const (
190191 ActionToggleFullscreen Action = "toggle_fullscreen"
191192 ActionToggleFloatingPane Action = "toggle_floating_pane"
192193 ActionToggleHistorySystemView Action = "toggle_history_systemview"
193- ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"
194+ ActionToggleFavoritesSidebar Action = "toggle_favorites_sidebar"
194195 ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
195196 ActionToggleConfigSystemView Action = "toggle_config_systemview"
196197
@@ -202,6 +203,15 @@ const (
202203
203204 // Application
204205 ActionQuit Action = "quit"
206+
207+ // Vim mode actions
208+ ActionEnterVimMode Action = "enter_vim_mode"
209+ ActionVimScrollLeft Action = "vim_scroll_left"
210+ ActionVimScrollDown Action = "vim_scroll_down"
211+ ActionVimScrollUp Action = "vim_scroll_up"
212+ ActionVimScrollRight Action = "vim_scroll_right"
213+ ActionVimScrollDownFast Action = "vim_scroll_down_fast"
214+ ActionVimScrollUpFast Action = "vim_scroll_up_fast"
205215 )
206216
207217 const floatingProfileActionPrefix = "open_floating_profile:"
@@ -222,6 +232,10 @@ type ShortcutSet struct {
222232 SessionMode ShortcutTable
223233 // ResizeMode shortcuts are only active in resize mode.
224234 ResizeMode ShortcutTable
235+ // VimMode shortcuts are only active in vim mode.
236+ VimMode ShortcutTable
237+ // vimModeSequences holds multi-key Vim mode bindings.
238+ vimModeSequences *vimkeys.Trie
225239 }
226240
227241 // NewShortcutSet creates a ShortcutSet from workspace and session configuration.
@@ -235,6 +249,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
235249 PaneMode: make(ShortcutTable),
236250 SessionMode: make(ShortcutTable),
237251 ResizeMode: make(ShortcutTable),
252+ VimMode: make(ShortcutTable),
238253 }
239254
240255 set.buildGlobalShortcutsFromParts(ctx, workspace, session)
@@ -242,6 +257,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
242257 set.buildTabModeShortcuts(ctx, workspace)
243258 set.buildPaneModeShortcuts(ctx, workspace)
244259 set.buildResizeModeShortcuts(ctx, workspace)
260+ set.buildVimModeShortcuts(ctx, workspace)
245261 }
246262 if session != nil {
247263 set.buildSessionModeShortcuts(ctx, session)
@@ -253,6 +269,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
253269 Int("pane", len(set.PaneMode)).
254270 Int("resize", len(set.ResizeMode)).
255271 Int("session", len(set.SessionMode)).
272+ Int("vim", len(set.VimMode)).
256273 Msg("shortcuts registered")
257274
258275 return set
@@ -288,6 +305,30 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.
288305 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
289306 }
290307
308+// buildVimModeShortcuts populates Vim mode shortcuts from config.
309+// Trie-owned multi-key bindings are excluded from the legacy table.
310+func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
311+ log := logging.FromContext(ctx)
312+ trie, owned := buildVimModeTrie(&cfg.VimMode)
313+ s.vimModeSequences = trie
314+ legacy := filterLegacyVimModeBindings(cfg.VimMode.GetKeyBindings(), owned)
315+ log.Debug().
316+ Int("owned", len(owned)).
317+ Int("legacy", len(legacy)).
318+ Msg("vim mode sequence trie built")
319+ s.buildModeShortcuts(ctx, legacy, s.VimMode, "vim")
320+ if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok {
321+ s.VimMode[binding] = ActionEnterVimMode
322+ log.Trace().
323+ Str("shortcut", cfg.VimMode.ActivationShortcut).
324+ Uint("keyval", binding.Keyval).
325+ Uint("mod", uint(binding.Modifiers)).
326+ Msg("vim mode toggle registered in vim mode table")
327+ } else {
328+ log.Warn().Str("shortcut", cfg.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut for vim mode table")
329+ }
330+}
331+
291332 func (s *ShortcutSet) registerActivationShortcutsFromParts(
292333 ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
293334 ) {
@@ -308,6 +349,16 @@ func (s *ShortcutSet) registerActivationShortcutsFromParts(
308349 }
309350 return
310351 }
352+ if binding, ok := ParseKeyString(workspace.VimMode.ActivationShortcut); ok {
353+ s.Global[binding] = ActionEnterVimMode
354+ log.Trace().
355+ Str("shortcut", workspace.VimMode.ActivationShortcut).
356+ Uint("keyval", binding.Keyval).
357+ Uint("mod", uint(binding.Modifiers)).
358+ Msg("vim mode activation registered")
359+ } else {
360+ log.Warn().Str("shortcut", workspace.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut")
361+ }
311362 if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
312363 s.Global[binding] = ActionEnterTabMode
313364 log.Trace().
@@ -568,8 +619,8 @@ var configActionToAction = map[string]Action{
568619 "toggle-floating-pane": ActionToggleFloatingPane,
569620 "toggle_history_systemview": ActionToggleHistorySystemView,
570621 "toggle-history-systemview": ActionToggleHistorySystemView,
571- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
572- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
622+ "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
623+ "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
573624 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
574625 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
575626 "toggle_config_systemview": ActionToggleConfigSystemView,
@@ -642,6 +693,20 @@ var configActionToAction = map[string]Action{
642693
643694 // Session actions
644695 "session-manager": ActionOpenSessionManager,
696+
697+ // Vim mode scroll actions
698+ "vim_scroll_left": ActionVimScrollLeft,
699+ "vim-scroll-left": ActionVimScrollLeft,
700+ "vim_scroll_down": ActionVimScrollDown,
701+ "vim-scroll-down": ActionVimScrollDown,
702+ "vim_scroll_up": ActionVimScrollUp,
703+ "vim-scroll-up": ActionVimScrollUp,
704+ "vim_scroll_right": ActionVimScrollRight,
705+ "vim-scroll-right": ActionVimScrollRight,
706+ "vim_scroll_down_fast": ActionVimScrollDownFast,
707+ "vim-scroll-down-fast": ActionVimScrollDownFast,
708+ "vim_scroll_up_fast": ActionVimScrollUpFast,
709+ "vim-scroll-up-fast": ActionVimScrollUpFast,
645710 }
646711
647712 // FloatingProfileTarget carries the session identity and URL for a floating profile action.
@@ -791,7 +856,9 @@ func stringToKeyval(s string) (uint, bool) {
791856 }
792857
793858 // Lookup finds an action for the given key binding in the appropriate table.
794-// It first checks the mode-specific table, then falls back to global.
859+// It first checks the mode-specific table, then falls back to global unless
860+// the current mode is Vim Mode. Vim Mode intentionally stays self-contained
861+// so app-global shortcuts do not interrupt held Vim-style scrolling.
795862 func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
796863 // Normalize the binding modifiers
797864 binding.Modifiers &= modifierMask
@@ -807,12 +874,17 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
807874 modeTable = s.ResizeMode
808875 case ModeSession:
809876 modeTable = s.SessionMode
877+ case ModeVim:
878+ modeTable = s.VimMode
810879 }
811880
812881 if modeTable != nil {
813882 if action, ok := modeTable[binding]; ok {
814883 return action, true
815884 }
885+ if mode == ModeVim {
886+ return "", false
887+ }
816888 }
817889
818890 // Fall back to global
M internal/ui/input/shortcuts_test.go +143 -2
@@ -1,8 +1,10 @@
11 package input
22
33 import (
4+ "context"
45 "testing"
56
7+ "github.com/bnema/dumber/internal/domain/entity"
68 "github.com/bnema/puregotk/v4/gdk"
79 )
810
@@ -401,7 +403,7 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
401403 want Action
402404 }{
403405 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
404- {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},
406+ {name: "toggle_favorites_sidebar", want: ActionToggleFavoritesSidebar},
405407 {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},
406408 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
407409 }
@@ -421,7 +423,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
421423 want Action
422424 }{
423425 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
424- {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},
426+ {name: "toggle-favorites-sidebar", want: ActionToggleFavoritesSidebar},
425427 {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
426428 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
427429 }
@@ -462,3 +464,142 @@ func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) {
462464 })
463465 }
464466 }
467+
468+func TestNewShortcutSet_VimModeActivationShortcut(t *testing.T) {
469+ workspace := &entity.WorkspaceConfig{
470+ TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
471+ PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
472+ VimMode: entity.VimModeConfig{ActivationShortcut: "ctrl+y"},
473+ }
474+
475+ set := NewShortcutSet(context.Background(), workspace, nil)
476+
477+ binding, ok := ParseKeyString("ctrl+y")
478+ if !ok {
479+ t.Fatal("failed to parse ctrl+y")
480+ }
481+
482+ action, found := set.Global[binding]
483+ if !found {
484+ t.Fatal("vim mode activation shortcut not found in Global")
485+ }
486+ if action != ActionEnterVimMode {
487+ t.Fatalf("Global shortcut action = %s, want %s", action, ActionEnterVimMode)
488+ }
489+}
490+
491+func TestShortcutSet_Lookup_VimMode(t *testing.T) {
492+ set := &ShortcutSet{
493+ Global: ShortcutTable{
494+ {uint(gdk.KEY_q), ModCtrl}: ActionQuit,
495+ },
496+ VimMode: ShortcutTable{
497+ {uint('h'), ModNone}: ActionVimScrollLeft,
498+ {uint('j'), ModNone}: ActionVimScrollDown,
499+ {uint('k'), ModNone}: ActionVimScrollUp,
500+ {uint('l'), ModNone}: ActionVimScrollRight,
501+ {uint(gdk.KEY_Escape), ModNone}: ActionExitMode,
502+ {uint(gdk.KEY_Return), ModNone}: ActionExitMode,
503+ {uint('y'), ModCtrl}: ActionEnterVimMode,
504+ },
505+ }
506+
507+ tests := []struct {
508+ name string
509+ binding KeyBinding
510+ want Action
511+ wantOk bool
512+ }{
513+ {
514+ name: "scroll left",
515+ binding: KeyBinding{uint('h'), ModNone},
516+ want: ActionVimScrollLeft,
517+ wantOk: true,
518+ },
519+ {
520+ name: "scroll down",
521+ binding: KeyBinding{uint('j'), ModNone},
522+ want: ActionVimScrollDown,
523+ wantOk: true,
524+ },
525+ {
526+ name: "scroll up",
527+ binding: KeyBinding{uint('k'), ModNone},
528+ want: ActionVimScrollUp,
529+ wantOk: true,
530+ },
531+ {
532+ name: "scroll right",
533+ binding: KeyBinding{uint('l'), ModNone},
534+ want: ActionVimScrollRight,
535+ wantOk: true,
536+ },
537+ {
538+ name: "escape exits vim mode",
539+ binding: KeyBinding{uint(gdk.KEY_Escape), ModNone},
540+ want: ActionExitMode,
541+ wantOk: true,
542+ },
543+ {
544+ name: "enter exits vim mode",
545+ binding: KeyBinding{uint(gdk.KEY_Return), ModNone},
546+ want: ActionExitMode,
547+ wantOk: true,
548+ },
549+ {
550+ name: "activation shortcut still toggles vim mode",
551+ binding: KeyBinding{uint('y'), ModCtrl},
552+ want: ActionEnterVimMode,
553+ wantOk: true,
554+ },
555+ {
556+ name: "global shortcut does not fire inside vim mode",
557+ binding: KeyBinding{uint(gdk.KEY_q), ModCtrl},
558+ want: "",
559+ wantOk: false,
560+ },
561+ }
562+
563+ for _, tt := range tests {
564+ t.Run(tt.name, func(t *testing.T) {
565+ got, ok := set.Lookup(tt.binding, ModeVim)
566+ if ok != tt.wantOk {
567+ t.Errorf("Lookup() ok = %v, want %v", ok, tt.wantOk)
568+ return
569+ }
570+ if got != tt.want {
571+ t.Errorf("Lookup() = %v, want %v", got, tt.want)
572+ }
573+ })
574+ }
575+}
576+
577+func TestMapConfigAction_VimScrollActions(t *testing.T) {
578+ tests := []struct {
579+ name string
580+ config string
581+ expected Action
582+ }{
583+ {name: "vim_scroll_left", config: "vim_scroll_left", expected: ActionVimScrollLeft},
584+ {name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft},
585+ {name: "vim_scroll_down", config: "vim_scroll_down", expected: ActionVimScrollDown},
586+ {name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown},
587+ {name: "vim_scroll_up", config: "vim_scroll_up", expected: ActionVimScrollUp},
588+ {name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp},
589+ {name: "vim_scroll_right", config: "vim_scroll_right", expected: ActionVimScrollRight},
590+ {name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight},
591+ {name: "vim_scroll_down_fast", config: "vim_scroll_down_fast", expected: ActionVimScrollDownFast},
592+ {name: "vim-scroll-down-fast", config: "vim-scroll-down-fast", expected: ActionVimScrollDownFast},
593+ {name: "vim_scroll_up_fast", config: "vim_scroll_up_fast", expected: ActionVimScrollUpFast},
594+ {name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast},
595+ }
596+
597+ for _, tt := range tests {
598+ t.Run(tt.name, func(t *testing.T) {
599+ got := mapConfigAction(tt.config)
600+ if got != tt.expected {
601+ t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.config, got, tt.expected)
602+ }
603+ })
604+ }
605+}
A internal/ui/input/vimkeys_bridge.go +94 -0
@@ -0,0 +1,94 @@
1+package input
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/dumber/internal/domain/vimkeys"
7+ "github.com/bnema/puregotk/v4/gdk"
8+)
9+
10+// namedKeyvalSym maps a GDK named keyval onto a canonical vimkeys symbol.
11+// Immutable switch mirrors Phase 1 parser quality (no mutable package map).
12+func namedKeyvalSym(keyval uint) (string, bool) {
13+ switch keyval {
14+ case uint(gdk.KEY_Escape):
15+ return "Esc", true
16+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
17+ return "CR", true
18+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
19+ return "Tab", true
20+ case uint(gdk.KEY_BackSpace):
21+ return "BackSpace", true
22+ case uint(gdk.KEY_space):
23+ return "Space", true
24+ case uint(gdk.KEY_Left):
25+ return "Left", true
26+ case uint(gdk.KEY_Right):
27+ return "Right", true
28+ case uint(gdk.KEY_Up):
29+ return "Up", true
30+ case uint(gdk.KEY_Down):
31+ return "Down", true
32+ case uint(gdk.KEY_Home):
33+ return "Home", true
34+ case uint(gdk.KEY_End):
35+ return "End", true
36+ case uint(gdk.KEY_Page_Up):
37+ return "PageUp", true
38+ case uint(gdk.KEY_Page_Down):
39+ return "PageDown", true
40+ case uint(gdk.KEY_Delete):
41+ return "Delete", true
42+ default:
43+ return "", false
44+ }
45+}
46+
47+// KeyvalToVimKey converts a GDK key event into a vimkeys.Key.
48+// Named keys keep modifiers. Printable non-letter glyphs drop Shift because the
49+// glyph already encodes it; Ctrl and Alt are preserved.
50+func KeyvalToVimKey(keyval uint, state gdk.ModifierType) (vimkeys.Key, bool) {
51+ mods := vimModsFromState(state)
52+ if sym, ok := namedKeyvalSym(keyval); ok {
53+ return vimkeys.Key{Sym: sym, Mods: mods}, true
54+ }
55+
56+ r := rune(keyvalToUnicode(keyval))
57+ if r == 0 || r < 0x20 {
58+ return vimkeys.Key{}, false
59+ }
60+ if r >= 'A' && r <= 'Z' {
61+ return vimkeys.Key{Sym: strings.ToLower(string(r)), Mods: mods}, true
62+ }
63+ if r >= 'a' && r <= 'z' {
64+ return vimkeys.Key{Sym: string(r), Mods: mods}, true
65+ }
66+ return vimkeys.Key{Sym: string(r), Mods: mods &^ vimkeys.ModShift}, true
67+}
68+
69+func vimModsFromState(state gdk.ModifierType) vimkeys.Mods {
70+ var mods vimkeys.Mods
71+ if state&gdk.ControlMaskValue != 0 {
72+ mods |= vimkeys.ModCtrl
73+ }
74+ if state&gdk.ShiftMaskValue != 0 {
75+ mods |= vimkeys.ModShift
76+ }
77+ if state&gdk.AltMaskValue != 0 {
78+ mods |= vimkeys.ModAlt
79+ }
80+ return mods
81+}
82+
83+// keyvalToUnicode mirrors gdk_keyval_to_unicode for Latin-1 graphic keysyms
84+// (identity mapping) and falls back to GDK for the remaining keyvals.
85+func keyvalToUnicode(keyval uint) uint32 {
86+ // ASCII graphic and Latin-1 supplement keysyms share Unicode code points.
87+ if keyval >= 0x20 && keyval <= 0x7e {
88+ return uint32(keyval)
89+ }
90+ if keyval >= 0xa0 && keyval <= 0xff {
91+ return uint32(keyval)
92+ }
93+ return gdk.KeyvalToUnicode(keyval)
94+}
A internal/ui/input/vimkeys_bridge_test.go +207 -0
@@ -0,0 +1,207 @@
1+package input
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/vimkeys"
7+ "github.com/bnema/puregotk/v4/gdk"
8+)
9+
10+func TestKeyvalToVimKey(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ keyval uint
14+ state gdk.ModifierType
15+ want vimkeys.Key
16+ wantOk bool
17+ }{
18+ {
19+ name: "braceright with shift encodes glyph without shift mod",
20+ keyval: uint(gdk.KEY_braceright),
21+ state: gdk.ShiftMaskValue,
22+ want: vimkeys.Key{Sym: "}"},
23+ wantOk: true,
24+ },
25+ {
26+ name: "uppercase J with shift keeps letter and shift",
27+ keyval: uint(gdk.KEY_J),
28+ state: gdk.ShiftMaskValue,
29+ want: vimkeys.Key{Sym: "j", Mods: vimkeys.ModShift},
30+ wantOk: true,
31+ },
32+ {
33+ name: "lowercase j without mods",
34+ keyval: uint(gdk.KEY_j),
35+ state: gdk.NoModifierMaskValue,
36+ want: vimkeys.Key{Sym: "j"},
37+ wantOk: true,
38+ },
39+ {
40+ name: "named Escape keeps ctrl modifier",
41+ keyval: uint(gdk.KEY_Escape),
42+ state: gdk.ControlMaskValue,
43+ want: vimkeys.Key{Sym: "Esc", Mods: vimkeys.ModCtrl},
44+ wantOk: true,
45+ },
46+ {
47+ name: "named Return keeps shift modifier",
48+ keyval: uint(gdk.KEY_Return),
49+ state: gdk.ShiftMaskValue,
50+ want: vimkeys.Key{Sym: "CR", Mods: vimkeys.ModShift},
51+ wantOk: true,
52+ },
53+ {
54+ name: "named KP_Enter maps to CR",
55+ keyval: uint(gdk.KEY_KP_Enter),
56+ state: gdk.NoModifierMaskValue,
57+ want: vimkeys.Key{Sym: "CR"},
58+ wantOk: true,
59+ },
60+ {
61+ name: "named Space keeps alt modifier",
62+ keyval: uint(gdk.KEY_space),
63+ state: gdk.AltMaskValue,
64+ want: vimkeys.Key{Sym: "Space", Mods: vimkeys.ModAlt},
65+ wantOk: true,
66+ },
67+ {
68+ name: "named Tab maps to Tab",
69+ keyval: uint(gdk.KEY_Tab),
70+ state: gdk.NoModifierMaskValue,
71+ want: vimkeys.Key{Sym: "Tab"},
72+ wantOk: true,
73+ },
74+ {
75+ name: "named ISO_Left_Tab maps to Tab",
76+ keyval: uint(gdk.KEY_ISO_Left_Tab),
77+ state: gdk.ShiftMaskValue,
78+ want: vimkeys.Key{Sym: "Tab", Mods: vimkeys.ModShift},
79+ wantOk: true,
80+ },
81+ {
82+ name: "named BackSpace is BackSpace not BS",
83+ keyval: uint(gdk.KEY_BackSpace),
84+ state: gdk.NoModifierMaskValue,
85+ want: vimkeys.Key{Sym: "BackSpace"},
86+ wantOk: true,
87+ },
88+ {
89+ name: "named Delete is Delete not Del",
90+ keyval: uint(gdk.KEY_Delete),
91+ state: gdk.ControlMaskValue,
92+ want: vimkeys.Key{Sym: "Delete", Mods: vimkeys.ModCtrl},
93+ wantOk: true,
94+ },
95+ {
96+ name: "named Left keeps ctrl",
97+ keyval: uint(gdk.KEY_Left),
98+ state: gdk.ControlMaskValue,
99+ want: vimkeys.Key{Sym: "Left", Mods: vimkeys.ModCtrl},
100+ wantOk: true,
101+ },
102+ {
103+ name: "named Right",
104+ keyval: uint(gdk.KEY_Right),
105+ state: gdk.NoModifierMaskValue,
106+ want: vimkeys.Key{Sym: "Right"},
107+ wantOk: true,
108+ },
109+ {
110+ name: "named Up",
111+ keyval: uint(gdk.KEY_Up),
112+ state: gdk.NoModifierMaskValue,
113+ want: vimkeys.Key{Sym: "Up"},
114+ wantOk: true,
115+ },
116+ {
117+ name: "named Down",
118+ keyval: uint(gdk.KEY_Down),
119+ state: gdk.NoModifierMaskValue,
120+ want: vimkeys.Key{Sym: "Down"},
121+ wantOk: true,
122+ },
123+ {
124+ name: "named Home",
125+ keyval: uint(gdk.KEY_Home),
126+ state: gdk.NoModifierMaskValue,
127+ want: vimkeys.Key{Sym: "Home"},
128+ wantOk: true,
129+ },
130+ {
131+ name: "named End",
132+ keyval: uint(gdk.KEY_End),
133+ state: gdk.NoModifierMaskValue,
134+ want: vimkeys.Key{Sym: "End"},
135+ wantOk: true,
136+ },
137+ {
138+ name: "named PageUp",
139+ keyval: uint(gdk.KEY_Page_Up),
140+ state: gdk.NoModifierMaskValue,
141+ want: vimkeys.Key{Sym: "PageUp"},
142+ wantOk: true,
143+ },
144+ {
145+ name: "named PageDown",
146+ keyval: uint(gdk.KEY_Page_Down),
147+ state: gdk.AltMaskValue,
148+ want: vimkeys.Key{Sym: "PageDown", Mods: vimkeys.ModAlt},
149+ wantOk: true,
150+ },
151+ {
152+ name: "ctrl+d printable letter keeps ctrl",
153+ keyval: uint(gdk.KEY_d),
154+ state: gdk.ControlMaskValue,
155+ want: vimkeys.Key{Sym: "d", Mods: vimkeys.ModCtrl},
156+ wantOk: true,
157+ },
158+ {
159+ name: "ctrl+eacute Latin-1 keyval round-trips through parser",
160+ keyval: uint(gdk.KEY_eacute),
161+ state: gdk.ControlMaskValue,
162+ want: vimkeys.Key{Sym: "é", Mods: vimkeys.ModCtrl},
163+ wantOk: true,
164+ },
165+ {
166+ name: "non-printable rejected",
167+ keyval: 0,
168+ state: gdk.NoModifierMaskValue,
169+ wantOk: false,
170+ },
171+ }
172+
173+ for _, tt := range tests {
174+ t.Run(tt.name+"_roundtrip_string", func(t *testing.T) {
175+ if !tt.wantOk {
176+ return
177+ }
178+ got, ok := KeyvalToVimKey(tt.keyval, tt.state)
179+ if !ok {
180+ t.Fatalf("KeyvalToVimKey() ok = false")
181+ }
182+ canonical := got.String()
183+ parsed, err := vimkeys.ParseBinding(canonical)
184+ if err != nil {
185+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
186+ }
187+ if !parsed.Equal(vimkeys.Sequence{got}) {
188+ t.Fatalf("round-trip %q -> %v, want %v", canonical, parsed, vimkeys.Sequence{got})
189+ }
190+ })
191+ }
192+
193+ for _, tt := range tests {
194+ t.Run(tt.name, func(t *testing.T) {
195+ got, ok := KeyvalToVimKey(tt.keyval, tt.state)
196+ if ok != tt.wantOk {
197+ t.Fatalf("KeyvalToVimKey() ok = %v, want %v", ok, tt.wantOk)
198+ }
199+ if !tt.wantOk {
200+ return
201+ }
202+ if got != tt.want {
203+ t.Fatalf("KeyvalToVimKey() = %#v, want %#v", got, tt.want)
204+ }
205+ })
206+ }
207+}
A internal/ui/input/vimkeys_dispatch.go +350 -0
@@ -0,0 +1,350 @@
1+package input
2+
3+import (
4+ "sort"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/vimkeys"
10+ "github.com/bnema/puregotk/v4/gdk"
11+ "github.com/rs/zerolog/log"
12+)
13+
14+func isCountStartKey(key vimkeys.Key) bool {
15+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
16+}
17+
18+// trieOwnsBinding reports whether a raw config binding belongs to the Vim
19+// sequence trie rather than the legacy single-chord Vim Mode table.
20+// Multi-key sequences and Vim-only chords that ParseKeyString cannot read are owned.
21+func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) {
22+ seq, err := vimkeys.ParseBinding(raw)
23+ if err != nil {
24+ return nil, false
25+ }
26+ if len(seq) >= 2 {
27+ if isCountStartKey(seq[0]) {
28+ return nil, false
29+ }
30+ return seq, true
31+ }
32+ if _, ok := ParseKeyString(raw); !ok {
33+ return seq, true
34+ }
35+ return nil, false
36+}
37+
38+// buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are
39+// sorted so the first sorted name wins on Insert conflicts (validation already
40+// rejects duplicates). Ownership is marked only after a successful Insert.
41+func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool) {
42+ trie := vimkeys.NewTrie()
43+ owned := make(map[string]bool)
44+ if cfg == nil {
45+ return trie, owned
46+ }
47+
48+ actions := make([]string, 0, len(cfg.Actions))
49+ for action := range cfg.Actions {
50+ actions = append(actions, action)
51+ }
52+ sort.Strings(actions)
53+
54+ for _, action := range actions {
55+ binding := cfg.Actions[action]
56+ keys := append([]string(nil), binding.Keys...)
57+ sort.Strings(keys)
58+ for _, key := range keys {
59+ seq, ok := trieOwnsBinding(key)
60+ if !ok {
61+ continue
62+ }
63+ if err := trie.Insert(seq, action); err != nil {
64+ log.Debug().
65+ Err(err).
66+ Str("key", key).
67+ Str("action", action).
68+ Msg("vim mode trie insert skipped")
69+ continue
70+ }
71+ owned[key] = true
72+ }
73+ }
74+ return trie, owned
75+}
76+
77+// VimModeSequences returns the Vim sequence trie, if any.
78+func (s *ShortcutSet) VimModeSequences() *vimkeys.Trie {
79+ if s == nil {
80+ return nil
81+ }
82+ return s.vimModeSequences
83+}
84+
85+// filterLegacyVimModeBindings copies GetKeyBindings entries that the trie does not own.
86+func filterLegacyVimModeBindings(bindings map[string]string, owned map[string]bool) map[string]string {
87+ legacy := make(map[string]string, len(bindings))
88+ for key, action := range bindings {
89+ if owned[key] {
90+ continue
91+ }
92+ legacy[key] = action
93+ }
94+ return legacy
95+}
96+
97+// sequenceTimer is the injectable ambiguity-timeout seam.
98+type sequenceTimer interface {
99+ Stop() bool
100+}
101+
102+// vimModeSequenceState holds per-handler matcher state and ambiguity timing.
103+type vimModeSequenceState struct {
104+ mu sync.Mutex
105+ matcher *vimkeys.Matcher
106+ onPending func(string)
107+ onAction func(string, int)
108+ timeout time.Duration
109+ timer sequenceTimer
110+ afterFunc func(time.Duration, func()) sequenceTimer
111+ scheduleOnMainThread func(func())
112+ generation uint64
113+}
114+
115+func newVimModeSequenceState(trie *vimkeys.Trie, timeout time.Duration) vimModeSequenceState {
116+ return vimModeSequenceState{
117+ matcher: vimkeys.NewMatcher(trie),
118+ timeout: timeout,
119+ afterFunc: func(d time.Duration, fn func()) sequenceTimer {
120+ return time.AfterFunc(d, fn)
121+ },
122+ scheduleOnMainThread: func(fn func()) { fn() },
123+ }
124+}
125+
126+// SetOnPendingSequenceChange sets the callback for pending sequence text updates.
127+func (h *KeyboardHandler) SetOnPendingSequenceChange(fn func(string)) {
128+ h.seq.mu.Lock()
129+ defer h.seq.mu.Unlock()
130+ h.seq.onPending = fn
131+}
132+
133+// SetOnSequenceAction sets the callback for completed sequence actions.
134+func (h *KeyboardHandler) SetOnSequenceAction(fn func(string, int)) {
135+ h.seq.mu.Lock()
136+ defer h.seq.mu.Unlock()
137+ h.seq.onAction = fn
138+}
139+
140+// SetSequenceTimeout sets the ambiguity resolution timeout.
141+func (h *KeyboardHandler) SetSequenceTimeout(d time.Duration) {
142+ h.seq.mu.Lock()
143+ defer h.seq.mu.Unlock()
144+ h.seq.timeout = d
145+}
146+
147+// SetSequenceMainThreadScheduler sets the main-thread dispatcher for ambiguity
148+// resolution. Timer goroutines must not touch matcher state directly.
149+func (h *KeyboardHandler) SetSequenceMainThreadScheduler(fn func(func())) {
150+ h.seq.mu.Lock()
151+ defer h.seq.mu.Unlock()
152+ if fn == nil {
153+ return
154+ }
155+ h.seq.scheduleOnMainThread = fn
156+}
157+
158+// PendingSequence returns the current pending count+keys string.
159+func (h *KeyboardHandler) PendingSequence() string {
160+ h.seq.mu.Lock()
161+ defer h.seq.mu.Unlock()
162+ if h.seq.matcher == nil {
163+ return ""
164+ }
165+ return h.seq.matcher.Pending()
166+}
167+
168+// ResetPendingSequence clears matcher state and notifies pending listeners.
169+func (h *KeyboardHandler) ResetPendingSequence() {
170+ h.resetPendingSequence(true)
171+}
172+
173+// resetPendingSequence clears matcher/timer state. When notify is false (mode
174+// exit under ModalState lock), pending callbacks are skipped.
175+func (h *KeyboardHandler) resetPendingSequence(notify bool) {
176+ h.seq.mu.Lock()
177+ h.stopSequenceTimerLocked()
178+ h.seq.generation++
179+ if h.seq.matcher != nil {
180+ h.seq.matcher.Reset()
181+ }
182+ onPending := h.seq.onPending
183+ h.seq.mu.Unlock()
184+
185+ if notify && onPending != nil {
186+ onPending("")
187+ }
188+}
189+
190+// teardownSequenceState linearizably cancels sequence timing and clears
191+// UI-retaining callbacks without notifying. Used by Detach/DetachForDestroy.
192+// Lock order: call without holding h.mu (same as resetPendingSequence).
193+func (h *KeyboardHandler) teardownSequenceState() {
194+ h.seq.mu.Lock()
195+ defer h.seq.mu.Unlock()
196+ h.stopSequenceTimerLocked()
197+ h.seq.generation++
198+ if h.seq.matcher != nil {
199+ h.seq.matcher.Reset()
200+ }
201+ h.seq.onPending = nil
202+ h.seq.onAction = nil
203+ h.seq.scheduleOnMainThread = nil
204+ log.Debug().Msg("vim mode sequence state torn down without notify")
205+}
206+
207+// InvalidateSequenceMatcher stops timing, bumps generation, and replaces the
208+// matcher from the current VimModeSequences trie (hot reload).
209+func (h *KeyboardHandler) InvalidateSequenceMatcher() {
210+ h.mu.RLock()
211+ var trie *vimkeys.Trie
212+ if h.shortcuts != nil {
213+ trie = h.shortcuts.VimModeSequences()
214+ }
215+ h.mu.RUnlock()
216+
217+ h.seq.mu.Lock()
218+ defer h.seq.mu.Unlock()
219+ h.stopSequenceTimerLocked()
220+ h.seq.generation++
221+ h.seq.matcher = vimkeys.NewMatcher(trie)
222+ log.Debug().Msg("vim mode sequence matcher replaced")
223+}
224+
225+// feedVimModeSequence feeds a key into the vim-mode sequence matcher.
226+// Pending/Complete consume the event (true). Invalid clears and returns false
227+// so the legacy shortcut path can handle the key.
228+func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierType) bool {
229+ if h.modal.Mode() != ModeVim {
230+ return false
231+ }
232+ key, ok := KeyvalToVimKey(keyval, state)
233+ if !ok {
234+ return false
235+ }
236+
237+ h.seq.mu.Lock()
238+ if h.seq.matcher == nil {
239+ h.seq.mu.Unlock()
240+ return false
241+ }
242+
243+ h.stopSequenceTimerLocked()
244+ h.seq.generation++
245+ hadPending := h.seq.matcher.Pending() != ""
246+
247+ result := h.seq.matcher.Feed(key)
248+ onPending := h.seq.onPending
249+ onAction := h.seq.onAction
250+
251+ switch result.Kind {
252+ case vimkeys.ResultPending:
253+ pending := result.Pending
254+ if pending == "" {
255+ pending = h.seq.matcher.Pending()
256+ }
257+ ambiguous := h.seq.matcher.Ambiguous()
258+ if ambiguous {
259+ h.armAmbiguityTimeoutLocked()
260+ }
261+ h.seq.mu.Unlock()
262+ if onPending != nil {
263+ onPending(pending)
264+ }
265+ log.Debug().Str("pending", pending).Bool("ambiguous", ambiguous).Msg("vim mode sequence pending")
266+ return true
267+
268+ case vimkeys.ResultComplete:
269+ action := result.Action
270+ count := result.Count
271+ h.seq.mu.Unlock()
272+ if onPending != nil {
273+ onPending("")
274+ }
275+ if onAction != nil {
276+ onAction(action, count)
277+ }
278+ log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete")
279+ return true
280+
281+ default: // ResultInvalid
282+ h.seq.mu.Unlock()
283+ if hadPending && onPending != nil {
284+ onPending("")
285+ }
286+ log.Debug().Msg("vim mode sequence invalid; falling through to legacy")
287+ return false
288+ }
289+}
290+
291+func (h *KeyboardHandler) stopSequenceTimerLocked() {
292+ if h.seq.timer != nil {
293+ h.seq.timer.Stop()
294+ h.seq.timer = nil
295+ }
296+}
297+
298+func (h *KeyboardHandler) armAmbiguityTimeoutLocked() {
299+ gen := h.seq.generation
300+ timeout := h.seq.timeout
301+ schedule := h.seq.scheduleOnMainThread
302+ afterFunc := h.seq.afterFunc
303+ if afterFunc == nil {
304+ afterFunc = func(d time.Duration, fn func()) sequenceTimer {
305+ return time.AfterFunc(d, fn)
306+ }
307+ }
308+ if schedule == nil {
309+ schedule = func(fn func()) { fn() }
310+ }
311+ h.seq.timer = afterFunc(timeout, func() {
312+ schedule(func() {
313+ h.resolveAmbiguity(gen)
314+ })
315+ })
316+ log.Debug().Dur("timeout", timeout).Uint64("generation", gen).Msg("vim mode ambiguity timer armed")
317+}
318+
319+func (h *KeyboardHandler) resolveAmbiguity(gen uint64) {
320+ h.seq.mu.Lock()
321+ if h.seq.generation != gen {
322+ h.seq.mu.Unlock()
323+ log.Debug().Uint64("generation", gen).Msg("stale vim mode ambiguity resolve ignored")
324+ return
325+ }
326+ if h.seq.matcher == nil {
327+ h.seq.mu.Unlock()
328+ return
329+ }
330+ result, ok := h.seq.matcher.ResolveAmbiguity()
331+ if !ok {
332+ h.seq.mu.Unlock()
333+ return
334+ }
335+ h.seq.timer = nil
336+ h.seq.generation++
337+ onPending := h.seq.onPending
338+ onAction := h.seq.onAction
339+ action := result.Action
340+ count := result.Count
341+ h.seq.mu.Unlock()
342+
343+ if onPending != nil {
344+ onPending("")
345+ }
346+ if onAction != nil {
347+ onAction(action, count)
348+ }
349+ log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved")
350+}
A internal/ui/input/vimkeys_dispatch_test.go +725 -0
@@ -0,0 +1,725 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/vimkeys"
10+ "github.com/bnema/puregotk/v4/gdk"
11+)
12+
13+type stubSequenceTimer struct {
14+ onStop func()
15+}
16+
17+func (s stubSequenceTimer) Stop() bool {
18+ if s.onStop != nil {
19+ s.onStop()
20+ }
21+ return true
22+}
23+
24+func vimModeSequenceWorkspace(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig {
25+ ws := newTestWorkspace()
26+ ws.VimMode = entity.VimModeConfig{
27+ ActivationShortcut: "ctrl+y",
28+ TimeoutMilliseconds: 0,
29+ SequenceTimeoutMilliseconds: 500,
30+ Actions: actions,
31+ }
32+ return ws
33+}
34+
35+func enterVimMode(t *testing.T, h *KeyboardHandler) {
36+ t.Helper()
37+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
38+ t.Fatal("ctrl+y should enter vim mode")
39+ }
40+ if h.Mode() != ModeVim {
41+ t.Fatalf("mode = %v, want ModeVim", h.Mode())
42+ }
43+}
44+
45+func capturePending(h *KeyboardHandler) *[]string {
46+ pending := &[]string{}
47+ h.SetOnPendingSequenceChange(func(p string) {
48+ *pending = append(*pending, p)
49+ })
50+ return pending
51+}
52+
53+func captureSequenceActions(h *KeyboardHandler) *[]struct {
54+ action string
55+ count int
56+} {
57+ actions := &[]struct {
58+ action string
59+ count int
60+ }{}
61+ h.SetOnSequenceAction(func(action string, count int) {
62+ *actions = append(*actions, struct {
63+ action string
64+ count int
65+ }{action: action, count: count})
66+ })
67+ return actions
68+}
69+
70+func TestNewVimModeSequenceState_NilTrie(t *testing.T) {
71+ state := newVimModeSequenceState(nil, 0)
72+ if state.matcher == nil {
73+ t.Fatal("matcher is nil")
74+ }
75+ if got := state.matcher.Pending(); got != "" {
76+ t.Fatalf("Pending() = %q, want empty", got)
77+ }
78+ if state.matcher.Ambiguous() {
79+ t.Fatal("Ambiguous() = true, want false")
80+ }
81+ result := state.matcher.Feed(vimkeys.Key{Sym: "x"})
82+ if result.Kind != vimkeys.ResultInvalid {
83+ t.Fatalf("Feed kind = %v, want %v", result.Kind, vimkeys.ResultInvalid)
84+ }
85+}
86+
87+func TestTrieOwnsBinding(t *testing.T) {
88+ cases := map[string]bool{
89+ "]]": true,
90+ "gi": true,
91+ "yah": true,
92+ "gO": true,
93+ "<C-d>": true,
94+ "j": false,
95+ "shift+j": false,
96+ "}": false,
97+ "enter": false,
98+ "escape": false,
99+ "<Nope>": false,
100+ }
101+
102+ for raw, wantOwned := range cases {
103+ t.Run(raw, func(t *testing.T) {
104+ seq, owned := trieOwnsBinding(raw)
105+ if owned != wantOwned {
106+ t.Fatalf("trieOwnsBinding(%q) owned = %v, want %v (seq=%v)", raw, owned, wantOwned, seq)
107+ }
108+ if !wantOwned {
109+ return
110+ }
111+ if len(seq) == 0 {
112+ t.Fatalf("trieOwnsBinding(%q) returned empty sequence", raw)
113+ }
114+ })
115+ }
116+}
117+
118+func TestTrieOwnsBinding_CrossContractAtoms(t *testing.T) {
119+ tests := []struct {
120+ raw string
121+ want vimkeys.Sequence
122+ }{
123+ {raw: "}", want: vimkeys.Sequence{{Sym: "}"}}},
124+ {raw: "<C-d>", want: vimkeys.Sequence{{Sym: "d", Mods: vimkeys.ModCtrl}}},
125+ {raw: "<CR>", want: vimkeys.Sequence{{Sym: "CR"}}},
126+ {raw: "J", want: vimkeys.Sequence{{Sym: "j", Mods: vimkeys.ModShift}}},
127+ {raw: "enter", want: vimkeys.Sequence{{Sym: "CR"}}},
128+ {raw: "escape", want: vimkeys.Sequence{{Sym: "Esc"}}},
129+ }
130+
131+ for _, tt := range tests {
132+ t.Run(tt.raw, func(t *testing.T) {
133+ got, err := vimkeys.ParseBinding(tt.raw)
134+ if err != nil {
135+ t.Fatalf("ParseBinding(%q) error = %v", tt.raw, err)
136+ }
137+ if !got.Equal(tt.want) {
138+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.raw, got, tt.want)
139+ }
140+ _, owned := trieOwnsBinding(tt.raw)
141+ switch tt.raw {
142+ case "<C-d>", "<CR>":
143+ if !owned {
144+ t.Fatalf("trieOwnsBinding(%q) = false, want true", tt.raw)
145+ }
146+ default:
147+ if owned {
148+ t.Fatalf("trieOwnsBinding(%q) = true, want false", tt.raw)
149+ }
150+ }
151+ })
152+ }
153+}
154+
155+func TestBuildVimModeTrie(t *testing.T) {
156+ cfg := &entity.VimModeConfig{
157+ Actions: map[string]entity.ActionBinding{
158+ "vim-scroll-down": {Keys: []string{"j"}},
159+ "heading-next": {Keys: []string{"]]"}},
160+ "focus-input": {Keys: []string{"gi"}},
161+ "yank-section": {Keys: []string{"yah"}},
162+ "outline": {Keys: []string{"gO"}},
163+ "half-page-down": {Keys: []string{"<C-d>"}},
164+ "confirm": {Keys: []string{"enter"}},
165+ "cancel": {Keys: []string{"escape"}},
166+ },
167+ }
168+
169+ trie, owned := buildVimModeTrie(cfg)
170+ if trie == nil {
171+ t.Fatal("buildVimModeTrie returned nil trie")
172+ }
173+
174+ wantOwned := map[string]bool{
175+ "]]": true,
176+ "gi": true,
177+ "yah": true,
178+ "gO": true,
179+ "<C-d>": true,
180+ }
181+ for key, want := range wantOwned {
182+ if owned[key] != want {
183+ t.Fatalf("owned[%q] = %v, want %v", key, owned[key], want)
184+ }
185+ }
186+ for _, legacy := range []string{"j", "enter", "escape"} {
187+ if owned[legacy] {
188+ t.Fatalf("legacy binding %q should not be owned", legacy)
189+ }
190+ }
191+
192+ seq, err := vimkeys.ParseBinding("]]")
193+ if err != nil {
194+ t.Fatalf("ParseBinding(]]) error = %v", err)
195+ }
196+ node, ok := trie.Walk(seq)
197+ if !ok || !node.Exact || node.Action != "heading-next" {
198+ t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
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+ }
209+}
210+
211+func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
212+ // Same sequence under two raw aliases; first sorted action wins Insert.
213+ // Losing raw key must not be marked owned (insert failed).
214+ cfg := &entity.VimModeConfig{
215+ Actions: map[string]entity.ActionBinding{
216+ "zzz-later": {Keys: []string{"<c-d>"}},
217+ "aaa-first": {Keys: []string{"<C-d>"}},
218+ "vim-scroll-down": {Keys: []string{"j"}},
219+ },
220+ }
221+
222+ trie, owned := buildVimModeTrie(cfg)
223+ if !owned["<C-d>"] {
224+ t.Fatal("winning raw key <C-d> should be owned after successful Insert")
225+ }
226+ if owned["<c-d>"] {
227+ t.Fatal("conflicting raw key <c-d> must not be marked owned when Insert fails")
228+ }
229+
230+ seq, err := vimkeys.ParseBinding("<C-d>")
231+ if err != nil {
232+ t.Fatalf("ParseBinding(<C-d>) error = %v", err)
233+ }
234+ node, ok := trie.Walk(seq)
235+ if !ok || !node.Exact || node.Action != "aaa-first" {
236+ t.Fatalf("Walk(<C-d>) = (%#v, %v), want exact aaa-first", node, ok)
237+ }
238+}
239+
240+func vimModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
241+ t.Helper()
242+
243+ workspace := &entity.WorkspaceConfig{
244+ VimMode: entity.VimModeConfig{
245+ ActivationShortcut: "ctrl+y",
246+ Actions: map[string]entity.ActionBinding{
247+ "vim-scroll-down": {Keys: []string{"j"}},
248+ "vim-scroll-down-fast": {Keys: []string{"shift+j"}},
249+ "confirm": {Keys: []string{"enter"}},
250+ "cancel": {Keys: []string{"escape"}},
251+ "heading-next": {Keys: []string{"]]"}},
252+ "yank-section": {Keys: []string{"yah"}},
253+ "outline": {Keys: []string{"gO"}},
254+ "half-page-down": {Keys: []string{"<C-d>"}},
255+ },
256+ },
257+ }
258+
259+ set := NewShortcutSet(context.Background(), workspace, nil)
260+ if set.VimModeSequences() == nil {
261+ t.Fatal("VimModeSequences() is nil")
262+ }
263+ return set
264+}
265+
266+func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
267+ t.Helper()
268+
269+ jBinding, jOK := ParseKeyString("j")
270+ if !jOK {
271+ t.Fatal("ParseKeyString(j) failed")
272+ }
273+ if action, found := set.VimMode[jBinding]; !found || action != ActionVimScrollDown {
274+ t.Fatalf("legacy j missing from VimMode: found=%v action=%q", found, action)
275+ }
276+
277+ enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
278+ if action, found := set.VimMode[enterBinding]; !found || action != ActionExitMode {
279+ t.Fatalf("legacy enter missing from VimMode: found=%v action=%q", found, action)
280+ }
281+
282+ escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
283+ if action, found := set.VimMode[escapeBinding]; !found || action != ActionExitMode {
284+ t.Fatalf("legacy escape missing from VimMode: found=%v action=%q", found, action)
285+ }
286+}
287+
288+func assertOwnedBindingsAbsentFromVimMode(t *testing.T, set *ShortcutSet) {
289+ t.Helper()
290+
291+ for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
292+ if binding, parsed := ParseKeyString(key); parsed {
293+ if action, found := set.VimMode[binding]; found {
294+ t.Fatalf("owned binding %q leaked into VimMode as %q", key, action)
295+ }
296+ }
297+ }
298+}
299+
300+func assertVimModeTrieYankSection(t *testing.T, set *ShortcutSet) {
301+ t.Helper()
302+
303+ seq, err := vimkeys.ParseBinding("yah")
304+ if err != nil {
305+ t.Fatalf("ParseBinding(yah) error = %v", err)
306+ }
307+ node, walked := set.VimModeSequences().Walk(seq)
308+ if !walked || !node.Exact || node.Action != "yank-section" {
309+ t.Fatalf("VimModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
310+ }
311+}
312+
313+func TestShortcutSet_VimModeSequencesOwnership(t *testing.T) {
314+ set := vimModeSequencesOwnershipFixture(t)
315+
316+ t.Run("legacy bindings", func(t *testing.T) {
317+ assertLegacyVimModeBindings(t, set)
318+ })
319+ t.Run("owned bindings absent from legacy table", func(t *testing.T) {
320+ assertOwnedBindingsAbsentFromVimMode(t, set)
321+ })
322+ t.Run("trie resolves owned sequence", func(t *testing.T) {
323+ assertVimModeTrieYankSection(t, set)
324+ })
325+}
326+
327+func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
328+ ctx := context.Background()
329+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
330+ "heading-next": {Keys: []string{"]]"}},
331+ "vim-scroll-down": {Keys: []string{"j"}},
332+ "cancel": {Keys: []string{"escape"}},
333+ })
334+ h := NewKeyboardHandler(ctx, ws, newTestSession())
335+ pending := capturePending(h)
336+ actions := captureSequenceActions(h)
337+ enterVimMode(t, h)
338+
339+ if !h.handleKeyPress(uint(']'), 0, 0) {
340+ t.Fatal("] prefix should be consumed")
341+ }
342+ if h.PendingSequence() != "]" {
343+ t.Fatalf("PendingSequence() = %q, want %q", h.PendingSequence(), "]")
344+ }
345+ if len(*pending) != 1 || (*pending)[0] != "]" {
346+ t.Fatalf("pending notifications = %#v, want [\"]\"]", *pending)
347+ }
348+
349+ if !h.handleKeyPress(uint(']'), 0, 0) {
350+ t.Fatal("]] should be consumed")
351+ }
352+ if h.PendingSequence() != "" {
353+ t.Fatalf("PendingSequence after complete = %q, want empty", h.PendingSequence())
354+ }
355+ if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 0 {
356+ t.Fatalf("actions = %#v, want heading-next count 0", *actions)
357+ }
358+ if len(*pending) < 2 || (*pending)[len(*pending)-1] != "" {
359+ t.Fatalf("pending after complete = %#v, want trailing clear", *pending)
360+ }
361+}
362+
363+func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
364+ ctx := context.Background()
365+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
366+ "yank-section": {Keys: []string{"yah"}},
367+ "vim-scroll-down": {Keys: []string{"j"}},
368+ })
369+ h := NewKeyboardHandler(ctx, ws, newTestSession())
370+ pending := capturePending(h)
371+ actions := captureSequenceActions(h)
372+ enterVimMode(t, h)
373+
374+ for _, step := range []struct {
375+ key uint
376+ want string
377+ }{
378+ {uint('y'), "y"},
379+ {uint('a'), "ya"},
380+ {uint('h'), ""},
381+ } {
382+ if !h.handleKeyPress(step.key, 0, 0) {
383+ t.Fatalf("key %c should be consumed", step.key)
384+ }
385+ if got := h.PendingSequence(); got != step.want {
386+ t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
387+ }
388+ }
389+ if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
390+ t.Fatalf("actions = %#v, want yank-section", *actions)
391+ }
392+ if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
393+ t.Fatalf("pending = %#v, want y then ya", *pending)
394+ }
395+}
396+
397+func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
398+ ctx := context.Background()
399+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
400+ "heading-next": {Keys: []string{"]]"}},
401+ "vim-scroll-down": {Keys: []string{"j"}},
402+ })
403+ h := NewKeyboardHandler(ctx, ws, newTestSession())
404+ var scrollCalls int
405+ h.SetOnAction(func(_ context.Context, action Action) error {
406+ if action == ActionVimScrollDown {
407+ scrollCalls++
408+ }
409+ return nil
410+ })
411+ enterVimMode(t, h)
412+
413+ // Unknown sequence key falls through; legacy j still works.
414+ if !h.handleKeyPress(uint('j'), 0, 0) {
415+ t.Fatal("legacy j should be handled")
416+ }
417+ if scrollCalls != 1 {
418+ t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
419+ }
420+ if h.PendingSequence() != "" {
421+ t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
422+ }
423+}
424+
425+func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
426+ ctx := context.Background()
427+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
428+ "heading-next": {Keys: []string{"]]"}},
429+ })
430+ h := NewKeyboardHandler(ctx, ws, newTestSession())
431+ pending := capturePending(h)
432+
433+ if h.feedVimModeSequence(uint(']'), 0) {
434+ t.Fatal("feed outside vim mode should return false")
435+ }
436+ if len(*pending) != 0 {
437+ t.Fatalf("pending = %#v, want none", *pending)
438+ }
439+}
440+
441+func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
442+ ctx := context.Background()
443+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
444+ "yank-section": {Keys: []string{"yah"}},
445+ "cancel": {Keys: []string{"escape"}},
446+ })
447+ h := NewKeyboardHandler(ctx, ws, newTestSession())
448+ pending := capturePending(h)
449+ enterVimMode(t, h)
450+
451+ if !h.handleKeyPress(uint('y'), 0, 0) {
452+ t.Fatal("y should be consumed")
453+ }
454+ before := len(*pending)
455+
456+ if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
457+ t.Fatal("escape should exit")
458+ }
459+ if h.Mode() != ModeNormal {
460+ t.Fatalf("mode = %v, want ModeNormal", h.Mode())
461+ }
462+ if h.PendingSequence() != "" {
463+ t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
464+ }
465+ if len(*pending) != before {
466+ t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
467+ }
468+}
469+
470+func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
471+ ctx := context.Background()
472+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
473+ "heading-next": {Keys: []string{"]]"}},
474+ })
475+ h := NewKeyboardHandler(ctx, ws, newTestSession())
476+ actions := captureSequenceActions(h)
477+ enterVimMode(t, h)
478+
479+ if !h.handleKeyPress(uint('2'), 0, 0) {
480+ t.Fatal("count digit should be consumed")
481+ }
482+ if h.PendingSequence() != "2" {
483+ t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
484+ }
485+ if !h.handleKeyPress(uint(']'), 0, 0) {
486+ t.Fatal("first ] after count should be consumed")
487+ }
488+ if !h.handleKeyPress(uint(']'), 0, 0) {
489+ t.Fatal("2]] should complete")
490+ }
491+ if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
492+ t.Fatalf("actions = %#v, want heading-next count 2", *actions)
493+ }
494+}
495+
496+func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
497+ ctx := context.Background()
498+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
499+ "bracket-c": {Keys: []string{"]c"}},
500+ "bracket-cc": {Keys: []string{"]cc"}},
501+ })
502+ h := NewKeyboardHandler(ctx, ws, newTestSession())
503+ actions := captureSequenceActions(h)
504+
505+ var fired func()
506+ var stops int
507+ h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
508+ if d != 500*time.Millisecond {
509+ t.Fatalf("ambiguity timeout = %v, want 500ms", d)
510+ }
511+ fired = fn
512+ return stubSequenceTimer{onStop: func() { stops++ }}
513+ }
514+ var queued []func()
515+ h.SetSequenceMainThreadScheduler(func(fn func()) {
516+ queued = append(queued, fn)
517+ })
518+
519+ enterVimMode(t, h)
520+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
521+ t.Fatal("]c prefix should be consumed")
522+ }
523+ if !h.seq.matcher.Ambiguous() {
524+ t.Fatal("]c should be ambiguous against ]cc")
525+ }
526+ if fired == nil {
527+ t.Fatal("expected ambiguity timer arm")
528+ }
529+
530+ fired() // timer expiry queues resolve on scheduler
531+ if len(queued) != 1 {
532+ t.Fatalf("queued resolves = %d, want 1", len(queued))
533+ }
534+ queued[0]()
535+ if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
536+ t.Fatalf("actions = %#v, want bracket-c", *actions)
537+ }
538+ if h.PendingSequence() != "" {
539+ t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
540+ }
541+ _ = stops
542+}
543+
544+func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
545+ ctx := context.Background()
546+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
547+ "bracket-c": {Keys: []string{"]c"}},
548+ "bracket-cc": {Keys: []string{"]cc"}},
549+ })
550+ ws.VimMode.SequenceTimeoutMilliseconds = 0
551+ h := NewKeyboardHandler(ctx, ws, newTestSession())
552+ actions := captureSequenceActions(h)
553+
554+ var armed time.Duration = -1
555+ h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
556+ armed = d
557+ fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
558+ return stubSequenceTimer{}
559+ }
560+ var queued []func()
561+ h.SetSequenceMainThreadScheduler(func(fn func()) {
562+ queued = append(queued, fn)
563+ })
564+
565+ enterVimMode(t, h)
566+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
567+ t.Fatal("]c should be consumed")
568+ }
569+ if armed != 0 {
570+ t.Fatalf("armed timeout = %v, want 0", armed)
571+ }
572+ if len(queued) != 1 {
573+ t.Fatalf("queued = %d, want 1", len(queued))
574+ }
575+ queued[0]()
576+ if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
577+ t.Fatalf("actions = %#v, want bracket-c", *actions)
578+ }
579+}
580+
581+func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
582+ ctx := context.Background()
583+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
584+ "bracket-c": {Keys: []string{"]c"}},
585+ "bracket-cc": {Keys: []string{"]cc"}},
586+ })
587+ h := NewKeyboardHandler(ctx, ws, newTestSession())
588+ actions := captureSequenceActions(h)
589+
590+ var fired func()
591+ var stops int
592+ h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
593+ fired = fn
594+ return stubSequenceTimer{onStop: func() { stops++ }}
595+ }
596+ h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
597+
598+ enterVimMode(t, h)
599+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
600+ t.Fatal("]c should be consumed")
601+ }
602+ if fired == nil {
603+ t.Fatal("expected timer")
604+ }
605+ if !h.handleKeyPress(uint('c'), 0, 0) {
606+ t.Fatal("]cc should complete")
607+ }
608+ if stops == 0 {
609+ t.Fatal("next key must Stop ambiguity timer")
610+ }
611+ if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
612+ t.Fatalf("actions = %#v, want bracket-cc", *actions)
613+ }
614+ // Stale timer must no-op.
615+ fired()
616+ if len(*actions) != 1 {
617+ t.Fatalf("stale timer mutated actions: %#v", *actions)
618+ }
619+}
620+
621+func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
622+ ctx := context.Background()
623+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
624+ "bracket-c": {Keys: []string{"]c"}},
625+ "bracket-cc": {Keys: []string{"]cc"}},
626+ "yank-section": {Keys: []string{"yah"}},
627+ })
628+ h := NewKeyboardHandler(ctx, ws, newTestSession())
629+ actions := captureSequenceActions(h)
630+ pending := capturePending(h)
631+
632+ var fired func()
633+ h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
634+ fired = fn
635+ return stubSequenceTimer{}
636+ }
637+ var queued []func()
638+ h.SetSequenceMainThreadScheduler(func(fn func()) {
639+ queued = append(queued, fn)
640+ })
641+
642+ enterVimMode(t, h)
643+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
644+ t.Fatal("]c should be consumed")
645+ }
646+ fired() // enqueue stale resolve
647+ if len(queued) != 1 {
648+ t.Fatalf("queued = %d, want 1", len(queued))
649+ }
650+ stale := queued[0]
651+ queued = nil
652+
653+ // Interleave: feed/reset/reload before stale closure runs.
654+ h.ResetPendingSequence()
655+ if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
656+ t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
657+ }
658+ stale()
659+ if len(*actions) != 0 {
660+ t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
661+ }
662+
663+ // Reload path also invalidates queued resolve.
664+ fired = nil
665+ queued = nil
666+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
667+ t.Fatal("]c again")
668+ }
669+ fired()
670+ stale = queued[0]
671+ h.ReloadShortcuts(ctx, ws, newTestSession())
672+ stale()
673+ if len(*actions) != 0 {
674+ t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
675+ }
676+}
677+
678+func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
679+ ctx := context.Background()
680+ wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
681+ "action-a": {Keys: []string{"yah"}},
682+ "vim-scroll-down": {Keys: []string{"j"}},
683+ })
684+ h := NewKeyboardHandler(ctx, wsA, newTestSession())
685+ actions := captureSequenceActions(h)
686+ enterVimMode(t, h)
687+
688+ if !h.handleKeyPress(uint('y'), 0, 0) {
689+ t.Fatal("y should pending under trie A")
690+ }
691+ if h.PendingSequence() != "y" {
692+ t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
693+ }
694+
695+ wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
696+ "action-b": {Keys: []string{"gO"}},
697+ "vim-scroll-down": {Keys: []string{"j"}},
698+ })
699+ h.ReloadShortcuts(ctx, wsB, newTestSession())
700+ if h.PendingSequence() != "" {
701+ t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
702+ }
703+
704+ // Old A sequence must not complete; fall through / invalid.
705+ if h.feedVimModeSequence(uint('a'), 0) {
706+ t.Fatal("stale A continuation must not be consumed after reload")
707+ }
708+ if len(*actions) != 0 {
709+ t.Fatalf("no A action after reload, got %#v", *actions)
710+ }
711+
712+ // New B sequence works.
713+ if !h.handleKeyPress(uint('g'), 0, 0) {
714+ t.Fatal("g should pending under trie B")
715+ }
716+ if h.PendingSequence() != "g" {
717+ t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
718+ }
719+ if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
720+ t.Fatal("gO should complete under trie B")
721+ }
722+ if len(*actions) != 1 || (*actions)[0].action != "action-b" {
723+ t.Fatalf("actions = %#v, want action-b", *actions)
724+ }
725+}
M internal/ui/theme/css.go +93 -3
@@ -51,6 +51,8 @@ window, tooltip, popover {
5151 `
5252 }
5353
54+const defaultTransitionDurationMs = 120
55+
5456 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
5557 // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
5658 // Uses default mode colors.
@@ -58,9 +60,16 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
5860 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
5961 }
6062
61-// GenerateCSSFull creates GTK4 CSS using all provided configuration.
63+// GenerateCSSFull creates GTK4 CSS using all provided configuration with the
64+// default transition duration.
6265 // Text scaling is handled via GtkSettings; CSS here stays scale-independent.
6366 func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColors) string {
67+ return GenerateCSSFullWithTiming(p, 1.0, fonts, modeColors, defaultTransitionDurationMs)
68+}
69+
70+// GenerateCSSFullWithTiming creates GTK4 CSS using all provided configuration
71+// and an explicit transition duration from workspace styling config.
72+func GenerateCSSFullWithTiming(p Palette, _ float64, fonts FontConfig, modeColors ModeColors, transitionDurationMs int) string {
6473 defaults := DefaultFontConfig()
6574 fonts = FontConfig{
6675 SansFont: Coalesce(fonts.SansFont, defaults.SansFont),
@@ -102,6 +111,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
102111 sb.WriteString(generateStackedPaneCSS(p))
103112 sb.WriteString("\n")
104113
114+ // Vim mode styling
115+ sb.WriteString(generateVimModeCSS(transitionDurationMs))
116+ sb.WriteString("\n")
117+
105118 // Progress bar styling
106119 sb.WriteString(generateProgressBarCSS(p))
107120 sb.WriteString("\n")
@@ -146,12 +159,20 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
146159 sb.WriteString(generateAccentPickerCSS(p))
147160 sb.WriteString("\n")
148161
149- // History sidebar styling
150- sb.WriteString(generateHistorySidebarCSS(p))
162+ appendNativeSidebarCSS(&sb, p)
151163
152164 return sb.String()
153165 }
154166
167+func appendNativeSidebarCSS(sb *strings.Builder, p Palette) {
168+ // Native sidebar styling
169+ sb.WriteString(generateSidebarCSS(p))
170+ sb.WriteString("\n")
171+ sb.WriteString(generateHistorySidebarCSS(p))
172+ sb.WriteString("\n")
173+ sb.WriteString(generateFavoritesSidebarCSS(p))
174+}
175+
155176 // generateTabBarCSS creates tab bar styles.
156177 // Uses em units for scalable UI.
157178 func generateTabBarCSS(p Palette) string {
@@ -572,6 +593,70 @@ entry.find-bar-entry:focus-visible {
572593
573594 // generatePaneCSS creates pane border styles.
574595 // Uses em units for scalable UI.
596+// generateVimModeCSS creates the pane-local Vim Mode accent and pulse styling.
597+// It reuses --pane-mode-color (from workspace.styling.pane_mode_color).
598+func generateVimModeCSS(transitionDurationMs int) string {
599+ if transitionDurationMs <= 0 {
600+ transitionDurationMs = defaultTransitionDurationMs
601+ }
602+ normalPulseMs := transitionDurationMs * 3
603+ fastPulseMs := transitionDurationMs * 6
604+ return fmt.Sprintf(`/* ===== Vim Mode Styling ===== */
605+
606+/* Vim mode active — subtle local border accent on the pane overlay.
607+ Uses the existing pane mode color token via CSS variable.
608+ This is independent of the workspace-level pane-mode border overlay. */
609+.vim-mode-active {
610+ box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35);
611+ border-radius: 0;
612+}
613+
614+/* Pane overlay pulse keyframes — start/end at the active accent state so the
615+ pane keeps its Vim mode border while briefly flaring brighter. The separate
616+ -anim-a and -anim-b pairs are required to restart repeated pulse animations. */
617+@keyframes vim-mode-overlay-pulse-anim-a {
618+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
619+ 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--pane-mode-color), 0.78); }
620+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
621+}
622+
623+@keyframes vim-mode-overlay-pulse-anim-b {
624+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
625+ 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--pane-mode-color), 0.78); }
626+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
627+}
628+
629+@keyframes vim-mode-overlay-pulse-fast-anim-a {
630+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
631+ 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--pane-mode-color), 0.95); }
632+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
633+}
634+
635+@keyframes vim-mode-overlay-pulse-fast-anim-b {
636+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
637+ 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--pane-mode-color), 0.95); }
638+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
639+}
640+
641+/* Pane overlay scroll pulse — derived from transition_duration. */
642+.vim-mode-pulse.vim-mode-pulse-cycle-a {
643+ animation: vim-mode-overlay-pulse-anim-a %dms ease-in-out;
644+}
645+
646+.vim-mode-pulse.vim-mode-pulse-cycle-b {
647+ animation: vim-mode-overlay-pulse-anim-b %dms ease-in-out;
648+}
649+
650+.vim-mode-pulse-fast.vim-mode-pulse-cycle-a {
651+ animation: vim-mode-overlay-pulse-fast-anim-a %dms ease-in-out;
652+}
653+
654+.vim-mode-pulse-fast.vim-mode-pulse-cycle-b {
655+ animation: vim-mode-overlay-pulse-fast-anim-b %dms ease-in-out;
656+}
657+`, normalPulseMs, normalPulseMs, fastPulseMs, fastPulseMs)
658+}
659+
575660 func generatePaneCSS(p Palette) string {
576661 return `/* ===== Pane Styling ===== */
577662
@@ -1130,6 +1215,11 @@ func generateToasterCSS(p Palette) string {
11301215 color: #ffffff;
11311216 }
11321217
1218+.toast-vim-mode {
1219+ background-color: var(--pane-mode-color);
1220+ color: #ffffff;
1221+}
1222+
11331223 .toast-tab-mode {
11341224 background-color: var(--tab-mode-color);
11351225 color: #ffffff;
M internal/ui/theme/css_test.go +10 -0
@@ -34,6 +34,16 @@ func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing
3434 }
3535 }
3636
37+func TestGenerateCSSFullWithTiming_VimModePulseUsesConfiguredTransitionDuration(t *testing.T) {
38+ // Vim Mode overlay pulse timings scale from the base transition duration:
39+ // normal pulse = 3× (150ms -> 450ms), fast pulse = 6× (150ms -> 900ms).
40+ css := GenerateCSSFullWithTiming(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors(), 150)
41+
42+ assert.Contains(t, css, "animation: vim-mode-overlay-pulse-anim-a 450ms ease-in-out;")
43+ assert.Contains(t, css, "animation: vim-mode-overlay-pulse-fast-anim-a 900ms ease-in-out;")
44+ assert.NotContains(t, css, "vim-mode-indicator")
45+}
46+
3747 func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) {
3848 css := GenerateCSS(DefaultDarkPalette())
3949
A internal/ui/theme/favorites_sidebar_css.go +134 -0
@@ -0,0 +1,134 @@
1+package theme
2+
3+// generateFavoritesSidebarCSS creates GTK4 CSS for favorites-specific controls.
4+func generateFavoritesSidebarCSS(_ Palette) string {
5+ return `/* ===== Favorites Sidebar Styling ===== */
6+
7+.favorites-sidebar-tags {
8+ padding: 0.25em 0.5em 0.375em;
9+ border-bottom: 0.0625em solid var(--border);
10+ background-color: var(--surface-variant);
11+}
12+
13+.favorites-sidebar-tag-filter {
14+ min-height: 0;
15+ min-width: 0;
16+ padding: 0.125em 0.375em;
17+ font-size: 0.72em;
18+ font-weight: 500;
19+ color: var(--muted);
20+ background-color: transparent;
21+ border: 0.0625em solid alpha(var(--border), 0.7);
22+ border-radius: 0.25em;
23+ box-shadow: none;
24+}
25+
26+.favorites-sidebar-tag-filter:hover {
27+ color: var(--text);
28+ background-color: alpha(var(--accent), 0.10);
29+}
30+
31+.favorites-sidebar-tag-filter:focus {
32+ color: var(--text);
33+ border-color: var(--accent);
34+ box-shadow: inset 0 0 0 0.0625em var(--accent);
35+}
36+
37+.favorites-sidebar-tag-filter-active {
38+ color: var(--accent);
39+ background-color: alpha(var(--accent), 0.16);
40+ border-color: alpha(var(--accent), 0.5);
41+}
42+
43+.favorites-sidebar-tag-add {
44+ min-width: 1.4em;
45+ font-weight: 700;
46+}
47+
48+.favorites-sidebar-tag-prompt {
49+ padding: 0.375em 0.5em;
50+ border-bottom: 0.0625em solid var(--border);
51+ background-color: var(--surface-variant);
52+}
53+
54+.favorites-sidebar-tag-prompt entry,
55+.favorites-sidebar-tag-prompt button {
56+ min-height: 0;
57+ padding: 0.1875em 0.375em;
58+ font-size: 0.78em;
59+}
60+
61+.favorites-sidebar-tag-binding {
62+ min-height: 0;
63+ padding: 0.125em 0.375em;
64+ font-size: 0.72em;
65+}
66+
67+.favorites-sidebar-shortcut-badge,
68+.favorites-sidebar-tag-chip {
69+ padding: 0.0625em 0.25em;
70+ font-size: 0.66em;
71+ font-weight: 600;
72+ color: var(--muted);
73+ background-color: alpha(var(--surface-variant), 0.9);
74+ border: 0.0625em solid alpha(var(--border), 0.65);
75+ border-radius: 0.2em;
76+}
77+
78+.favorites-sidebar-row-tags {
79+ margin-top: 0.0625em;
80+}
81+
82+.favorites-sidebar-shortcut-badge {
83+ color: var(--accent);
84+ border-color: alpha(var(--accent), 0.45);
85+ background-color: alpha(var(--accent), 0.10);
86+}
87+
88+.favorites-sidebar-form {
89+ padding: 0.5em;
90+ border-top: 0.0625em solid var(--border);
91+ background-color: var(--surface-variant);
92+}
93+
94+.favorites-sidebar-form entry {
95+ min-height: 0;
96+ padding: 0.1875em 0.375em;
97+ font-size: 0.8em;
98+ background-color: var(--surface);
99+}
100+
101+.favorites-sidebar-form-tag-matches {
102+ padding: 0.125em 0;
103+}
104+
105+.favorites-sidebar-form-tag-match {
106+ min-height: 0;
107+ min-width: 0;
108+ padding: 0.0625em 0.25em;
109+ font-size: 0.7em;
110+ color: var(--muted);
111+ background-color: alpha(var(--surface), 0.8);
112+ border: 0.0625em solid alpha(var(--border), 0.65);
113+ border-radius: 0.2em;
114+ box-shadow: none;
115+}
116+
117+.favorites-sidebar-form-tag-match:hover {
118+ color: var(--text);
119+ background-color: alpha(var(--accent), 0.12);
120+}
121+
122+.favorites-sidebar-form-tag-match:focus {
123+ color: var(--text);
124+ border-color: var(--accent);
125+ box-shadow: inset 0 0 0 0.0625em var(--accent);
126+}
127+
128+.favorites-sidebar-form button {
129+ min-height: 0;
130+ padding: 0.1875em 0.5em;
131+ font-size: 0.78em;
132+}
133+`
134+}
A internal/ui/theme/favorites_sidebar_css_test.go +53 -0
@@ -0,0 +1,53 @@
1+package theme
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestGenerateFavoritesSidebarCSS_ContainsCompactControlStyles(t *testing.T) {
11+ css := generateFavoritesSidebarCSS(DefaultDarkPalette())
12+
13+ for _, selector := range []string{
14+ ".favorites-sidebar-tags",
15+ ".favorites-sidebar-tag-filter",
16+ ".favorites-sidebar-tag-filter-active",
17+ ".favorites-sidebar-tag-add",
18+ ".favorites-sidebar-tag-prompt",
19+ ".favorites-sidebar-tag-binding",
20+ ".favorites-sidebar-shortcut-badge",
21+ ".favorites-sidebar-tag-chip",
22+ ".favorites-sidebar-form",
23+ ".favorites-sidebar-form-tag-matches",
24+ ".favorites-sidebar-form-tag-match",
25+ } {
26+ assert.Contains(t, css, selector+" {")
27+ }
28+ assert.Contains(t, css, ".favorites-sidebar-tag-filter-active {", "active filters use sidebar-owned styling")
29+ assert.NotContains(t, css, ".suggested-action", "native suggested-action geometry must not affect tag filters")
30+ assert.Contains(t, css, "font-size: 0.66em;", "tags and shortcut badges stay compact")
31+ assert.Contains(t, css, "border-bottom: 0.0625em solid var(--border);", "filters are separated from the list")
32+ assert.Contains(t, css, "border-top: 0.0625em solid var(--border);", "the form is separated from the list")
33+}
34+
35+func TestGenerateFavoritesSidebarCSS_UsesSharedThemeVariables(t *testing.T) {
36+ css := generateFavoritesSidebarCSS(DefaultDarkPalette())
37+
38+ assert.Contains(t, css, "var(--surface)")
39+ assert.Contains(t, css, "var(--surface-variant)")
40+ assert.Contains(t, css, "var(--accent)")
41+ assert.NotContains(t, css, DefaultDarkPalette().Accent)
42+ assert.Equal(t, css, generateFavoritesSidebarCSS(DefaultLightPalette()))
43+}
44+
45+func TestGenerateFavoritesSidebarCSS_IsRegisteredWithTheFullTheme(t *testing.T) {
46+ css := GenerateCSSFull(DefaultDarkPalette(), 1, DefaultFontConfig(), DefaultModeColors())
47+
48+ assert.Contains(t, css, "Native Sidebar Foundation")
49+ assert.Contains(t, css, "Favorites Sidebar Styling")
50+ assert.Contains(t, css, ".favorites-sidebar-tag-filter {")
51+ assert.Contains(t, css, ".favorites-sidebar-form {")
52+ assert.Equal(t, strings.Count(css, "{"), strings.Count(css, "}"))
53+}
M internal/ui/theme/history_sidebar_css.go +0 -58
@@ -4,26 +4,6 @@ package theme
44 func generateHistorySidebarCSS(_ Palette) string {
55 return `/* ===== History Sidebar Styling ===== */
66
7-.history-sidebar-outer {
8- background-color: var(--surface);
9- border-left: 0.0625em solid var(--border);
10-}
11-
12-.history-sidebar-search-box {
13- padding: 0.375em 0.5em;
14- border-bottom: 0.0625em solid var(--border);
15- background-color: var(--surface);
16-}
17-
18-.history-sidebar-search {
19- padding: 0.125em 0.375em;
20- font-size: 0.85em;
21-}
22-
23-.history-sidebar-groups {
24- background-color: var(--surface);
25-}
26-
277 .history-sidebar-group-header {
288 padding: 0.25em 0.625em;
299 padding-top: 0.375em;
@@ -36,37 +16,6 @@ func generateHistorySidebarCSS(_ Palette) string {
3616 border-bottom: 0.0625em solid var(--border);
3717 }
3818
39-.history-sidebar-row {
40- padding: 0.1875em 0.625em;
41- min-height: 0;
42- border-bottom: 0.0625em solid alpha(var(--border), 0.4);
43- background-color: var(--surface);
44- transition: background-color 100ms ease;
45-}
46-
47-.history-sidebar-row:hover {
48- background-color: alpha(var(--accent), 0.18);
49-}
50-
51-.history-sidebar-row:selected {
52- background-color: alpha(var(--accent), 0.18);
53-}
54-
55-.history-sidebar-row:focus {
56- background-color: alpha(var(--accent), 0.18);
57-}
58-
59-.history-sidebar-row-title {
60- font-size: 0.82em;
61- color: var(--text);
62- font-weight: 500;
63-}
64-
65-.history-sidebar-row-subtitle {
66- font-size: 0.72em;
67- color: var(--muted);
68-}
69-
7019 .history-sidebar-row-time {
7120 font-size: 0.68em;
7221 color: var(--muted);
@@ -74,13 +23,6 @@ func generateHistorySidebarCSS(_ Palette) string {
7423 opacity: 0.75;
7524 }
7625
77-.history-sidebar-empty {
78- padding: 1.5em 0.75em;
79- font-size: 0.82em;
80- color: var(--muted);
81- font-style: italic;
82-}
83-
8426 .history-sidebar-loading {
8527 padding: 1.5em 0.75em;
8628 font-size: 0.82em;
M internal/ui/theme/history_sidebar_css_test.go +65 -70
@@ -12,19 +12,8 @@ func TestGenerateHistorySidebarCSS_ContainsExpectedSelectors(t *testing.T) {
1212 css := generateHistorySidebarCSS(DefaultDarkPalette())
1313
1414 selectors := []string{
15- ".history-sidebar-outer",
16- ".history-sidebar-search-box",
17- ".history-sidebar-search",
18- ".history-sidebar-groups",
1915 ".history-sidebar-group-header",
20- ".history-sidebar-row",
21- ".history-sidebar-row:hover",
22- ".history-sidebar-row:selected",
23- ".history-sidebar-row:focus",
24- ".history-sidebar-row-title",
25- ".history-sidebar-row-subtitle",
2616 ".history-sidebar-row-time",
27- ".history-sidebar-empty",
2817 ".history-sidebar-loading",
2918 }
3019
@@ -34,8 +23,8 @@ func TestGenerateHistorySidebarCSS_ContainsExpectedSelectors(t *testing.T) {
3423 }
3524 }
3625
37-func TestGenerateHistorySidebarCSS_AccentAlphaInterpolation(t *testing.T) {
38- css := generateHistorySidebarCSS(DefaultDarkPalette())
26+func TestGenerateSidebarCSS_AccentAlphaInterpolation(t *testing.T) {
27+ css := generateSidebarCSS(DefaultDarkPalette())
3928
4029 expectedAlpha := "alpha(var(--accent), 0.18)"
4130 assert.Contains(t, css, expectedAlpha,
@@ -60,9 +49,9 @@ func TestGenerateHistorySidebarCSS_DeterministicOutput(t *testing.T) {
6049 assert.Equal(t, hash1, hash2, "CSS output must be deterministic for the same palette")
6150 }
6251
63-func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) {
64- darkCSS := generateHistorySidebarCSS(DefaultDarkPalette())
65- lightCSS := generateHistorySidebarCSS(DefaultLightPalette())
52+func TestGenerateSidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) {
53+ darkCSS := generateSidebarCSS(DefaultDarkPalette())
54+ lightCSS := generateSidebarCSS(DefaultLightPalette())
6655
6756 assert.Equal(t, darkCSS, lightCSS,
6857 "history sidebar fragment should be palette-independent when it relies on CSS variables")
@@ -73,12 +62,12 @@ func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t
7362 func TestGenerateHistorySidebarCSS_ThroughGenerateCSS(t *testing.T) {
7463 fullCSS := GenerateCSS(DefaultDarkPalette())
7564
76- assert.Contains(t, fullCSS, ".history-sidebar-outer {",
77- "full CSS must contain history sidebar selectors")
65+ assert.Contains(t, fullCSS, ".sidebar-outer {",
66+ "full CSS must contain shared sidebar selectors")
7867 assert.Contains(t, fullCSS, ".history-sidebar-group-header {",
7968 "full CSS must contain group header selector")
80- assert.Contains(t, fullCSS, ".history-sidebar-row-title {",
81- "full CSS must contain row title selector")
69+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
70+ "full CSS must contain shared row title selector")
8271 assert.Contains(t, fullCSS, "History Sidebar Styling",
8372 "full CSS must contain the history sidebar comment marker")
8473 }
@@ -100,7 +89,7 @@ func TestGenerateHistorySidebarCSS_NoEmptyCSSBlocks(t *testing.T) {
10089 }
10190 }
10291
103-func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) {
92+func TestGenerateSidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) {
10493 customPalette := Palette{
10594 Background: "#111111",
10695 Surface: "#222222",
@@ -114,22 +103,28 @@ func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T)
114103 Destructive: "#cc2222",
115104 }
116105
117- css := generateHistorySidebarCSS(customPalette)
106+ css := generateSidebarCSS(customPalette)
118107
119108 assert.Contains(t, css, "alpha(var(--accent), 0.18)",
120109 "sidebar CSS should use the shared accent variable")
121110 assert.NotContains(t, css, "#ff6600",
122111 "sidebar fragment should not inline palette-specific accent values")
123112
124- assert.Contains(t, css, ".history-sidebar-outer {")
125- assert.Contains(t, css, ".history-sidebar-row-title {")
126- assert.Contains(t, css, ".history-sidebar-row-subtitle {")
127- assert.Contains(t, css, ".history-sidebar-row-time {")
128- assert.Contains(t, css, ".history-sidebar-empty {")
113+ assert.Contains(t, css, ".sidebar-outer {")
114+ assert.Contains(t, css, ".sidebar-row-title {")
115+ assert.Contains(t, css, ".sidebar-row-subtitle {")
116+ assert.Contains(t, css, ".sidebar-empty {")
129117 }
130118
131-func TestGenerateHistorySidebarCSS_ContainsTransition(t *testing.T) {
132- css := generateHistorySidebarCSS(DefaultDarkPalette())
119+func TestGenerateSidebarCSS_UsesCompactBalancedSearchChrome(t *testing.T) {
120+ css := generateSidebarCSS(DefaultDarkPalette())
121+
122+ assert.Contains(t, css, ".sidebar-search-box {\n\tpadding: 0.5em 0.5em 0.375em;")
123+ assert.Contains(t, css, ".sidebar-search {\n\tmin-height: 0;\n\tpadding: 0.125em 0.375em;")
124+}
125+
126+func TestGenerateSidebarCSS_ContainsTransition(t *testing.T) {
127+ css := generateSidebarCSS(DefaultDarkPalette())
133128
134129 assert.Contains(t, css, "transition:",
135130 "CSS should include transition property for smooth hover effects")
@@ -149,27 +144,27 @@ func TestGenerateHistorySidebarCSS_ContainsUppercaseGroupHeader(t *testing.T) {
149144 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) {
150145 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
151146
152- assert.Contains(t, fullCSS, ".history-sidebar-outer {",
153- "GenerateCSSFull must include history sidebar outer selector for dark palette")
154- assert.Contains(t, fullCSS, ".history-sidebar-row-title {",
155- "GenerateCSSFull must include history sidebar row title selector for dark palette")
156- assert.Contains(t, fullCSS, ".history-sidebar-search {",
157- "GenerateCSSFull must include history sidebar search selector for dark palette")
158- assert.Contains(t, fullCSS, ".history-sidebar-empty {",
159- "GenerateCSSFull must include history sidebar empty selector for dark palette")
147+ assert.Contains(t, fullCSS, ".sidebar-outer {",
148+ "GenerateCSSFull must include shared sidebar outer selector for dark palette")
149+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
150+ "GenerateCSSFull must include shared sidebar row title selector for dark palette")
151+ assert.Contains(t, fullCSS, ".sidebar-search {",
152+ "GenerateCSSFull must include shared sidebar search selector for dark palette")
153+ assert.Contains(t, fullCSS, ".sidebar-empty {",
154+ "GenerateCSSFull must include shared sidebar empty selector for dark palette")
160155 }
161156
162157 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) {
163158 fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
164159
165- assert.Contains(t, fullCSS, ".history-sidebar-outer {",
166- "GenerateCSSFull must include history sidebar outer selector for light palette")
167- assert.Contains(t, fullCSS, ".history-sidebar-row-title {",
168- "GenerateCSSFull must include history sidebar row title selector for light palette")
169- assert.Contains(t, fullCSS, ".history-sidebar-search {",
170- "GenerateCSSFull must include history sidebar search selector for light palette")
171- assert.Contains(t, fullCSS, ".history-sidebar-empty {",
172- "GenerateCSSFull must include history sidebar empty selector for light palette")
160+ assert.Contains(t, fullCSS, ".sidebar-outer {",
161+ "GenerateCSSFull must include shared sidebar outer selector for light palette")
162+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
163+ "GenerateCSSFull must include shared sidebar row title selector for light palette")
164+ assert.Contains(t, fullCSS, ".sidebar-search {",
165+ "GenerateCSSFull must include shared sidebar search selector for light palette")
166+ assert.Contains(t, fullCSS, ".sidebar-empty {",
167+ "GenerateCSSFull must include shared sidebar empty selector for light palette")
173168 }
174169
175170 func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) {
@@ -181,8 +176,8 @@ func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T
181176 lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
182177
183178 // Both must contain history sidebar selectors.
184- assert.Contains(t, darkCSS, ".history-sidebar-outer {", "dark CSS must have sidebar styles")
185- assert.Contains(t, lightCSS, ".history-sidebar-outer {", "light CSS must have sidebar styles")
179+ assert.Contains(t, darkCSS, ".sidebar-outer {", "dark CSS must have shared sidebar styles")
180+ assert.Contains(t, lightCSS, ".sidebar-outer {", "light CSS must have shared sidebar styles")
186181
187182 // The CSS must differ between palettes (different color values).
188183 if darkCSS == lightCSS {
@@ -211,14 +206,14 @@ func TestGenerateHistorySidebarCSS_InGenerateCSSFull_WithCustomScaleAndFonts(t *
211206
212207 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors)
213208
214- assert.Contains(t, fullCSS, ".history-sidebar-outer {",
215- "GenerateCSSFull with custom scale/fonts must include history sidebar selectors")
216- assert.Contains(t, fullCSS, ".history-sidebar-row:hover {",
217- "GenerateCSSFull must include hover state for history rows")
218- assert.Contains(t, fullCSS, ".history-sidebar-row:selected {",
219- "GenerateCSSFull must include selected state for history rows")
220- assert.Contains(t, fullCSS, ".history-sidebar-row:focus {",
221- "GenerateCSSFull must include focus state for history rows")
209+ assert.Contains(t, fullCSS, ".sidebar-outer {",
210+ "GenerateCSSFull with custom scale/fonts must include shared sidebar selectors")
211+ assert.Contains(t, fullCSS, ".sidebar-row:hover {",
212+ "GenerateCSSFull must include hover state for shared sidebar rows")
213+ assert.Contains(t, fullCSS, ".sidebar-row:selected {",
214+ "GenerateCSSFull must include selected state for shared sidebar rows")
215+ assert.Contains(t, fullCSS, ".sidebar-row:focus {",
216+ "GenerateCSSFull must include focus state for shared sidebar rows")
222217 }
223218
224219 // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the
@@ -247,21 +242,21 @@ func TestGenerateHistorySidebarCSS_LiveReloadFullPath(t *testing.T) {
247242 lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
248243 customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
249244
250- // All three must contain the full set of history sidebar selectors.
245+ // All three must contain the shared foundation and history-specific rules.
251246 sidebarSelectors := []string{
252- ".history-sidebar-outer",
253- ".history-sidebar-search-box",
254- ".history-sidebar-search",
255- ".history-sidebar-groups",
247+ ".sidebar-outer",
248+ ".sidebar-search-box",
249+ ".sidebar-search",
250+ ".sidebar-list",
251+ ".sidebar-row",
252+ ".sidebar-row:hover",
253+ ".sidebar-row:selected",
254+ ".sidebar-row:focus",
255+ ".sidebar-row-title",
256+ ".sidebar-row-subtitle",
257+ ".sidebar-empty",
256258 ".history-sidebar-group-header",
257- ".history-sidebar-row",
258- ".history-sidebar-row:hover",
259- ".history-sidebar-row:selected",
260- ".history-sidebar-row:focus",
261- ".history-sidebar-row-title",
262- ".history-sidebar-row-subtitle",
263259 ".history-sidebar-row-time",
264- ".history-sidebar-empty",
265260 ".history-sidebar-loading",
266261 }
267262
@@ -324,9 +319,9 @@ func TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch(t *testing.T) {
324319 assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n")
325320 }
326321
327- // History sidebar selectors must be present in both.
328- assert.Contains(t, cssBefore, ".history-sidebar-row:selected {")
329- assert.Contains(t, cssAfter, ".history-sidebar-row:selected {")
322+ // Shared sidebar selectors must be present in both.
323+ assert.Contains(t, cssBefore, ".sidebar-row:selected {")
324+ assert.Contains(t, cssAfter, ".sidebar-row:selected {")
330325
331326 // The before and after CSS must be different (palette changed).
332327 assert.NotEqual(t, cssBefore, cssAfter, "CSS must change when palette switches")
M internal/ui/theme/manager.go +23 -12
@@ -15,22 +15,23 @@ import (
1515 // Manager handles theme state and CSS application.
1616 // Theme precedence and fallback are resolved before data reaches this adapter.
1717 type Manager struct {
18- prefersDark bool // Resolved dark mode preference
19- lightPalette Palette // Light theme colors
20- darkPalette Palette // Dark theme colors
21- uiScale float64 // UI scaling factor (1.0 = 100%)
22- fonts FontConfig
23- gtkFont string
24- modeColors ModeColors // Modal mode indicator colors
25- cssProvider *gtk.CssProvider
26- appliedFont string
18+ prefersDark bool // Resolved dark mode preference
19+ lightPalette Palette // Light theme colors
20+ darkPalette Palette // Dark theme colors
21+ uiScale float64 // UI scaling factor (1.0 = 100%)
22+ fonts FontConfig
23+ gtkFont string
24+ modeColors ModeColors // Modal mode indicator colors
25+ transitionDurationMs int
26+ cssProvider *gtk.CssProvider
27+ appliedFont string
2728 }
2829
2930 // NewManager creates a new theme manager from an already-resolved theme.
3031 func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager {
3132 log := logging.FromContext(ctx)
3233
33- m := &Manager{}
34+ m := &Manager{transitionDurationMs: defaultTransitionDurationMs}
3435 m.applyResolvedTheme(resolved)
3536
3637 log.Debug().
@@ -60,6 +61,15 @@ func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) {
6061 m.modeColors = ModeColorsFromEntity(resolved.ModeColors)
6162 }
6263
64+// SetTransitionDuration sets the CSS transition duration used for theme-driven
65+// animations such as Vim mode pulse timing.
66+func (m *Manager) SetTransitionDuration(ms int) {
67+ if ms < 0 {
68+ ms = defaultTransitionDurationMs
69+ }
70+ m.transitionDurationMs = ms
71+}
72+
6373 // PrefersDark returns true if dark mode is active.
6474 func (m *Manager) PrefersDark() bool {
6575 return m.prefersDark
@@ -135,9 +145,10 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
135145 return
136146 }
137147
138- // Generate CSS with current palette, UI scale, fonts, and mode colors
148+ // Generate CSS with current palette, UI scale, fonts, mode colors, and the
149+ // configured transition duration.
139150 palette := m.GetCurrentPalette()
140- css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
151+ css := GenerateCSSFullWithTiming(palette, m.uiScale, m.fonts, m.modeColors, m.transitionDurationMs)
141152 fontName := formatGTKFontName(m.gtkFont, m.uiScale)
142153
143154 settings := gtk.SettingsGetForDisplay(display)
A internal/ui/theme/sidebar_css.go +70 -0
@@ -0,0 +1,70 @@
1+package theme
2+
3+// generateSidebarCSS creates the shared GTK4 foundation used by native sidebars.
4+// The history sidebar remains the visual reference; feature-specific rules only
5+// add the controls unique to each sidebar.
6+func generateSidebarCSS(_ Palette) string {
7+ return `/* ===== Native Sidebar Foundation ===== */
8+
9+.sidebar-outer {
10+ background-color: var(--surface);
11+ border-left: 0.0625em solid var(--border);
12+}
13+
14+.sidebar-search-box {
15+ padding: 0.5em 0.5em 0.375em;
16+ border-bottom: 0.0625em solid var(--border);
17+ background-color: var(--surface);
18+}
19+
20+.sidebar-search {
21+ min-height: 0;
22+ padding: 0.125em 0.375em;
23+ font-size: 0.85em;
24+}
25+
26+.sidebar-list {
27+ background-color: var(--surface);
28+}
29+
30+.sidebar-row {
31+ padding: 0.1875em 0.625em;
32+ min-height: 0;
33+ border-bottom: 0.0625em solid color-mix(in srgb, var(--border) 40%, transparent);
34+ background-color: var(--surface);
35+ transition: background-color 100ms ease;
36+}
37+
38+.sidebar-row:hover {
39+ background-color: alpha(var(--accent), 0.18);
40+}
41+
42+.sidebar-row:selected {
43+ background-color: alpha(var(--accent), 0.18);
44+}
45+
46+.sidebar-row:focus {
47+ background-color: alpha(var(--accent), 0.18);
48+ outline: 0.0625em solid var(--accent);
49+ outline-offset: -0.0625em;
50+}
51+
52+.sidebar-row-title {
53+ font-size: 0.82em;
54+ color: var(--text);
55+ font-weight: 500;
56+}
57+
58+.sidebar-row-subtitle {
59+ font-size: 0.72em;
60+ color: var(--muted);
61+}
62+
63+.sidebar-empty {
64+ padding: 1.5em 0.75em;
65+ font-size: 0.82em;
66+ color: var(--muted);
67+ font-style: italic;
68+}
69+`
70+}

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