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:
54 steps:54 steps:
55 - name: Install dependencies55 - name: Install dependencies
56 run: |56 run: |
57- pacman -Syu --noconfirm57+ for attempt in 1 2 3; do
58- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell gobject-introspection base-devel brotli58+ 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
59 - uses: actions/checkout@v7.0.166 - uses: actions/checkout@v7.0.1
60 - name: Allow git commands in container checkout67 - name: Allow git commands in container checkout
61 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"68 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -98,8 +105,15 @@ jobs:
98 steps:105 steps:
99 - name: Install dependencies106 - name: Install dependencies
100 run: |107 run: |
101- pacman -Syu --noconfirm108+ for attempt in 1 2 3; do
102- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell libadwaita gobject-introspection base-devel brotli dbus109+ 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
103 - uses: actions/checkout@v7.0.1117 - uses: actions/checkout@v7.0.1
104 - name: Set up pinned Go toolchain118 - name: Set up pinned Go toolchain
105 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e119 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
M .mockery.yaml +1 -0
@@ -59,6 +59,7 @@ packages:
59 SessionSpawner: {}59 SessionSpawner: {}
60 WindowStateProvider: {}60 WindowStateProvider: {}
61 WebView: {}61 WebView: {}
62+ PageScrollable: {}
62 WebViewFactory: {}63 WebViewFactory: {}
63 WebViewPool: {}64 WebViewPool: {}
64 MemoryPressureApplier: {}65 MemoryPressureApplier: {}
M AGENTS.md +1 -1
@@ -40,4 +40,4 @@ Run before committing:
40 ## Misc40 ## Misc
41 41
42 - vendor dir is gitignored42 - 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.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
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+
7 ## [0.30.0] - 2026-06-2733 ## [0.30.0] - 2026-06-27
8 34
9 ### Added35 ### Added
M README.md +27 -29
@@ -1,21 +1,18 @@
1-# Dumber1+<h1 align="center">Dumber</h1>
2 2
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>
4 9
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>
6 11
7-Tabs contain workspaces. Workspaces contain panes. Panes can be split, stacked, moved, resized, and closed from the keyboard.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>
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
14 13
15 https://github.com/user-attachments/assets/232822af-08e4-4a74-9416-87f79c96b11814 https://github.com/user-attachments/assets/232822af-08e4-4a74-9416-87f79c96b118
16 15
17-The demo shows split panes, stacked panes, modal navigation, and workspace switching.
18-
19 ## Overview16 ## Overview
20 17
21 Dumber uses three layout levels:18 Dumber uses three layout levels:
@@ -24,7 +21,7 @@ Dumber uses three layout levels:
24 - **Workspaces** hold a layout of panes.21 - **Workspaces** hold a layout of panes.
25 - **Panes** display web pages and can be split, stacked, moved, resized, or closed.22 - **Panes** display web pages and can be split, stacked, moved, resized, or closed.
26 23
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.
28 25
29 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.26 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.
30 27
@@ -46,7 +43,7 @@ The browser chrome stays out of the way by default. Open the omnibox when you wa
46 43
47 ### Keyboard workflow44 ### Keyboard workflow
48 45
49-- Pane, tab, resize, and session modes46+- Pane, tab, Vim, resize, and session modes
50 - Vim/Zellij-style navigation patterns47 - Vim/Zellij-style navigation patterns
51 - Search bangs such as `!g`, `!gi`, and `!ddg`48 - Search bangs such as `!g`, `!gi`, and `!ddg`
52 - Launcher integration for `rofi`, `fuzzel`, and `dmenu`49 - 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
70 ### Install script67 ### Install script
71 68
72 ```bash69 ```bash
73-curl -fsSL https://dumber.bnema.dev/install | sh70+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/v0.31.0/install.sh | DUMBER_VERSION=v0.31.0 bash
74 dumber browse71 dumber browse
75 ```72 ```
76 73
@@ -89,7 +86,7 @@ flatpak install --user dumber.flatpak
89 flatpak run dev.bnema.Dumber browse86 flatpak run dev.bnema.Dumber browse
90 ```87 ```
91 88
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).
93 90
94 ## Keyboard modes91 ## Keyboard modes
95 92
@@ -99,6 +96,7 @@ Dumber uses modal keybindings for browser management.
99 |------|-------------|----------|96 |------|-------------|----------|
100 | Pane | `Ctrl+P` | Split, stack, close, and move panes |97 | Pane | `Ctrl+P` | Split, stack, close, and move panes |
101 | Tab | `Ctrl+T` | Create, close, switch, and rename tabs |98 | 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 |
102 | Resize | `Ctrl+N` | Resize panes with `hjkl` or arrow keys |100 | Resize | `Ctrl+N` | Resize panes with `hjkl` or arrow keys |
103 | Session | `Ctrl+O` | Snapshot, restore, and browse sessions |101 | Session | `Ctrl+O` | Snapshot, restore, and browse sessions |
104 102
@@ -111,7 +109,7 @@ The floating pane is a temporary browser pane that can be toggled without changi
111 - Profile shortcuts such as `Alt+G` are optional and configured under `workspace.floating_pane.profiles`.109 - Profile shortcuts such as `Alt+G` are optional and configured under `workspace.floating_pane.profiles`.
112 - Some `Alt+<key>` bindings may conflict with browser-engine shortcuts or desktop-level handlers.110 - Some `Alt+<key>` bindings may conflict with browser-engine shortcuts or desktop-level handlers.
113 111
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.
115 113
116 ## Configuration114 ## Configuration
117 115
@@ -125,11 +123,19 @@ type = "cef"
125 enabled = true123 enabled = true
126 ```124 ```
127 125
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.
129 135
130 ## Browser engine136 ## Browser engine
131 137
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.
133 139
134 On Arch Linux, install the CEF runtime with:140 On Arch Linux, install the CEF runtime with:
135 141
@@ -166,7 +172,7 @@ WebKit can be selected explicitly:
166 type = "webkit"172 type = "webkit"
167 ```173 ```
168 174
169-### Rendering notes175+### Rendering
170 176
171 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"`.177 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"`.
172 178
@@ -190,19 +196,11 @@ sudo apt install libwebkitgtk-6.0-4 libgtk-4-1 gstreamer1.0-plugins-base gstream
190 196
191 > Ubuntu 24.04 ships GLib 2.80, but Dumber requires GLib 2.84+. Use Arch, Fedora 41+, or the Flatpak.197 > Ubuntu 24.04 ships GLib 2.80, but Dumber requires GLib 2.84+. Use Arch, Fedora 41+, or the Flatpak.
192 198
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-
201 ## Development199 ## Development
202 200
203 Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend.201 Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend.
204 202
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.
206 204
207 ### Build from source205 ### Build from source
208 206
M cmd/dumber/main.go +39 -9
@@ -96,18 +96,35 @@ func launchModeFromArgs(args []string) (launchMode, string) {
96 }96 }
97 97
98 func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.BrowserLaunchRelay, browseURL string) (bool, error) {98 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) {
99 if relay == nil {116 if relay == nil {
100 return false, nil117 return false, nil
101 }118 }
102 119
103- delivered, err := relay.DeliverOpenFreshWindow(ctx, browseURL)120+ delivered, err := deliver(ctx, browseURL)
121+ if delivered {
122+ return true, nil
123+ }
104 if err != nil {124 if err != nil {
105- if delivered && errors.Is(err, desktop.ErrBrowserLaunchRelayUnconfirmed) {
106- return true, nil
107- }
108 return false, err125 return false, err
109 }126 }
110- return delivered, nil127+ return false, nil
111 }128 }
112 129
113 func launchStandaloneBrowserURL(ctx context.Context, launch func(context.Context, string) error, uri string) error {130 func launchStandaloneBrowserURL(ctx context.Context, launch func(context.Context, string) error, uri string) error {
@@ -147,6 +164,11 @@ type startupTiming struct {
147 }164 }
148 165
149 func main() {166 func main() {
167+ if err := bootstrap.ApplyDevelopmentEnvironment(); err != nil {
168+ _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err)
169+ os.Exit(1)
170+ }
171+
150 // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can172 // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can
151 // invalidate frames retained across those boundaries and make the GC abort173 // invalidate frames retained across those boundaries and make the GC abort
152 // with "traceback did not unwind completely". Re-exec once so the runtime174 // with "traceback did not unwind completely". Re-exec once so the runtime
@@ -193,7 +215,15 @@ func main() {
193 timing.configComplete = time.Now()215 timing.configComplete = time.Now()
194 configureBrowserLaunchRelay(cfg)216 configureBrowserLaunchRelay(cfg)
195 startupURL := domainurl.ResolveBrowserStartupURL(browseURL)217 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 {
197 fmt.Fprintf(227 fmt.Fprintf(
198 os.Stderr,228 os.Stderr,
199 "warning: failed to forward browse URL %q to a running instance, falling back to a new process: %v\n",229 "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 {
297 }327 }
298 if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {328 if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
299 relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {329 relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
300- if err := app.OpenFreshWindow(ctx, url); err != nil {330+ if err := app.OpenExternalURL(ctx, url); err != nil {
301- log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window")331+ log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch external URL")
302 }332 }
303 })333 })
304 }334 }
@@ -961,7 +991,7 @@ func buildUIDependencies(
961 },991 },
962 LaunchExternalURL: desktop.LaunchExternalURL,992 LaunchExternalURL: desktop.LaunchExternalURL,
963 LaunchBrowserURL: func(navCtx context.Context, uri string) error {993 LaunchBrowserURL: func(navCtx context.Context, uri string) error {
964- return launchStandaloneBrowserURL(navCtx, browserLauncher.LaunchURL, uri)994+ return launchStandaloneBrowserURL(navCtx, browserLauncher.LaunchFreshWindowURL, uri)
965 },995 },
966 BrowserLaunchRelay: browserLaunchRelay,996 BrowserLaunchRelay: browserLaunchRelay,
967 MigrationChecker: config.NewMigrator(),997 MigrationChecker: config.NewMigrator(),
M cmd/dumber/main_test.go +19 -5
@@ -230,7 +230,7 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) {
230 230
231 func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testing.T) {231 func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testing.T) {
232 relay := mocks.NewMockBrowserLaunchRelay(t)232 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)
234 234
235 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "dumb://history")235 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "dumb://history")
236 236
@@ -244,7 +244,7 @@ func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testi
244 244
245 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) {245 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) {
246 relay := mocks.NewMockBrowserLaunchRelay(t)246 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)
248 248
249 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")249 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
250 250
@@ -256,9 +256,23 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T
256 }256 }
257 }257 }
258 258
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+
259 func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) {273 func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) {
260 relay := mocks.NewMockBrowserLaunchRelay(t)274 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)
262 276
263 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")277 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
264 278
@@ -272,7 +286,7 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing
272 286
273 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnUnconfirmedRelayDelivery(t *testing.T) {287 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnUnconfirmedRelayDelivery(t *testing.T) {
274 relay := mocks.NewMockBrowserLaunchRelay(t)288 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)
276 290
277 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")291 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
278 292
@@ -309,7 +323,7 @@ func TestLaunchStandaloneBrowserURL_PropagatesOtherErrors(t *testing.T) {
309 func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) {323 func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) {
310 relay := mocks.NewMockBrowserLaunchRelay(t)324 relay := mocks.NewMockBrowserLaunchRelay(t)
311 wantErr := errors.New("relay error")325 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)
313 327
314 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")328 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
315 329
M docs/config/index.md +75 -4
@@ -398,6 +398,76 @@ cancel = ["escape"]
398 398
399 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.399 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
400 400
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+
401 ### Resize Mode471 ### Resize Mode
402 472
403 | Key | Type | Default | Description |473 | Key | Type | Default | Description |
@@ -529,20 +599,21 @@ desc = "Open floating pane on GitHub"
529 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |599 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
530 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |600 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
531 | `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |601 | `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 |
533 | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |603 | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
534 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |604 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
535 | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |605 | `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 |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 |
537-| `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |607+| `workspace.styling.transition_duration` | int | `120` | Border and pulse transition duration (ms) |
538 608
539 **Example:**609 **Example:**
610+
540 ```toml611 ```toml
541 [workspace.styling]612 [workspace.styling]
542 border_width = 1613 border_width = 1
543 border_color = "@theme_selected_bg_color"614 border_color = "@theme_selected_bg_color"
544 mode_border_width = 4615 mode_border_width = 4
545-pane_mode_color = "#4A90E2" # Blue for pane mode616+pane_mode_color = "#4A90E2" # Blue for pane mode and Vim mode local accent
546 tab_mode_color = "#FFA500" # Orange for tab mode617 tab_mode_color = "#FFA500" # Orange for tab mode
547 session_mode_color = "#9B59B6" # Purple for session mode618 session_mode_color = "#9B59B6" # Purple for session mode
548 resize_mode_color = "#00D4AA" # Teal for resize mode619 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:
16 |------|------------|---------|16 |------|------------|---------|
17 | Pane Mode | `Ctrl+P` | Split, close, focus panes |17 | Pane Mode | `Ctrl+P` | Split, close, focus panes |
18 | Tab Mode | `Ctrl+T` | Create, close, switch tabs |18 | Tab Mode | `Ctrl+T` | Create, close, switch tabs |
19+| Vim Mode | `Ctrl+Y` | Scroll, 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 |
19 | Resize Mode | `Ctrl+N` | Resize pane splits |20 | Resize Mode | `Ctrl+N` | Resize pane splits |
20 | Session Mode | `Ctrl+O` | Session management |21 | Session Mode | `Ctrl+O` | Session management |
21 22
22 Press `Escape` or `Enter` to exit any mode.23 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.
23 25
24 ## Pane Mode Quick Reference26 ## Pane Mode Quick Reference
25 27
@@ -28,6 +30,25 @@ Press `Escape` or `Enter` to exit any mode.
28 3. `Shift+arrows` to focus adjacent panes30 3. `Shift+arrows` to focus adjacent panes
29 4. `X` to close current pane31 4. `X` to close current pane
30 32
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+
31 ## Omnibox52 ## Omnibox
32 53
33 Press `Ctrl+L` to open the omnibox for:54 Press `Ctrl+L` to open the omnibox for:
M docs/installation.md +4 -4
@@ -3,7 +3,7 @@
3 ## Quick Install (Linux x86_64)3 ## Quick Install (Linux x86_64)
4 4
5 ```bash5 ```bash
6-curl -fsSL https://dumber.bnema.dev/install | bash6+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | bash
7 ```7 ```
8 8
9 Installs to `~/.local/bin` if available, otherwise `/usr/local/bin` (requires sudo).9 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
13 Install a specific version:13 Install a specific version:
14 14
15 ```bash15 ```bash
16-DUMBER_VERSION=v0.26.2 curl -fsSL https://dumber.bnema.dev/install | bash16+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_VERSION=v0.26.2 bash
17 ```17 ```
18 18
19 Install the latest prerelease:19 Install the latest prerelease:
20 20
21 ```bash21 ```bash
22-DUMBER_PRERELEASE=1 curl -fsSL https://dumber.bnema.dev/install | bash22+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_PRERELEASE=1 bash
23 ```23 ```
24 24
25 Combine both to install a specific prerelease:25 Combine both to install a specific prerelease:
26 26
27 ```bash27 ```bash
28-DUMBER_VERSION=v0.27.0-rc.1 DUMBER_PRERELEASE=1 curl -fsSL https://dumber.bnema.dev/install | bash28+curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_VERSION=v0.27.0-rc.1 DUMBER_PRERELEASE=1 bash
29 ```29 ```
30 30
31 ## Flatpak31 ## Flatpak
M docs/reference/configuration.md +9 -0
@@ -1,5 +1,7 @@
1 # Configuration Reference1 # Configuration Reference
2 2
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+
3 | Key | Type | Default | Valid Values |5 | Key | Type | Default | Valid Values |
4 |-----|------|---------|--------------|6 |-----|------|---------|--------------|
5 | `database.path` | string | `~/.local/share/dumber/dumber.db` | |7 | `database.path` | string | `~/.local/share/dumber/dumber.db` | |
@@ -98,6 +100,11 @@
98 | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | |100 | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | |
99 | `workspace.tab_mode.timeout_ms` | int | `3000` | |101 | `workspace.tab_mode.timeout_ms` | int | `3000` | |
100 | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings |102 | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings |
103+| `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | |
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) |
101 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |108 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |
102 | `workspace.resize_mode.timeout_ms` | int | `3000` | |109 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
103 | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings |110 | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings |
@@ -111,6 +118,8 @@
111 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |118 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
112 | `workspace.floating_pane.profiles.<name>.url` | string | | required URL |119 | `workspace.floating_pane.profiles.<name>.url` | string | | required URL |
113 | `workspace.floating_pane.profiles.<name>.desc` | string | | |120 | `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`) |
114 | `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |123 | `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
115 | `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` |124 | `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
116 | `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | |125 | `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
8 |------|-----|---------|8 |------|-----|---------|
9 | Pane Mode | `Ctrl+P` | Split, close, focus panes |9 | Pane Mode | `Ctrl+P` | Split, close, focus panes |
10 | Tab Mode | `Ctrl+T` | Create, close, switch tabs |10 | Tab Mode | `Ctrl+T` | Create, close, switch tabs |
11+| Vim Mode | `Ctrl+Y` | Scroll, 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 |
11 | Resize Mode | `Ctrl+N` | Resize pane splits |12 | Resize Mode | `Ctrl+N` | Resize pane splits |
12 | Session Mode | `Ctrl+O` | Session management |13 | Session Mode | `Ctrl+O` | Session management |
13 14
14 Press `Escape` or `Enter` to exit any mode.15 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.
15 17
16 Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix.18 Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix.
17 19
@@ -51,6 +53,29 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke
51 | Confirm | `Enter` |53 | Confirm | `Enter` |
52 | Cancel | `Escape` |54 | Cancel | `Escape` |
53 55
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+
54 ## Resize Mode (`Ctrl+N`)79 ## Resize Mode (`Ctrl+N`)
55 80
56 | Action | Keys |81 | Action | Keys |
@@ -98,7 +123,7 @@ These work outside modal modes:
98 - `Alt+F` is the only floating-pane shortcut enabled by default.123 - `Alt+F` is the only floating-pane shortcut enabled by default.
99 - `Alt+F` toggles floating visibility and keeps floating pane state intact.124 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
100 - `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`.125 - `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.
102 - `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.127 - `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.
103 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.128 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
104 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.129 - 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`:
118 split-right = ["arrowright", "r"]143 split-right = ["arrowright", "r"]
119 close-pane = ["x", "q"]144 close-pane = ["x", "q"]
120 145
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+
121 [workspace.shortcuts.actions.close-pane]177 [workspace.shortcuts.actions.close-pane]
122 keys = ["ctrl+w"]178 keys = ["ctrl+w"]
123 179
@@ -127,7 +183,7 @@ keys = ["alt+f"]
127 [workspace.shortcuts.actions.toggle-history-systemview]183 [workspace.shortcuts.actions.toggle-history-systemview]
128 keys = ["ctrl+h"]184 keys = ["ctrl+h"]
129 185
130-[workspace.shortcuts.actions.toggle-favorites-systemview]186+[workspace.shortcuts.actions.toggle-favorites-sidebar]
131 keys = ["ctrl+b"]187 keys = ["ctrl+b"]
132 188
133 [workspace.shortcuts.actions.toggle-current-page-favorite]189 [workspace.shortcuts.actions.toggle-current-page-favorite]
M go.mod +5 -5
@@ -1,17 +1,17 @@
1 module github.com/bnema/dumber1 module github.com/bnema/dumber
2 2
3-go 1.26.33+go 1.26
4 4
5 require (5 require (
6 github.com/a-h/templ v0.3.10206 github.com/a-h/templ v0.3.1020
7 github.com/andybalholm/brotli v1.2.27 github.com/andybalholm/brotli v1.2.2
8 github.com/bnema/purego v0.11.0-bnema.48 github.com/bnema/purego v0.11.0-bnema.4
9 github.com/bnema/purego-cef v0.14.29 github.com/bnema/purego-cef v0.14.2
10- github.com/bnema/purego-cef2gtk v0.9.010+ github.com/bnema/purego-cef2gtk v0.9.3
11- github.com/bnema/purego-pipewire v0.1.511+ github.com/bnema/purego-pipewire v0.1.6
12- github.com/bnema/purego-sqlite v0.1.412+ github.com/bnema/purego-sqlite v0.1.5
13 github.com/bnema/purego-webp v0.2.113 github.com/bnema/purego-webp v0.2.1
14- github.com/bnema/puregotk v0.7.114+ github.com/bnema/puregotk v0.7.2
15 github.com/charmbracelet/bubbles v1.0.015 github.com/charmbracelet/bubbles v1.0.0
16 github.com/charmbracelet/bubbletea v1.3.1016 github.com/charmbracelet/bubbletea v1.3.10
17 github.com/charmbracelet/lipgloss v1.1.017 github.com/charmbracelet/lipgloss v1.1.0
M go.sum +8 -8 generated file: collapsed
M install.sh +2 -2
@@ -2,8 +2,8 @@
2 set -e2 set -e
3 3
4 # Dumber installer script4 # Dumber installer script
5-# Usage: curl -fsSL https://dumber.bnema.dev/install | sh5+# Usage: curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | sh
6-# Usage with pre-release: curl -fsSL https://dumber.bnema.dev/install | DUMBER_PRERELEASE=1 sh6+# Usage with pre-release: curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_PRERELEASE=1 sh
7 7
8 REPO="bnema/dumber"8 REPO="bnema/dumber"
9 VERSION="${DUMBER_VERSION:-latest}"9 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 (
5 "io"5 "io"
6 )6 )
7 7
8-// BrowserWindowOpener opens a fresh browser window.8+// BrowserWindowOpener opens browser URLs received through the launch relay.
9 type BrowserWindowOpener interface {9 type BrowserWindowOpener interface {
10+ OpenExternalURL(ctx context.Context, url string) error
10 OpenFreshWindow(ctx context.Context, url string) error11 OpenFreshWindow(ctx context.Context, url string) error
11 }12 }
12 13
13-// BrowserLaunchRelay delivers fresh-window launch requests.14+// BrowserLaunchRelay delivers browser URL launch requests.
14 type BrowserLaunchRelay interface {15 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.
16 // The bool reports whether the relay accepted the request; false means the17 // The bool reports whether the relay accepted the request; false means the
17 // caller should treat it as undelivered and try another path.18 // caller should treat it as undelivered and try another path.
18 // An error may still be returned with delivered=true when the relay accepted19 // An error may still be returned with delivered=true when the relay accepted
19 // the request but could not confirm completion before the caller timed out.20 // 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.
20 DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error)24 DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error)
21 Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error)25 Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error)
22 }26 }
M internal/application/port/favorites_sidebar.go +2 -0
@@ -11,10 +11,12 @@ import (
11 type FavoritesSidebarFavorites interface {11 type FavoritesSidebarFavorites interface {
12 GetAll(ctx context.Context) ([]*entity.Favorite, error)12 GetAll(ctx context.Context) ([]*entity.Favorite, error)
13 GetAllTags(ctx context.Context) ([]*entity.Tag, error)13 GetAllTags(ctx context.Context) ([]*entity.Tag, error)
14+ AddTag(ctx context.Context, name, color string) (*entity.Tag, error)
14 AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)15 AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
15 UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)16 UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
16 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error17 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
17 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error18 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
18 TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error19 TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
19 UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error20 UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
21+ UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, tagIDs []entity.TagID) error
20 }22 }
M internal/application/port/mocks/browser_launch.go +123 -0
@@ -39,6 +39,63 @@ func (_m *MockBrowserWindowOpener) EXPECT() *MockBrowserWindowOpener_Expecter {
39 return &MockBrowserWindowOpener_Expecter{mock: &_m.Mock}39 return &MockBrowserWindowOpener_Expecter{mock: &_m.Mock}
40 }40 }
41 41
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+
42 // OpenFreshWindow provides a mock function for the type MockBrowserWindowOpener99 // OpenFreshWindow provides a mock function for the type MockBrowserWindowOpener
43 func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {100 func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {
44 ret := _mock.Called(ctx, url)101 ret := _mock.Called(ctx, url)
@@ -123,6 +180,72 @@ func (_m *MockBrowserLaunchRelay) EXPECT() *MockBrowserLaunchRelay_Expecter {
123 return &MockBrowserLaunchRelay_Expecter{mock: &_m.Mock}180 return &MockBrowserLaunchRelay_Expecter{mock: &_m.Mock}
124 }181 }
125 182
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+
126 // DeliverOpenFreshWindow provides a mock function for the type MockBrowserLaunchRelay249 // DeliverOpenFreshWindow provides a mock function for the type MockBrowserLaunchRelay
127 func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {250 func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
128 ret := _mock.Called(ctx, url)251 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
3112 _c.Call.Return(run)3112 _c.Call.Return(run)
3113 return _c3113 return _c
3114 }3114 }
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
5 5
6 import (6 import (
7 "context"7 "context"
8+ "fmt"
8 9
9 "github.com/bnema/dumber/internal/application/dto"10 "github.com/bnema/dumber/internal/application/dto"
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
@@ -142,6 +143,10 @@ type WebViewCallbacks struct {
142 // OnTouchpadNavigationGesture is called while a two-finger touchpad history143 // OnTouchpadNavigationGesture is called while a two-finger touchpad history
143 // navigation gesture is progressing or finishing.144 // navigation gesture is progressing or finishing.
144 OnTouchpadNavigationGesture func(gesture entity.TouchpadNavigationGesture)145 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)
145 }150 }
146 151
147 // FindOptions configures search behavior.152 // FindOptions configures search behavior.
@@ -390,3 +395,93 @@ type OAuthCallbackCapable interface {
390 type TextInputTargetProvider interface {395 type TextInputTargetProvider interface {
391 TextInputTarget() TextInputTarget396 TextInputTarget() TextInputTarget
392 }397 }
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{
18 "global": true,18 "global": true,
19 "pane": true,19 "pane": true,
20 "tab": true,20 "tab": true,
21+ "vim": true,
21 "resize": true,22 "resize": true,
22 "session": true,23 "session": true,
23 }24 }
M internal/application/usecase/keybindings_test.go +11 -0
@@ -72,6 +72,17 @@ func TestGetKeybindingsUseCase_Execute(t *testing.T) {
72 }72 }
73 73
74 func TestSetKeybindingUseCase_Execute(t *testing.T) {74 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+
75 t.Run("successfully sets keybinding", func(t *testing.T) {86 t.Run("successfully sets keybinding", func(t *testing.T) {
76 // Arrange87 // Arrange
77 mockProvider := mocks.NewMockKeybindingsProvider(t)88 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
599 return nil599 return nil
600 }600 }
601 601
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+
602 // GetTagsForFavorite retrieves all tags for a favorite.679 // GetTagsForFavorite retrieves all tags for a favorite.
603 func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {680 func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
604 log := logging.FromContext(ctx)681 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
140 func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig {140 func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig {
141 in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions)141 in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions)
142 in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions)142 in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions)
143+ in.VimMode.Actions = cloneActionBindings(in.VimMode.Actions)
143 in.ResizeMode.Actions = cloneActionBindings(in.ResizeMode.Actions)144 in.ResizeMode.Actions = cloneActionBindings(in.ResizeMode.Actions)
144 in.Shortcuts.Actions = cloneActionBindings(in.Shortcuts.Actions)145 in.Shortcuts.Actions = cloneActionBindings(in.Shortcuts.Actions)
145 in.FloatingPane.Profiles = cloneFloatingPaneProfiles(in.FloatingPane.Profiles)146 in.FloatingPane.Profiles = cloneFloatingPaneProfiles(in.FloatingPane.Profiles)
M internal/bootstrap/runtime_config_test.go +12 -0
@@ -258,6 +258,9 @@ func runtimeConfigWithNestedMutableFields() *config.Config {
258 cfg.Workspace.TabMode.Actions = map[string]entity.ActionBinding{258 cfg.Workspace.TabMode.Actions = map[string]entity.ActionBinding{
259 "next-tab": {Keys: []string{"l"}, Desc: "Next tab"},259 "next-tab": {Keys: []string{"l"}, Desc: "Next tab"},
260 }260 }
261+ cfg.Workspace.VimMode.Actions = map[string]entity.ActionBinding{
262+ "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll down"},
263+ }
261 cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{264 cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{
262 "grow-right": {Keys: []string{"right"}, Desc: "Grow right"},265 "grow-right": {Keys: []string{"right"}, Desc: "Grow right"},
263 }266 }
@@ -280,6 +283,9 @@ func mutateNestedRuntimeConfigSnapshot(snapshot entity.RuntimeConfigSnapshot) {
280 mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab")283 mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab")
281 snapshot.UI.Workspace.TabMode.Actions["added-tab"] = entity.ActionBinding{Keys: []string{"added"}}284 snapshot.UI.Workspace.TabMode.Actions["added-tab"] = entity.ActionBinding{Keys: []string{"added"}}
282 285
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+
283 mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize")289 mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize")
284 snapshot.UI.Workspace.ResizeMode.Actions["added-resize"] = entity.ActionBinding{Keys: []string{"added"}}290 snapshot.UI.Workspace.ResizeMode.Actions["added-resize"] = entity.ActionBinding{Keys: []string{"added"}}
285 291
@@ -310,6 +316,9 @@ func assertNestedRuntimeConfigSnapshotUnchanged(t *testing.T, snapshot entity.Ru
310 assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l")316 assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l")
311 assertMapEntryAbsent(t, snapshot.UI.Workspace.TabMode.Actions, "added-tab")317 assertMapEntryAbsent(t, snapshot.UI.Workspace.TabMode.Actions, "added-tab")
312 318
319+ assertActionBinding(t, snapshot.UI.Workspace.VimMode.Actions, "vim-scroll-down", "j")
320+ assertMapEntryAbsent(t, snapshot.UI.Workspace.VimMode.Actions, "added-page")
321+
313 assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right")322 assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right")
314 assertMapEntryAbsent(t, snapshot.UI.Workspace.ResizeMode.Actions, "added-resize")323 assertMapEntryAbsent(t, snapshot.UI.Workspace.ResizeMode.Actions, "added-resize")
315 324
@@ -357,6 +366,9 @@ func assertNestedSourceConfigUnchanged(t *testing.T, cfg *config.Config) {
357 assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l")366 assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l")
358 assertMapEntryAbsent(t, cfg.Workspace.TabMode.Actions, "added-tab")367 assertMapEntryAbsent(t, cfg.Workspace.TabMode.Actions, "added-tab")
359 368
369+ assertActionBinding(t, cfg.Workspace.VimMode.Actions, "vim-scroll-down", "j")
370+ assertMapEntryAbsent(t, cfg.Workspace.VimMode.Actions, "added-page")
371+
360 assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right")372 assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right")
361 assertMapEntryAbsent(t, cfg.Workspace.ResizeMode.Actions, "added-resize")373 assertMapEntryAbsent(t, cfg.Workspace.ResizeMode.Actions, "added-resize")
362 374
M internal/domain/entity/config_types.go +48 -6
@@ -78,6 +78,20 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
78 return keyBindingsFromActions(t.Actions)78 return keyBindingsFromActions(t.Actions)
79 }79 }
80 80
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+
81 // ResizeModeConfig holds resize mode shortcut configuration.95 // ResizeModeConfig holds resize mode shortcut configuration.
82 type ResizeModeConfig struct {96 type ResizeModeConfig struct {
83 ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line97 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 {
197 // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.211 // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
198 type PopupBehaviorConfig = BrowsingContextConfig212 type PopupBehaviorConfig = BrowsingContextConfig
199 213
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+
200 // WorkspaceConfig holds all workspace layout and behavior settings.240 // WorkspaceConfig holds all workspace layout and behavior settings.
201 type WorkspaceConfig struct {241 type WorkspaceConfig struct {
202- NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`242+ 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"`243+ 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"`244+ 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"`245+ VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"`
206- Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`246+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
207- FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`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"`
208 250
209 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`251 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
210 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 line252 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 {
280 func newDumberApp(engine *Engine) purecef.App {280 func newDumberApp(engine *Engine) purecef.App {
281 app := &dumberApp{engine: engine}281 app := &dumberApp{engine: engine}
282 app.bph = &dumberBPH{engine: engine}282 app.bph = &dumberBPH{engine: engine}
283- app.rph = newPopupOpenerRenderProcessHandler()283+ app.rph = newComposedRenderProcessHandler()
284 return app284 return app
285 }285 }
286 286
287 // NewSubprocessApp returns a lightweight raw App implementation for CEF287 // NewSubprocessApp returns a lightweight raw App implementation for CEF
288-// subprocesses re-executed from the main dumber binary. Keep the legacy288+// subprocesses re-executed from the main dumber binary. Keep the legacy V8
289-// renderer bridge disabled, but allow the minimal popup-opener render handler289+// renderer bridge disabled while composing editable-focus reporting with the
290-// needed to install synthetic opener state before page scripts run.290+// popup-opener callbacks needed before page scripts run.
291 func NewSubprocessApp() purecef.App {291 func NewSubprocessApp() purecef.App {
292- return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()}292+ return &subprocessApp{rph: newComposedRenderProcessHandler()}
293 }293 }
294 294
295 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.295 // 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) {
156 wv.handlers = handlers156 wv.handlers = handlers
157 wv.findCtrl = newFindController()157 wv.findCtrl = newFindController()
158 158
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+
159 // Build a CEF client backed by our handlerSet.167 // Build a CEF client backed by our handlerSet.
160 // Store on WebView to prevent GC collection before CEF AddRef's it.168 // Store on WebView to prevent GC collection before CEF AddRef's it.
161 wv.client = purecef.NewClient(wv.handlers)169 wv.client = purecef.NewClient(wv.handlers)
@@ -164,10 +172,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
164 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")172 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
165 }173 }
166 }174 }
167- viewportHooksResult := wv.installViewportSyncHooks()175+ if err := wv.installNewWebViewViewportHooksAfterCapture(); err != nil {
168- if !viewportHooksResult.Completed() {176+ return nil, err
169- wv.destroyViewBridgeOnGTKAsync()
170- return nil, errGTKSyncDispatchIncomplete("install CEF viewport sync hooks", viewportHooksResult)
171 }177 }
172 wv.startRenderStallWatchdog()178 wv.startRenderStallWatchdog()
173 return wv, nil179 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
50 return metadata, true50 return metadata, true
51 }51 }
52 52
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+
53 type popupOpenerRenderProcessHandler struct {78 type popupOpenerRenderProcessHandler struct {
54 mu sync.RWMutex79 mu sync.RWMutex
55 byBrowserID map[int32]popupOpenerRenderMetadata80 byBrowserID map[int32]popupOpenerRenderMetadata
M internal/infrastructure/cef/render_handler_adapter.go +11 -2
@@ -19,6 +19,7 @@ type dumberRenderHandler struct {
19 main purecef.RenderHandler19 main purecef.RenderHandler
20 popup purecef.RenderHandler20 popup purecef.RenderHandler
21 popupSurface popupSurface21 popupSurface popupSurface
22+ a11y purecef.AccessibilityHandler
22 }23 }
23 24
24 var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)25 var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
@@ -48,6 +49,14 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
48 return nil49 return nil
49 }50 }
50 h := &dumberRenderHandler{wv: wv}51 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)
51 60
52 var unsupportedPaintOnce sync.Once61 var unsupportedPaintOnce sync.Once
53 hooks := startupPresentationHooks(activeStartupTrace())62 hooks := startupPresentationHooks(activeStartupTrace())
@@ -110,10 +119,10 @@ func (h *dumberRenderHandler) renderTargetForPaint(elementType purecef.PaintElem
110 }119 }
111 120
112 func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler {121 func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler {
113- if h == nil || h.main == nil {122+ if h == nil {
114 return nil123 return nil
115 }124 }
116- return h.main.GetAccessibilityHandler()125+ return h.a11y
117 }126 }
118 127
119 func (h *dumberRenderHandler) GetRootScreenRect(browser purecef.Browser, rect *purecef.Rect) int32 {128 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(
267 267
268 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})268 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
269 }269 }
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 (
21 "github.com/bnema/dumber/internal/application/dto"21 "github.com/bnema/dumber/internal/application/dto"
22 "github.com/bnema/dumber/internal/application/port"22 "github.com/bnema/dumber/internal/application/port"
23 "github.com/bnema/dumber/internal/domain/entity"23 "github.com/bnema/dumber/internal/domain/entity"
24+ "github.com/bnema/dumber/internal/infrastructure/webutil"
24 "github.com/bnema/dumber/internal/logging"25 "github.com/bnema/dumber/internal/logging"
25 "github.com/bnema/dumber/internal/shared/syncdispatch"26 "github.com/bnema/dumber/internal/shared/syncdispatch"
26 )27 )
@@ -34,6 +35,9 @@ var (
34 _ port.PopupOpenerCapable = (*WebView)(nil)35 _ port.PopupOpenerCapable = (*WebView)(nil)
35 _ port.ViewportSyncCapable = (*WebView)(nil)36 _ port.ViewportSyncCapable = (*WebView)(nil)
36 _ port.OAuthCallbackCapable = (*WebView)(nil)37 _ port.OAuthCallbackCapable = (*WebView)(nil)
38+ _ port.PageScrollable = (*WebView)(nil)
39+ _ port.PageScrollCanceler = (*WebView)(nil)
40+ _ port.AccessibilityEnabler = (*WebView)(nil)
37 )41 )
38 42
39 // errDestroyed is returned when an operation is attempted on a destroyed WebView.43 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -69,8 +73,24 @@ var cefLoadWatchdogDelays = []time.Duration{
69 const (73 const (
70 cefGTKSyncDispatchTimeout = 2 * time.Second74 cefGTKSyncDispatchTimeout = 2 * time.Second
71 cefGTKSyncDispatchSlowThreshold = 250 * time.Millisecond75 cefGTKSyncDispatchSlowThreshold = 250 * time.Millisecond
76+ maxPendingPageScroll = 3200
72 )77 )
73 78
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+
74 var (94 var (
75 cefNewTask = purecef.NewTask95 cefNewTask = purecef.NewTask
76 cefNewStringVisitor = purecef.NewStringVisitor96 cefNewStringVisitor = purecef.NewStringVisitor
@@ -131,6 +151,21 @@ type WebView struct {
131 adaptiveFrameRatePollID uint151 adaptiveFrameRatePollID uint
132 lastAdaptiveFrameRate int32152 lastAdaptiveFrameRate int32
133 153
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+
134 // beginFrameTick drives CEF external BeginFrame requests while the GTK169 // beginFrameTick drives CEF external BeginFrame requests while the GTK
135 // widget is visible. Access is guarded by mu.170 // widget is visible. Access is guarded by mu.
136 beginFrameTick *gtk.TickCallback171 beginFrameTick *gtk.TickCallback
@@ -1201,6 +1236,8 @@ func (wv *WebView) Destroy() {
1201 if !wv.destroyed.CompareAndSwap(false, true) {1236 if !wv.destroyed.CompareAndSwap(false, true) {
1202 return1237 return
1203 }1238 }
1239+ wv.shutdownAccessibilityCapture()
1240+ wv.resetPageScrollQueue()
1204 wv.syntheticPopupMu.Lock()1241 wv.syntheticPopupMu.Lock()
1205 wv.syntheticPopups = nil1242 wv.syntheticPopups = nil
1206 wv.syntheticPopupMu.Unlock()1243 wv.syntheticPopupMu.Unlock()
@@ -1423,23 +1460,28 @@ func (wv *WebView) setEditableFocus(editable bool) {
1423 wv.mu.Lock()1460 wv.mu.Lock()
1424 previous := wv.focusedEditable1461 previous := wv.focusedEditable
1425 wv.focusedEditable = editable1462 wv.focusedEditable = editable
1463+ cb := wv.callbacks
1464+ var timer stoppableTimer
1426 if editable {1465 if editable {
1427 wv.selectionDebounceSeq++1466 wv.selectionDebounceSeq++
1428- timer := wv.selectionDebounceTimer1467+ timer = wv.selectionDebounceTimer
1429 wv.selectionDebounceTimer = nil1468 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
1438 }1469 }
1439 wv.mu.Unlock()1470 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 {
1441 logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")1478 logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
1442 }1479 }
1480+ if cb != nil && cb.OnEditableFocusChanged != nil {
1481+ wv.runOnGTK(func() {
1482+ cb.OnEditableFocusChanged(editable)
1483+ })
1484+ }
1443 }1485 }
1444 1486
1445 func (wv *WebView) cancelSelectionDebounce() {1487 func (wv *WebView) cancelSelectionDebounce() {
@@ -2368,6 +2410,231 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
2368 return pc2410 return pc
2369 }2411 }
2370 2412
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+
2371 // beginAudioStreamStart invalidates any prior stream before creating a new2638 // beginAudioStreamStart invalidates any prior stream before creating a new
2372 // one. A stream created for the returned generation may only be published if2639 // one. A stream created for the returned generation may only be published if
2373 // that generation is still current.2640 // 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
472 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).472 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
473 Msg("cef: OnLoadStart")473 Msg("cef: OnLoadStart")
474 }474 }
475+ h.wv.setEditableFocus(false)
475 h.wv.updateURI(frame.GetURL())476 h.wv.updateURI(frame.GetURL())
476 h.wv.mu.RLock()477 h.wv.mu.RLock()
477 cb := h.wv.callbacks478 cb := h.wv.callbacks
@@ -954,6 +955,7 @@ func (h *handlerSet) finishAfterCreated(
954 }955 }
955 h.wv.scheduleStartAdaptiveFrameRatePolling()956 h.wv.scheduleStartAdaptiveFrameRatePolling()
956 h.wv.scheduleStartBeginFrameLoop()957 h.wv.scheduleStartBeginFrameLoop()
958+ h.wv.applyAccessibilityIfReady()
957 h.wv.fireReadyToShow()959 h.wv.fireReadyToShow()
958 }960 }
959 961
M internal/infrastructure/cef/webview_handlers_test.go +34 -0
@@ -544,6 +544,40 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
544 require.Equal(t, "free selection", recorder.selection.Text)544 require.Equal(t, "free selection", recorder.selection.Text)
545 }545 }
546 546
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+
547 func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) {581 func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) {
548 called := false582 called := false
549 wv := &WebView{closeCallbacks: []func(){func() { called = true }}}583 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 (
38 defaultOmniboxAutoOpenOnNewPane = false38 defaultOmniboxAutoOpenOnNewPane = false
39 39
40 // Workspace defaults40 // Workspace defaults
41- defaultPaneActivationShortcut = "ctrl+p"41+ defaultPaneActivationShortcut = "ctrl+p"
42- defaultPaneTimeoutMilliseconds = 300042+ defaultPaneTimeoutMilliseconds = 3000
43- defaultTabActivationShortcut = "ctrl+t"43+ defaultTabActivationShortcut = "ctrl+t"
44- defaultTabTimeoutMilliseconds = 300044+ defaultTabTimeoutMilliseconds = 3000
45- defaultResizeActivationShortcut = "ctrl+n"45+ defaultResizeActivationShortcut = "ctrl+n"
46- defaultResizeTimeoutMilliseconds = 300046+ defaultResizeTimeoutMilliseconds = 3000
47- defaultResizeStepPercent = 5.047+ defaultPageActivationShortcut = "ctrl+y"
48- defaultResizeMinPanePercent = 10.048+ defaultPageTimeoutMilliseconds = 0
49- defaultTabBarPosition = "bottom"49+ defaultPageSequenceTimeoutMilliseconds = 500
50- defaultPopupPlacement = "right"50+ defaultResizeStepPercent = 5.0
51- defaultFloatingPaneWidthPct = 0.8251+ defaultResizeMinPanePercent = 10.0
52- defaultFloatingPaneHeightPct = 0.7252+ defaultTabBarPosition = "bottom"
53+ defaultPopupPlacement = "right"
54+ defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed
55+ defaultExternalLinkPlacement = ExternalLinkPlacementRight
56+ defaultFloatingPaneWidthPct = 0.82
57+ defaultFloatingPaneHeightPct = 0.72
53 58
54 // Session defaults59 // Session defaults
55 defaultSessionActivationShortcut = "ctrl+o"60 defaultSessionActivationShortcut = "ctrl+o"
@@ -300,6 +305,33 @@ func DefaultConfig() *Config {
300 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},305 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
301 },306 },
302 },307 },
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+ },
303 ResizeMode: ResizeModeConfig{335 ResizeMode: ResizeModeConfig{
304 ActivationShortcut: defaultResizeActivationShortcut,336 ActivationShortcut: defaultResizeActivationShortcut,
305 TimeoutMilliseconds: defaultResizeTimeoutMilliseconds,337 TimeoutMilliseconds: defaultResizeTimeoutMilliseconds,
@@ -324,7 +356,7 @@ func DefaultConfig() *Config {
324 Actions: map[string]ActionBinding{356 Actions: map[string]ActionBinding{
325 "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},357 "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
326 "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},358 "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"},
328 "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},360 "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},
329 "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},361 "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
330 "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},362 "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
@@ -341,6 +373,10 @@ func DefaultConfig() *Config {
341 HeightPct: defaultFloatingPaneHeightPct,373 HeightPct: defaultFloatingPaneHeightPct,
342 Profiles: map[string]FloatingPaneProfile{},374 Profiles: map[string]FloatingPaneProfile{},
343 },375 },
376+ ExternalLinks: ExternalLinksConfig{
377+ Behavior: defaultExternalLinkBehavior,
378+ Placement: defaultExternalLinkPlacement,
379+ },
344 TabBarPosition: defaultTabBarPosition,380 TabBarPosition: defaultTabBarPosition,
345 HideTabBarWhenSingleTab: true,381 HideTabBarWhenSingleTab: true,
346 BrowsingContexts: browsingContextDefaults,382 BrowsingContexts: browsingContextDefaults,
M internal/infrastructure/config/defaults_test.go +3 -1
@@ -13,6 +13,8 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
13 assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)13 assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)
14 assert.False(t, cfg.Logging.CaptureGTKLogs)14 assert.False(t, cfg.Logging.CaptureGTKLogs)
15 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)15 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
16+ assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)
17+ assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)
16 18
17 // Engine defaults (replaces old Performance/Privacy sections)19 // Engine defaults (replaces old Performance/Privacy sections)
18 assert.Equal(t, EngineTypeCEF, cfg.Engine.Type)20 assert.Equal(t, EngineTypeCEF, cfg.Engine.Type)
@@ -41,7 +43,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
41 43
42 // System view shortcuts are first-class global actions.44 // System view shortcuts are first-class global actions.
43 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})45 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"})
45 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-current-page-favorite", []string{"ctrl+d"})47 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-current-page-favorite", []string{"ctrl+d"})
46 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})48 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
47 49
M internal/infrastructure/config/keybindings_gateway.go +23 -0
@@ -32,6 +32,7 @@ func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.Keybindin
32 g.buildGlobalGroup(cfg, defaults),32 g.buildGlobalGroup(cfg, defaults),
33 g.buildPaneModeGroup(cfg, defaults),33 g.buildPaneModeGroup(cfg, defaults),
34 g.buildTabModeGroup(cfg, defaults),34 g.buildTabModeGroup(cfg, defaults),
35+ g.buildVimModeGroup(cfg, defaults),
35 g.buildResizeModeGroup(cfg, defaults),36 g.buildResizeModeGroup(cfg, defaults),
36 g.buildSessionModeGroup(cfg, defaults),37 g.buildSessionModeGroup(cfg, defaults),
37 }38 }
@@ -48,6 +49,7 @@ func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.Ke
48 g.buildGlobalGroup(defaults, defaults),49 g.buildGlobalGroup(defaults, defaults),
49 g.buildPaneModeGroup(defaults, defaults),50 g.buildPaneModeGroup(defaults, defaults),
50 g.buildTabModeGroup(defaults, defaults),51 g.buildTabModeGroup(defaults, defaults),
52+ g.buildVimModeGroup(defaults, defaults),
51 g.buildResizeModeGroup(defaults, defaults),53 g.buildResizeModeGroup(defaults, defaults),
52 g.buildSessionModeGroup(defaults, defaults),54 g.buildSessionModeGroup(defaults, defaults),
53 }55 }
@@ -108,6 +110,7 @@ func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error {
108 110
109 cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions111 cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions
110 cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions112 cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions
113+ cfg.Workspace.VimMode.Actions = defaults.Workspace.VimMode.Actions
111 cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions114 cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions
112 cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts115 cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts
113 cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions116 cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions
@@ -144,6 +147,16 @@ func (g *KeybindingsGateway) buildTabModeGroup(cfg, defaults *Config) port.Keybi
144 }147 }
145 }148 }
146 149
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+
147 // buildResizeModeGroup builds the resize mode group.160 // buildResizeModeGroup builds the resize mode group.
148 func (g *KeybindingsGateway) buildResizeModeGroup(cfg, defaults *Config) port.KeybindingGroup {161 func (g *KeybindingsGateway) buildResizeModeGroup(cfg, defaults *Config) port.KeybindingGroup {
149 return port.KeybindingGroup{162 return port.KeybindingGroup{
@@ -208,6 +221,11 @@ func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingR
208 existing.Keys = req.Keys221 existing.Keys = req.Keys
209 cfg.Workspace.TabMode.Actions[req.Action] = existing222 cfg.Workspace.TabMode.Actions[req.Action] = existing
210 }223 }
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+ }
211 case "resize":229 case "resize":
212 if existing, ok := cfg.Workspace.ResizeMode.Actions[req.Action]; ok {230 if existing, ok := cfg.Workspace.ResizeMode.Actions[req.Action]; ok {
213 existing.Keys = req.Keys231 existing.Keys = req.Keys
@@ -236,6 +254,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string)
236 if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok {254 if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok {
237 return binding.Keys255 return binding.Keys
238 }256 }
257+ case "vim":
258+ if binding, ok := defaults.Workspace.VimMode.Actions[action]; ok {
259+ return binding.Keys
260+ }
239 case "resize":261 case "resize":
240 if binding, ok := defaults.Workspace.ResizeMode.Actions[action]; ok {262 if binding, ok := defaults.Workspace.ResizeMode.Actions[action]; ok {
241 return binding.Keys263 return binding.Keys
@@ -269,6 +291,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction
269 addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)291 addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)
270 addBindings("pane", cfg.Workspace.PaneMode.Actions)292 addBindings("pane", cfg.Workspace.PaneMode.Actions)
271 addBindings("tab", cfg.Workspace.TabMode.Actions)293 addBindings("tab", cfg.Workspace.TabMode.Actions)
294+ addBindings("vim", cfg.Workspace.VimMode.Actions)
272 addBindings("resize", cfg.Workspace.ResizeMode.Actions)295 addBindings("resize", cfg.Workspace.ResizeMode.Actions)
273 addBindings("session", cfg.Session.SessionMode.Actions)296 addBindings("session", cfg.Session.SessionMode.Actions)
274 297
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() {
175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176 }176 }
177 177
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-
182 // Apply transformed config back to viper178 // Apply transformed config back to viper
183 for key, value := range rawConfig {179 for key, value := range rawConfig {
184 m.viper.Set(key, value)180 m.viper.Set(key, value)
@@ -378,6 +374,7 @@ func ensureDatabasePath(config *Config) error {
378 }374 }
379 375
380 func normalizeConfig(config *Config) {376 func normalizeConfig(config *Config) {
377+ normalizeExternalLinks(config)
381 normalizeAppearance(config)378 normalizeAppearance(config)
382 normalizeMedia(config)379 normalizeMedia(config)
383 normalizeEngineConfig(config)380 normalizeEngineConfig(config)
@@ -390,6 +387,20 @@ func normalizeBrowsingContexts(config *Config) {
390 config.Workspace.Popups = config.Workspace.BrowsingContexts387 config.Workspace.Popups = config.Workspace.BrowsingContexts
391 }388 }
392 389
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+
393 func normalizeEngineConfig(config *Config) {404 func normalizeEngineConfig(config *Config) {
394 // Normalize GSK renderer (engine.webkit)405 // Normalize GSK renderer (engine.webkit)
395 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {406 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
@@ -695,6 +706,11 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
695 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)706 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
696 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)707 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
697 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)708 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)
698 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)714 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
699 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)715 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
700 m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)716 m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
@@ -704,6 +720,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
704 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)720 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
705 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)721 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
706 m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)722 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))
707 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)725 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
708 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)726 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
709 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)727 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
36 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)36 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
37 }37 }
38 38
39-func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {39+func TestManagerLoad_DoesNotMergeMissingPaneModeActionsInMemory(t *testing.T) {
40 home := t.TempDir()40 home := t.TempDir()
41 t.Setenv("HOME", home)41 t.Setenv("HOME", home)
42 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))42 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
@@ -64,10 +64,65 @@ desc = "Custom stack pane"
64 cfg := mgr.Get()64 cfg := mgr.Get()
65 require.NotNil(t, cfg)65 require.NotNil(t, cfg)
66 66
67- eject, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]67+ _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68- require.True(t, ok)68+ assert.False(t, ok)
69- assert.Equal(t, []string{"w"}, eject.Keys)
70 69
71 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]70 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
72 assert.Equal(t, "Custom stack pane", stack.Desc)71 assert.Equal(t, "Custom stack pane", stack.Desc)
73 }72 }
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) {
39 assert.Empty(t, mgr.viper.GetString("runtime.prefix"))39 assert.Empty(t, mgr.viper.GetString("runtime.prefix"))
40 }40 }
41 41
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+
42 func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {53 func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {
43 cfg := DefaultConfig()54 cfg := DefaultConfig()
44 cfg.Engine.CookiePolicy = CookiePolicy("INVALID")55 cfg.Engine.CookiePolicy = CookiePolicy("INVALID")
M internal/infrastructure/config/migrate.go +228 -13
@@ -25,11 +25,6 @@ type Migrator struct {
25 defaultConfig *Config25 defaultConfig *Config
26 }26 }
27 27
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-
33 // NewMigrator creates a new Migrator instance.28 // NewMigrator creates a new Migrator instance.
34 func NewMigrator() *Migrator {29 func NewMigrator() *Migrator {
35 v := viper.New()30 v := viper.New()
@@ -58,17 +53,22 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
58 return nil, nil53 return nil, nil
59 }54 }
60 55
61- // Get user-defined keys from the TOML file56+ userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
62- userKeys, err := m.getUserConfigKeys(configFile)
63 if err != nil {57 if err != nil {
64 return nil, fmt.Errorf("failed to parse user config: %w", err)58 return nil, fmt.Errorf("failed to parse user config: %w", err)
65 }59 }
66 60
61+ userKeys := make(map[string]bool, len(userKeysWithValues))
62+ for key := range userKeysWithValues {
63+ userKeys[key] = true
64+ }
65+
67 // Get all default keys66 // Get all default keys
68 defaultKeys := m.getAllDefaultKeys()67 defaultKeys := m.getAllDefaultKeys()
69 68
70 // Find missing keys (in defaults but not in user config)69 // Find missing keys (in defaults but not in user config)
71 missingKeys := m.findMissingKeys(defaultKeys, userKeys)70 missingKeys := m.findMissingKeys(defaultKeys, userKeys)
71+ missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
72 72
73 if len(missingKeys) == 0 {73 if len(missingKeys) == 0 {
74 return nil, nil74 return nil, nil
@@ -119,13 +119,17 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
119 userKeySet[k] = true119 userKeySet[k] = true
120 }120 }
121 121
122- legacyBrowsingContextRenames := m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues)122+ legacyRenames := append(
123+ m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues),
124+ m.detectLegacyFavoritesSidebarRename(userKeysWithValues)...,
125+ )
126+ legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...)
123 var changes []port.KeyChange127 var changes []port.KeyChange
124- changes = append(changes, legacyBrowsingContextRenames...)128+ changes = append(changes, legacyRenames...)
125 129
126- handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyBrowsingContextRenames))130+ handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyRenames))
127- handledLegacyMissingKeys := make(map[string]bool, len(legacyBrowsingContextRenames))131+ handledLegacyMissingKeys := make(map[string]bool, len(legacyRenames))
128- for _, change := range legacyBrowsingContextRenames {132+ for _, change := range legacyRenames {
129 handledLegacyDeprecatedKeys[change.OldKey] = true133 handledLegacyDeprecatedKeys[change.OldKey] = true
130 handledLegacyMissingKeys[change.NewKey] = true134 handledLegacyMissingKeys[change.NewKey] = true
131 }135 }
@@ -299,6 +303,7 @@ func (m *Migrator) defaultActionMaps() []defaultActionMap {
299 {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},303 {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
300 {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},304 {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
301 {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},305 {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
306+ {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},
302 {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},307 {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},
303 {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},308 {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},
304 }309 }
@@ -416,6 +421,7 @@ func (m *Migrator) Migrate() ([]string, error) {
416 transformer.TransformLegacyActions(rawConfig)421 transformer.TransformLegacyActions(rawConfig)
417 transformer.TransformLegacyEngineConfig(rawConfig)422 transformer.TransformLegacyEngineConfig(rawConfig)
418 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)423 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
424+ m.migrateLegacyVimMode(rawConfig)
419 425
420 // Build sets of keys to remove and renames to apply426 // Build sets of keys to remove and renames to apply
421 keysToRemove := make(map[string]bool)427 keysToRemove := make(map[string]bool)
@@ -443,10 +449,11 @@ func (m *Migrator) Migrate() ([]string, error) {
443 }449 }
444 }450 }
445 451
446- // Remove deprecated and renamed keys from the raw config452+ // Remove deprecated and renamed keys from the raw config.
447 for key := range keysToRemove {453 for key := range keysToRemove {
448 m.deleteNestedKey(rawConfig, key)454 m.deleteNestedKey(rawConfig, key)
449 }455 }
456+ m.upgradeLegacyFavoritesSidebarDefault(rawConfig)
450 m.mergeMissingDefaultActions(rawConfig)457 m.mergeMissingDefaultActions(rawConfig)
451 458
452 // Create a new Viper instance with defaults for added keys459 // Create a new Viper instance with defaults for added keys
@@ -722,6 +729,31 @@ func (m *Migrator) getRawUserConfigKeysWithValues(configFile string) (map[string
722 return result, nil729 return result, nil
723 }730 }
724 731
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+
725 func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange {757 func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange {
726 type renamePair struct {758 type renamePair struct {
727 oldKey string759 oldKey string
@@ -758,6 +790,104 @@ func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any
758 return changes790 return changes
759 }791 }
760 792
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+
761 // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.891 // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
762 func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {892 func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
763 for k, v := range data {893 for k, v := range data {
@@ -894,6 +1024,91 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
894 }1024 }
895 }1025 }
896 1026
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+
897 func appendUniqueKeys(base, extra []string) []string {1112 func appendUniqueKeys(base, extra []string) []string {
898 if len(extra) == 0 {1113 if len(extra) == 0 {
899 return base1114 return base
M internal/infrastructure/config/migrate_test.go +258 -0
@@ -816,6 +816,41 @@ func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) {
816 assert.Equal(t, "Custom move pane", move["desc"])816 assert.Equal(t, "Custom move pane", move["desc"])
817 }817 }
818 818
819+func TestMigrator_MergeMissingDefaultActions_VimMode(t *testing.T) {
820+ m := NewMigrator()
821+
822+ rawConfig := map[string]any{
823+ "workspace": map[string]any{
824+ "vim_mode": map[string]any{
825+ "actions": map[string]any{
826+ "heading-next": map[string]any{
827+ "keys": []string{"]]"},
828+ "desc": "Custom next heading",
829+ },
830+ },
831+ },
832+ },
833+ }
834+
835+ m.mergeMissingDefaultActions(rawConfig)
836+
837+ actionsAny := m.getNestedValue(rawConfig, "workspace.vim_mode.actions")
838+ actions, ok := actionsAny.(map[string]any)
839+ require.True(t, ok)
840+
841+ focusAny, hasFocus := actions["focus-input"]
842+ require.True(t, hasFocus)
843+ focus, ok := focusAny.(ActionBinding)
844+ require.True(t, ok)
845+ assert.Equal(t, []string{"gi"}, focus.Keys)
846+
847+ headingAny, hasHeading := actions["heading-next"]
848+ require.True(t, hasHeading)
849+ heading, ok := headingAny.(map[string]any)
850+ require.True(t, ok)
851+ assert.Equal(t, "Custom next heading", heading["desc"])
852+}
853+
819 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {854 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
820 m := NewMigrator()855 m := NewMigrator()
821 856
@@ -1175,6 +1210,118 @@ func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
1175 assert.NotContains(t, string(migrated), "[workspace.popups]")1210 assert.NotContains(t, string(migrated), "[workspace.popups]")
1176 }1211 }
1177 1212
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+
1178 func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {1325 func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
1179 home := t.TempDir()1326 home := t.TempDir()
1180 t.Setenv("HOME", home)1327 t.Setenv("HOME", home)
@@ -1282,3 +1429,114 @@ section:
1282 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)1429 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
1283 })1430 })
1284 }1431 }
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
24 // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).24 // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
25 type ResizeModeConfig = entity.ResizeModeConfig25 type ResizeModeConfig = entity.ResizeModeConfig
26 26
27+// VimModeConfig defines modal behavior for Vim-style scrolling.
28+type VimModeConfig = entity.VimModeConfig
29+
27 // SessionModeConfig defines modal behavior for session management.30 // SessionModeConfig defines modal behavior for session management.
28 type SessionModeConfig = entity.SessionModeConfig31 type SessionModeConfig = entity.SessionModeConfig
29 32
@@ -42,6 +45,29 @@ type FloatingPaneConfig = entity.FloatingPaneConfig
42 // WorkspaceStylingConfig defines visual styling for workspace panes.45 // WorkspaceStylingConfig defines visual styling for workspace panes.
43 type WorkspaceStylingConfig = entity.WorkspaceStylingConfig46 type WorkspaceStylingConfig = entity.WorkspaceStylingConfig
44 47
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+
45 // PopupBehavior defines how popup windows should be opened.71 // PopupBehavior defines how popup windows should be opened.
46 type PopupBehavior = entity.PopupBehavior72 type PopupBehavior = entity.PopupBehavior
47 73
M internal/infrastructure/config/schema_provider.go +55 -0
@@ -472,6 +472,44 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
472 Description: "Key bindings for tab mode actions",472 Description: "Key bindings for tab mode actions",
473 Section: SectionWorkspace,473 Section: SectionWorkspace,
474 },474 },
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+ },
475 // Resize mode513 // Resize mode
476 {514 {
477 Key: "workspace.resize_mode.activation_shortcut",515 Key: "workspace.resize_mode.activation_shortcut",
@@ -541,6 +579,23 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
541 Description: "Named floating pane URL profiles with keys, url, and optional desc",579 Description: "Named floating pane URL profiles with keys, url, and optional desc",
542 Section: SectionWorkspace,580 Section: SectionWorkspace,
543 },581 },
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+ },
544 // Browsing contexts599 // Browsing contexts
545 {600 {
546 Key: "workspace.browsing_contexts.behavior",601 Key: "workspace.browsing_contexts.behavior",
M internal/infrastructure/config/schema_provider_test.go +1 -0
@@ -146,6 +146,7 @@ func TestSchemaProviderCoversViperDefaults(t *testing.T) {
146 "search_shortcuts": "search_shortcuts.<key>",146 "search_shortcuts": "search_shortcuts.<key>",
147 "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>",147 "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>",
148 "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>",148 "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>",
149+ "workspace.vim_mode.actions": "workspace.vim_mode.actions.<action>",
149 "workspace.resize_mode.actions": "workspace.resize_mode.actions.<action>",150 "workspace.resize_mode.actions": "workspace.resize_mode.actions.<action>",
150 "workspace.floating_pane.profiles": "workspace.floating_pane.profiles.<name>",151 "workspace.floating_pane.profiles": "workspace.floating_pane.profiles.<name>",
151 "session.session_mode.actions": "session.session_mode.actions.<action>",152 "session.session_mode.actions": "session.session_mode.actions.<action>",
M internal/infrastructure/config/validation.go +141 -37
@@ -11,6 +11,7 @@ import (
11 11
12 domainurl "github.com/bnema/dumber/internal/domain/url"12 domainurl "github.com/bnema/dumber/internal/domain/url"
13 domainvalidation "github.com/bnema/dumber/internal/domain/validation"13 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
14+ "github.com/bnema/dumber/internal/domain/vimkeys"
14 )15 )
15 16
16 const cefLogSeverityDisabled = 9917 const cefLogSeverityDisabled = 99
@@ -24,10 +25,12 @@ func validateConfig(config *Config) error {
24 validationErrors = append(validationErrors, validateAppearance(config)...)25 validationErrors = append(validationErrors, validateAppearance(config)...)
25 validationErrors = append(validationErrors, validateSearchEngine(config)...)26 validationErrors = append(validationErrors, validateSearchEngine(config)...)
26 validationErrors = append(validationErrors, validatePopups(config)...)27 validationErrors = append(validationErrors, validatePopups(config)...)
28+ validationErrors = append(validationErrors, validateExternalLinks(config)...)
27 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)29 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
28 validationErrors = append(validationErrors, validatePaneMode(config)...)30 validationErrors = append(validationErrors, validatePaneMode(config)...)
29 validationErrors = append(validationErrors, validateTabBar(config)...)31 validationErrors = append(validationErrors, validateTabBar(config)...)
30 validationErrors = append(validationErrors, validateTabMode(config)...)32 validationErrors = append(validationErrors, validateTabMode(config)...)
33+ validationErrors = append(validationErrors, validateVimMode(config)...)
31 validationErrors = append(validationErrors, validateFloatingPane(config)...)34 validationErrors = append(validationErrors, validateFloatingPane(config)...)
32 validationErrors = append(validationErrors, validateLogging(config)...)35 validationErrors = append(validationErrors, validateLogging(config)...)
33 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)36 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
@@ -187,6 +190,28 @@ func validatePopups(config *Config) []string {
187 return validationErrors190 return validationErrors
188 }191 }
189 192
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+
190 func validateWorkspaceStyling(config *Config) []string {215 func validateWorkspaceStyling(config *Config) []string {
191 var validationErrors []string216 var validationErrors []string
192 if config.Workspace.Styling.BorderWidth < 0 {217 if config.Workspace.Styling.BorderWidth < 0 {
@@ -202,32 +227,7 @@ func validateWorkspaceStyling(config *Config) []string {
202 }227 }
203 228
204 func validatePaneMode(config *Config) []string {229 func validatePaneMode(config *Config) []string {
205- var validationErrors []string230+ return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions)
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
231 }231 }
232 232
233 func validateTabBar(config *Config) []string {233 func validateTabBar(config *Config) []string {
@@ -240,34 +240,138 @@ func validateTabBar(config *Config) []string {
240 }240 }
241 241
242 func validateTabMode(config *Config) []string {242 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 {
243 var validationErrors []string272 var validationErrors []string
244- if config.Workspace.TabMode.TimeoutMilliseconds < 0 {273+ if timeoutMS < 0 {
245- validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")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))
246 }278 }
247- if len(config.Workspace.TabMode.Actions) == 0 {279+
248- validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")280+ if keyCanon == nil {
281+ keyCanon = func(key string) (string, error) { return key, nil }
249 }282 }
250 283
251- tabSeenKeys := make(map[string]string)284+ actionNames := make([]string, 0, len(actions))
252- for action, binding := range config.Workspace.TabMode.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]
253 if len(binding.Keys) == 0 {293 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
255 }296 }
256- for _, key := range binding.Keys {297+ keys := append([]string(nil), binding.Keys...)
257- if existingAction, exists := tabSeenKeys[key]; exists {298+ sort.Strings(keys)
299+ for _, key := range keys {
300+ canonical, err := keyCanon(key)
301+ if err != nil {
258 validationErrors = append(validationErrors, fmt.Sprintf(302 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,
260 key,306 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,
261 existingAction,342 existingAction,
262 action,343 action,
263 ))344 ))
264 }345 }
265- tabSeenKeys[key] = action346+ seenSequence[canonical] = action
266 }347 }
267 }348 }
268 return validationErrors349 return validationErrors
269 }350 }
270 351
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+
271 func validateFloatingPane(config *Config) []string {375 func validateFloatingPane(config *Config) []string {
272 var validationErrors []string376 var validationErrors []string
273 seenProfileKeys := make(map[string]string)377 seenProfileKeys := make(map[string]string)
M internal/infrastructure/config/validation_test.go +32 -0
@@ -294,6 +294,38 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
294 }294 }
295 }295 }
296 296
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+
297 func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {329 func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {
298 tmpDir := t.TempDir()330 tmpDir := t.TempDir()
299 path := filepath.Join(tmpDir, "page.html")331 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
446 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.446 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
447 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"447 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
448 448
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+
449 const layerShellPreloadToken = "libgtk4" + "-layer-shell"453 const layerShellPreloadToken = "libgtk4" + "-layer-shell"
450 454
451 // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).455 // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).
@@ -466,6 +470,23 @@ func LaunchBrowserURL(uri string) {
466 }470 }
467 471
468 func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, error), spawnDetachedProcess func(*exec.Cmd) error) error {472 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 {
469 if resolveExecutablePath == nil {490 if resolveExecutablePath == nil {
470 resolveExecutablePath = getExecutablePath491 resolveExecutablePath = getExecutablePath
471 }492 }
@@ -479,7 +500,10 @@ func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, er
479 }500 }
480 501
481 cmd := exec.Command(execPath, "browse", uri)502 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+ }
483 if err := spawnDetachedProcess(cmd); err != nil {507 if err := spawnDetachedProcess(cmd); err != nil {
484 return fmt.Errorf("failed to launch dumber browse for requested URL: %w", err)508 return fmt.Errorf("failed to launch dumber browse for requested URL: %w", err)
485 }509 }
M internal/infrastructure/desktop/adapter_test.go +15 -0
@@ -220,6 +220,21 @@ func TestLaunchBrowserBrowseURL_SpawnErrorDoesNotLeakURL(t *testing.T) {
220 }220 }
221 }221 }
222 222
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+
223 func TestSetAsDefaultBrowserAlsoUpdatesMimeHandlers(t *testing.T) {238 func TestSetAsDefaultBrowserAlsoUpdatesMimeHandlers(t *testing.T) {
224 t.Setenv("HOME", t.TempDir())239 t.Setenv("HOME", t.TempDir())
225 t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))240 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 {
39 }39 }
40 40
41 type browserLaunchRequest struct {41 type browserLaunchRequest struct {
42- RequestID string `json:"request_id,omitempty"`42+ RequestID string `json:"request_id,omitempty"`
43- URL string `json:"url"`43+ URL string `json:"url"`
44+ Action browserLaunchAction `json:"action,omitempty"`
44 }45 }
45 46
47+type browserLaunchAction string
48+
49+const (
50+ browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url"
51+ browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window"
52+)
53+
46 type browserLaunchResponse struct {54 type browserLaunchResponse struct {
47 RequestID string `json:"request_id,omitempty"`55 RequestID string `json:"request_id,omitempty"`
48 Accepted bool `json:"accepted,omitempty"`56 Accepted bool `json:"accepted,omitempty"`
@@ -68,7 +76,15 @@ func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay
68 return &browserLaunchRelay{ipc: ipc}76 return &browserLaunchRelay{ipc: ipc}
69 }77 }
70 78
79+func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
80+ return r.deliver(ctx, url, "")
81+}
82+
71 func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {83 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) {
72 socketPath, err := r.socketPath()88 socketPath, err := r.socketPath()
73 if err != nil {89 if err != nil {
74 return false, err90 return false, err
@@ -93,7 +109,7 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str
93 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {109 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
94 return false, err110 return false, err
95 }111 }
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 {
97 return false, err113 return false, err
98 }114 }
99 115
@@ -353,20 +369,34 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
353 log.Debug().369 log.Debug().
354 Str("request_id", requestID).370 Str("request_id", requestID).
355 Str("url_host", safeURLHost(request.URL)).371 Str("url_host", safeURLHost(request.URL)).
356- Msg("browser launch relay calling browser window opener")372+ Msg("browser launch relay calling browser URL opener")
357- if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {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 {
358 log.Warn().Err(err).388 log.Warn().Err(err).
359 Str("request_id", requestID).389 Str("request_id", requestID).
360 Str("url_host", safeURLHost(request.URL)).390 Str("url_host", safeURLHost(request.URL)).
361 Dur("elapsed", time.Since(started)).391 Dur("elapsed", time.Since(started)).
362- Msg("browser launch relay opener failed")392+ Msg("browser launch relay browser URL opener failed")
363 return393 return
364 }394 }
365 log.Debug().395 log.Debug().
366 Str("request_id", requestID).396 Str("request_id", requestID).
367 Str("url_host", safeURLHost(request.URL)).397 Str("url_host", safeURLHost(request.URL)).
368 Dur("elapsed", time.Since(started)).398 Dur("elapsed", time.Since(started)).
369- Msg("browser launch relay opener returned success")399+ Msg("browser launch relay browser URL opener returned success")
370 }()400 }()
371 }401 }
372 402
M internal/infrastructure/desktop/browser_launch_relay_test.go +78 -23
@@ -17,10 +17,27 @@ import (
17 17
18 type browserWindowOpenerFunc func(context.Context, string) error18 type browserWindowOpenerFunc func(context.Context, string) error
19 19
20+func (f browserWindowOpenerFunc) OpenExternalURL(ctx context.Context, url string) error {
21+ return f(ctx, url)
22+}
23+
20 func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {24 func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
21 return f(ctx, url)25 return f(ctx, url)
22 }26 }
23 27
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+
24 func waitForSocket(t *testing.T, path string) {41 func waitForSocket(t *testing.T, path string) {
25 t.Helper()42 t.Helper()
26 43
@@ -112,10 +129,10 @@ func overrideBrowserLaunchRequestID(t *testing.T, id string) func() {
112 return func() { newBrowserLaunchRequestID = previous }129 return func() { newBrowserLaunchRequestID = previous }
113 }130 }
114 131
115-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {132+func TestBrowserLaunchRelay_DeliverOpenExternalURL_MissingListenerReturnsFalseNil(t *testing.T) {
116 relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))133 relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))
117 134
118- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")135+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com")
119 136
120 require.NoError(t, err)137 require.NoError(t, err)
121 assert.False(t, delivered)138 assert.False(t, delivered)
@@ -138,7 +155,7 @@ func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) {
138 155
139 waitForSocket(t, ipc.BrowserLaunchSocket)156 waitForSocket(t, ipc.BrowserLaunchSocket)
140 157
141- delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace")158+ delivered, err := clientRelay.DeliverOpenExternalURL(context.Background(), "https://example.com/same-namespace")
142 require.NoError(t, err)159 require.NoError(t, err)
143 require.True(t, delivered)160 require.True(t, delivered)
144 require.Equal(t, "https://example.com/same-namespace", <-received)161 require.Equal(t, "https://example.com/same-namespace", <-received)
@@ -151,7 +168,7 @@ func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T)
151 require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)168 require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)
152 }169 }
153 170
154-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {171+func TestBrowserLaunchRelay_DeliverOpenExternalURL_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {
155 ipc := testIPC(shortTempDir(t))172 ipc := testIPC(shortTempDir(t))
156 relay := NewBrowserLaunchRelay(ipc)173 relay := NewBrowserLaunchRelay(ipc)
157 restore := overrideBrowserLaunchRequestID(t, "request-test-1")174 restore := overrideBrowserLaunchRequestID(t, "request-test-1")
@@ -180,7 +197,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch
180 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: request.RequestID, Accepted: true})197 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: request.RequestID, Accepted: true})
181 }()198 }()
182 199
183- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/request-id")200+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id")
184 201
185 require.NoError(t, err)202 require.NoError(t, err)
186 require.True(t, delivered)203 require.True(t, delivered)
@@ -189,7 +206,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch
189 require.Equal(t, "https://example.com/request-id", request.URL)206 require.Equal(t, "https://example.com/request-id", request.URL)
190 }207 }
191 208
192-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequestID(t *testing.T) {209+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RejectsMismatchedResponseRequestID(t *testing.T) {
193 ipc := testIPC(shortTempDir(t))210 ipc := testIPC(shortTempDir(t))
194 relay := NewBrowserLaunchRelay(ipc)211 relay := NewBrowserLaunchRelay(ipc)
195 restore := overrideBrowserLaunchRequestID(t, "request-test-2")212 restore := overrideBrowserLaunchRequestID(t, "request-test-2")
@@ -216,30 +233,36 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequ
216 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: "different-request", Accepted: true})233 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: "different-request", Accepted: true})
217 }()234 }()
218 235
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")
220 237
221 require.Error(t, err)238 require.Error(t, err)
222 require.True(t, delivered)239 require.True(t, delivered)
223 require.Contains(t, err.Error(), "mismatched browser launch relay response")240 require.Contains(t, err.Error(), "mismatched browser launch relay response")
224 }241 }
225 242
226-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {243+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RoundTrip(t *testing.T) {
227 ipc := testIPC(shortTempDir(t))244 ipc := testIPC(shortTempDir(t))
228 relay := NewBrowserLaunchRelay(ipc)245 relay := NewBrowserLaunchRelay(ipc)
229 246
230 received := make(chan string, 1)247 received := make(chan string, 1)
231 ctx := t.Context()248 ctx := t.Context()
232 249
233- closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {250+ closer, err := relay.Listen(ctx, browserWindowOpener{
234- received <- url251+ openExternalURL: func(_ context.Context, url string) error {
235- return nil252+ received <- url
236- }))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+ })
237 require.NoError(t, err)260 require.NoError(t, err)
238 defer closer.Close()261 defer closer.Close()
239 262
240 waitForSocket(t, ipc.BrowserLaunchSocket)263 waitForSocket(t, ipc.BrowserLaunchSocket)
241 264
242- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")265+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/new")
243 266
244 require.NoError(t, err)267 require.NoError(t, err)
245 assert.True(t, delivered)268 assert.True(t, delivered)
@@ -252,6 +275,38 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
252 }275 }
253 }276 }
254 277
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+
255 func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) {310 func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) {
256 relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{})311 relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{})
257 312
@@ -279,7 +334,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t
279 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))334 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
280 require.Error(t, err)335 require.Error(t, err)
281 336
282- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/live")337+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/live")
283 require.NoError(t, err)338 require.NoError(t, err)
284 require.True(t, delivered)339 require.True(t, delivered)
285 340
@@ -310,7 +365,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
310 365
311 waitForSocket(t, ipc.BrowserLaunchSocket)366 waitForSocket(t, ipc.BrowserLaunchSocket)
312 367
313- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")368+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/stale")
314 require.NoError(t, err)369 require.NoError(t, err)
315 require.True(t, delivered)370 require.True(t, delivered)
316 371
@@ -345,7 +400,7 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
345 require.NoError(t, err)400 require.NoError(t, err)
346 require.NoError(t, conn.Close())401 require.NoError(t, conn.Close())
347 402
348- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/still-works")403+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/still-works")
349 404
350 require.NoError(t, err)405 require.NoError(t, err)
351 assert.True(t, delivered)406 assert.True(t, delivered)
@@ -387,12 +442,12 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
387 waitForSocketGone(t, ipc.BrowserLaunchSocket)442 waitForSocketGone(t, ipc.BrowserLaunchSocket)
388 require.NoError(t, closer.Close())443 require.NoError(t, closer.Close())
389 444
390- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")445+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/after-cancel")
391 require.NoError(t, err)446 require.NoError(t, err)
392 assert.False(t, delivered)447 assert.False(t, delivered)
393 }448 }
394 449
395-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {450+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RespectsResponseReadTimeout(t *testing.T) {
396 ipc := testIPC(shortTempDir(t))451 ipc := testIPC(shortTempDir(t))
397 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))452 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
398 453
@@ -419,13 +474,13 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
419 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)474 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
420 defer cancel()475 defer cancel()
421 476
422- delivered, err := relay.DeliverOpenFreshWindow(ctx, "https://example.com/slow")477+ delivered, err := relay.DeliverOpenExternalURL(ctx, "https://example.com/slow")
423 require.Error(t, err)478 require.Error(t, err)
424 require.ErrorIs(t, err, context.DeadlineExceeded)479 require.ErrorIs(t, err, context.DeadlineExceeded)
425 assert.False(t, delivered)480 assert.False(t, delivered)
426 }481 }
427 482
428-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {483+func TestBrowserLaunchRelay_DeliverOpenExternalURL_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
429 ipc := testIPC(shortTempDir(t))484 ipc := testIPC(shortTempDir(t))
430 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))485 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
431 486
@@ -459,7 +514,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe
459 result := make(chan deliverResult, 1)514 result := make(chan deliverResult, 1)
460 go func() {515 go func() {
461 close(started)516 close(started)
462- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")517+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/slow")
463 result <- deliverResult{delivered: delivered, err: err}518 result <- deliverResult{delivered: delivered, err: err}
464 }()519 }()
465 <-started520 <-started
@@ -470,7 +525,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe
470 require.Error(t, got.err)525 require.Error(t, got.err)
471 require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)526 require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)
472 case <-time.After(browserLaunchIOTimeout * 3):527 case <-time.After(browserLaunchIOTimeout * 3):
473- t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")528+ t.Fatal("DeliverOpenExternalURL blocked without a caller deadline")
474 }529 }
475 }530 }
476 531
@@ -574,7 +629,7 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
574 }629 }
575 result := make(chan deliverResult, 1)630 result := make(chan deliverResult, 1)
576 go func() {631 go func() {
577- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/recovered")632+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/recovered")
578 result <- deliverResult{delivered: delivered, err: err}633 result <- deliverResult{delivered: delivered, err: err}
579 }()634 }()
580 635
M internal/infrastructure/desktop/browser_launcher.go +6 -4
@@ -13,7 +13,7 @@ import (
13 // received the request, but the caller could not confirm that in time.13 // received the request, but the caller could not confirm that in time.
14 var ErrBrowserLaunchUnconfirmed = errors.New("browser launch could not be confirmed")14 var ErrBrowserLaunchUnconfirmed = errors.New("browser launch could not be confirmed")
15 15
16-// BrowserLauncher opens URLs in a relay-first dumber browser instance.16+// BrowserLauncher opens standalone-omnibox URLs in a relay-first dumber browser instance.
17 type BrowserLauncher struct {17 type BrowserLauncher struct {
18 relay port.BrowserLaunchRelay18 relay port.BrowserLaunchRelay
19 resolveExecutablePath func() (string, error)19 resolveExecutablePath func() (string, error)
@@ -29,8 +29,10 @@ func NewBrowserLauncher(relay port.BrowserLaunchRelay) *BrowserLauncher {
29 }29 }
30 }30 }
31 31
32-// LaunchURL forwards the URL to a running instance when possible, otherwise spawns a new one.32+// LaunchFreshWindowURL forwards the URL to a running instance when possible,
33-func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) error {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 {
34 if l == nil {36 if l == nil {
35 return errors.New("browser launcher is unavailable")37 return errors.New("browser launcher is unavailable")
36 }38 }
@@ -48,7 +50,7 @@ func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) error {
48 }50 }
49 }51 }
50 52
51- if err := launchBrowserBrowseURL(url, l.resolveExecutablePath, l.startDetachedProcess); err != nil {53+ if err := launchBrowserFreshWindowURL(url, l.resolveExecutablePath, l.startDetachedProcess); err != nil {
52 return fmt.Errorf("launch dumber browse: %w", err)54 return fmt.Errorf("launch dumber browse: %w", err)
53 }55 }
54 return nil56 return nil
M internal/infrastructure/desktop/browser_launcher_test.go +11 -10
@@ -11,7 +11,7 @@ import (
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 )12 )
13 13
14-func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) {14+func TestBrowserLauncher_LaunchFreshWindowURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) {
15 relay := mocks.NewMockBrowserLaunchRelay(t)15 relay := mocks.NewMockBrowserLaunchRelay(t)
16 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)16 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)
17 17
@@ -26,13 +26,13 @@ func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *te
26 return nil26 return nil
27 }27 }
28 28
29- err := launcher.LaunchURL(context.Background(), "https://example.com")29+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
30 30
31 require.NoError(t, err)31 require.NoError(t, err)
32 assert.False(t, spawned)32 assert.False(t, spawned)
33 }33 }
34 34
35-func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) {35+func TestBrowserLauncher_LaunchFreshWindowURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) {
36 relay := mocks.NewMockBrowserLaunchRelay(t)36 relay := mocks.NewMockBrowserLaunchRelay(t)
37 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)37 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)
38 38
@@ -47,15 +47,16 @@ func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T)
47 return nil47 return nil
48 }48 }
49 49
50- err := launcher.LaunchURL(context.Background(), "https://example.com")50+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
51 51
52 require.NoError(t, err)52 require.NoError(t, err)
53 require.NotNil(t, gotCmd)53 require.NotNil(t, gotCmd)
54 assert.Equal(t, "/usr/bin/dumber", gotCmd.Path)54 assert.Equal(t, "/usr/bin/dumber", gotCmd.Path)
55 assert.Equal(t, []string{"/usr/bin/dumber", "browse", "https://example.com"}, gotCmd.Args)55 assert.Equal(t, []string{"/usr/bin/dumber", "browse", "https://example.com"}, gotCmd.Args)
56+ assert.Contains(t, gotCmd.Env, FreshWindowLaunchEnvVar+"=1")
56 }57 }
57 58
58-func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmbiguous(t *testing.T) {59+func TestBrowserLauncher_LaunchFreshWindowURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmbiguous(t *testing.T) {
59 relay := mocks.NewMockBrowserLaunchRelay(t)60 relay := mocks.NewMockBrowserLaunchRelay(t)
60 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, ErrBrowserLaunchRelayUnconfirmed)61 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, ErrBrowserLaunchRelayUnconfirmed)
61 62
@@ -69,13 +70,13 @@ func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmb
69 return nil70 return nil
70 }71 }
71 72
72- err := launcher.LaunchURL(context.Background(), "https://example.com")73+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
73 74
74 require.Error(t, err)75 require.Error(t, err)
75 assert.ErrorIs(t, err, ErrBrowserLaunchUnconfirmed)76 assert.ErrorIs(t, err, ErrBrowserLaunchUnconfirmed)
76 }77 }
77 78
78-func TestBrowserLauncher_LaunchURL_PropagatesRelayError(t *testing.T) {79+func TestBrowserLauncher_LaunchFreshWindowURL_PropagatesRelayError(t *testing.T) {
79 relay := mocks.NewMockBrowserLaunchRelay(t)80 relay := mocks.NewMockBrowserLaunchRelay(t)
80 wantErr := errors.New("relay exploded")81 wantErr := errors.New("relay exploded")
81 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr)82 relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr)
@@ -90,17 +91,17 @@ func TestBrowserLauncher_LaunchURL_PropagatesRelayError(t *testing.T) {
90 return nil91 return nil
91 }92 }
92 93
93- err := launcher.LaunchURL(context.Background(), "https://example.com")94+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
94 95
95 require.Error(t, err)96 require.Error(t, err)
96 require.ErrorIs(t, err, wantErr)97 require.ErrorIs(t, err, wantErr)
97 assert.NotContains(t, err.Error(), "https://example.com")98 assert.NotContains(t, err.Error(), "https://example.com")
98 }99 }
99 100
100-func TestBrowserLauncher_LaunchURL_NilLauncherReturnsError(t *testing.T) {101+func TestBrowserLauncher_LaunchFreshWindowURL_NilLauncherReturnsError(t *testing.T) {
101 var launcher *BrowserLauncher102 var launcher *BrowserLauncher
102 103
103- err := launcher.LaunchURL(context.Background(), "https://example.com")104+ err := launcher.LaunchFreshWindowURL(context.Background(), "https://example.com")
104 105
105 require.Error(t, err)106 require.Error(t, err)
106 assert.Contains(t, err.Error(), "unavailable")107 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 (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "sort"6 "sort"
7+ "strconv"
7 "strings"8 "strings"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
@@ -179,35 +180,46 @@ const explicitCopyScript = `(function() {
179 }180 }
180 })();`181 })();`
181 182
182-// accentDetectionScript is built at init from entity.AccentMap so the JS183+// accentDetectionScriptKeys is built at init from entity.AccentMap so the JS
183 // filter stays in sync with the Go-side accent table.184 // filter stays in sync with the Go-side accent table.
184-var accentDetectionScript string185+var accentDetectionScriptKeys string
185 186
186 func buildExplicitCopyScript() string {187 func buildExplicitCopyScript() string {
187 return explicitCopyScript188 return explicitCopyScript
188 }189 }
189 190
190-func init() {191+func buildAccentDetectionScript(token string) string {
191- // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"192+ return fmt.Sprintf(`(function() {
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() {
199 'use strict';193 'use strict';
200 const accentKeys = new Set([%s]);194 const accentKeys = new Set([%s]);
195+ const editableFocusToken = %q;
201 let pressedKey = null;196 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+ }
202 209
203 function isEditableTarget(target) {210 function isEditableTarget(target) {
204 const el = target instanceof Element ? target : null;211 const el = target instanceof Element ? target : null;
205 if (!el) return false;212 if (!el) return false;
206- if (el.closest('[contenteditable=""], [contenteditable="true"]')) return true;213+ if (el.isContentEditable) return true;
207- if (el.closest('textarea')) 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;
208 const input = el.closest('input');218 const input = el.closest('input');
209 if (!input) return false;219 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;
211 }223 }
212 224
213 document.addEventListener('keydown', function(e) {225 document.addEventListener('keydown', function(e) {
@@ -234,12 +246,50 @@ func init() {
234 }246 }
235 }, true);247 }, true);
236 248
237- document.addEventListener('focusin', function(e) {249+ function canReportNegativeFocus(target) {
238- if (isEditableTarget(e.target)) {250+ if (window.top === window) return true;
239- window.__dumber_lastEditableEl = e.target;251+ return lastEditableFocusState === true && window.__dumber_lastEditableEl === target;
240- }252+ }
241- }, true);253+
242-})();`, strings.Join(keys, ","))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, ",")
243 }293 }
244 294
245 // accentDetectionInjectionMode controls which frames receive the accent detection script.295 // accentDetectionInjectionMode controls which frames receive the accent detection script.
@@ -449,9 +499,17 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
449 499
450 // 8. Inject accent key detection for all pages and all frames (unconditional).500 // 8. Inject accent key detection for all pages and all frames (unconditional).
451 // JS only reports keydown/keyup events; Go handles timing and picker display.501 // 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())
452 addScript(510 addScript(
453 webkit.NewUserScript(511 webkit.NewUserScript(
454- accentDetectionScript,512+ accentScript,
455 accentDetectionInjectionMode,513 accentDetectionInjectionMode,
456 webkit.UserScriptInjectAtDocumentEndValue,514 webkit.UserScriptInjectAtDocumentEndValue,
457 nil, // all pages515 nil, // all pages
M internal/infrastructure/webkit/injector_accent_test.go +30 -2
@@ -19,16 +19,44 @@ func TestAccentDetectionScriptInjectionMode(t *testing.T) {
19 }19 }
20 20
21 func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) {21 func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) {
22+ script := buildAccentDetectionScript("")
22 assert.Contains(t,23 assert.Contains(t,
23- accentDetectionScript,24+ script,
24 "window.__dumber_lastEditableEl = e.target",25 "window.__dumber_lastEditableEl = e.target",
25 "accent detection script must track the last focused editable element",26 "accent detection script must track the last focused editable element",
26 )27 )
27 assert.Contains(t,28 assert.Contains(t,
28- accentDetectionScript,29+ script,
29 "document.addEventListener('focusin'",30 "document.addEventListener('focusin'",
30 "accent detection script must register a focusin listener",31 "accent detection script must register a focusin listener",
31 )32 )
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")
32 }60 }
33 61
34 func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {62 func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
M internal/infrastructure/webkit/messaging.go +68 -26
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/javascriptcore"12 "github.com/bnema/puregotk/v4/javascriptcore"
13 "github.com/bnema/puregotk/v4/webkit"13 "github.com/bnema/puregotk/v4/webkit"
14+ "github.com/rs/zerolog"
14 )15 )
15 16
16 // Message is the JS -> Go message envelope sent via postMessage.17 // Message is the JS -> Go message envelope sent via postMessage.
@@ -187,32 +188,8 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
187 188
188 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry")189 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry")
189 190
190- if valuePtr == 0 {191+ msg, ok := decodeScriptMessage(valuePtr, log)
191- log.Warn().Msg("received script message with nil value pointer")192+ if !ok {
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")
216 return193 return
217 }194 }
218 195
@@ -221,6 +198,9 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
221 log.Warn().Msg("rejecting script message from unknown sender")198 log.Warn().Msg("rejecting script message from unknown sender")
222 return199 return
223 }200 }
201+ if r.handleAllowlistedBridgeMessage(senderWV, msg) {
202+ return
203+ }
224 if !isTrustedBridgeWebView(senderWV) {204 if !isTrustedBridgeWebView(senderWV) {
225 log.Warn().205 log.Warn().
226 Str("type", msg.Type).206 Str("type", msg.Type).
@@ -279,6 +259,68 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
279 }259 }
280 }260 }
281 261
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+
282 func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {324 func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
283 ptr := senderUCM.GoPointer()325 ptr := senderUCM.GoPointer()
284 if ptr == 0 {326 if ptr == 0 {
M internal/infrastructure/webkit/messaging_test.go +53 -0
@@ -2,9 +2,11 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 8
9+ "github.com/bnema/dumber/internal/application/port"
8 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
9 )11 )
10 12
@@ -35,6 +37,57 @@ func TestMessageRouterBaseContextConcurrentUpdate(t *testing.T) {
35 wg.Wait()37 wg.Wait()
36 }38 }
37 39
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+
38 func TestIsTrustedBridgeURI(t *testing.T) {91 func TestIsTrustedBridgeURI(t *testing.T) {
39 t.Parallel()92 t.Parallel()
40 93
M internal/infrastructure/webkit/pool.go +18 -1
@@ -178,7 +178,17 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
178 }178 }
179 179
180 // Ensure frontend is attached even if this WebView was pooled before injection was configured.180 // 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+ }
182 // Apply filters if available (may not have been applied during prewarm)192 // Apply filters if available (may not have been applied during prewarm)
183 if p.filterApplier != nil {193 if p.filterApplier != nil {
184 p.filterApplier.ApplyTo(ctx, wv.ucm)194 p.filterApplier.ApplyTo(ctx, wv.ucm)
@@ -240,6 +250,13 @@ func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuP
240 return wv != nil && pipeline != nil && len(wv.signalIDs) > 0250 return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
241 }251 }
242 252
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+
243 func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool {260 func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool {
244 return settings != nil && wv != nil && wv.inner != nil261 return settings != nil && wv != nil && wv.inner != nil
245 }262 }
M internal/infrastructure/webkit/pool_policy_test.go +5 -0
@@ -16,3 +16,8 @@ func TestResolveReusePolicy(t *testing.T) {
16 t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe")16 t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe")
17 assert.Equal(t, reusePolicySafe, resolveReusePolicy(""))17 assert.Equal(t, reusePolicySafe, resolveReusePolicy(""))
18 }18 }
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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/rand"
6+ "crypto/subtle"
7+ "encoding/hex"
5 "fmt"8 "fmt"
6 "net/url"9 "net/url"
7 "os"10 "os"
@@ -16,6 +19,7 @@ import (
16 "github.com/bnema/dumber/internal/domain/entity"19 "github.com/bnema/dumber/internal/domain/entity"
17 urlutil "github.com/bnema/dumber/internal/domain/url"20 urlutil "github.com/bnema/dumber/internal/domain/url"
18 "github.com/bnema/dumber/internal/infrastructure/desktop"21 "github.com/bnema/dumber/internal/infrastructure/desktop"
22+ "github.com/bnema/dumber/internal/infrastructure/webutil"
19 "github.com/bnema/dumber/internal/logging"23 "github.com/bnema/dumber/internal/logging"
20 "github.com/bnema/puregotk/v4/gdk"24 "github.com/bnema/puregotk/v4/gdk"
21 "github.com/bnema/puregotk/v4/gio"25 "github.com/bnema/puregotk/v4/gio"
@@ -34,6 +38,26 @@ var _ port.Printer = (*WebView)(nil)
34 var _ port.PopupLifecycleCapable = (*WebView)(nil)38 var _ port.PopupLifecycleCapable = (*WebView)(nil)
35 var _ port.PopupFeatureResolver = (*WebView)(nil)39 var _ port.PopupFeatureResolver = (*WebView)(nil)
36 var _ port.OAuthCallbackCapable = (*WebView)(nil)40 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+}
37 61
38 // WebViewID is an alias to port.WebViewID for clean architecture compliance.62 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
39 // Infrastructure layer uses the type defined in the application port.63 // Infrastructure layer uses the type defined in the application port.
@@ -163,6 +187,7 @@ type WebView struct {
163 OnEnterFullscreen func() bool // Return true to prevent fullscreen187 OnEnterFullscreen func() bool // Return true to prevent fullscreen
164 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen188 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
165 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops189 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
190+ OnEditableFocusChanged func(editable bool) // Called when page editable focus changes
166 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)191 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
167 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)192 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
168 browsingContextDecision dto.HostDecision193 browsingContextDecision dto.HostDecision
@@ -179,8 +204,9 @@ type WebView struct {
179 logger zerolog.Logger204 logger zerolog.Logger
180 mu sync.RWMutex205 mu sync.RWMutex
181 206
182- frontendAttached atomic.Bool207+ frontendAttached atomic.Bool
183- navigationActive atomic.Bool208+ navigationActive atomic.Bool
209+ editableFocusBridgeToken string
184 210
185 // asyncCallbacks keeps references to async JS callbacks to prevent GC211 // asyncCallbacks keeps references to async JS callbacks to prevent GC
186 asyncCallbacks []any212 asyncCallbacks []any
@@ -346,11 +372,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
346 }372 }
347 373
348 wv := &WebView{374 wv := &WebView{
349- inner: inner,375+ inner: inner,
350- ucm: inner.GetUserContentManager(),376+ ucm: inner.GetUserContentManager(),
351- logger: log.With().Str("component", "webview").Logger(),377+ logger: log.With().Str("component", "webview").Logger(),
352- signalIDs: make([]uintptr, 0, 4),378+ signalIDs: make([]uintptr, 0, 4),
353- runJSErrorStats: make(map[string]runJSErrorStat),379+ runJSErrorStats: make(map[string]runJSErrorStat),
380+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
354 }381 }
355 382
356 // Register in global registry383 // Register in global registry
@@ -396,12 +423,13 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti
396 Msg("related webview created, checking pointers")423 Msg("related webview created, checking pointers")
397 424
398 wv := &WebView{425 wv := &WebView{
399- inner: inner,426+ inner: inner,
400- isRelated: true, // Shares web process with parent - must not terminate process on destroy427+ isRelated: true, // Shares web process with parent - must not terminate process on destroy
401- ucm: inner.GetUserContentManager(),428+ ucm: inner.GetUserContentManager(),
402- logger: log.With().Str("component", "webview-popup").Logger(),429+ logger: log.With().Str("component", "webview-popup").Logger(),
403- signalIDs: make([]uintptr, 0, 6),430+ signalIDs: make([]uintptr, 0, 6),
404- runJSErrorStats: make(map[string]runJSErrorStat),431+ runJSErrorStats: make(map[string]runJSErrorStat),
432+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
405 }433 }
406 434
407 wv.id = globalRegistry.register(wv)435 wv.id = globalRegistry.register(wv)
@@ -448,37 +476,49 @@ func (wv *WebView) connectLoadChangedSignal() {
448 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {476 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
449 uri := inner.GetUri()477 uri := inner.GetUri()
450 title := inner.GetTitle()478 title := inner.GetTitle()
451-479+ progress := inner.GetEstimatedLoadProgress()
452- wv.mu.Lock()480+ wv.handleLoadChanged(LoadEvent(event), uri, title, progress)
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- }
477 }481 }
478 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)482 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
479 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))483 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
480 }484 }
481 485
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+
482 func (wv *WebView) connectLoadFailedSignal() {522 func (wv *WebView) connectLoadFailedSignal() {
483 loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool {523 loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool {
484 wv.logger.Warn().524 wv.logger.Warn().
@@ -1762,6 +1802,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1762 wv.OnEnterFullscreen = nil1802 wv.OnEnterFullscreen = nil
1763 wv.OnLeaveFullscreen = nil1803 wv.OnLeaveFullscreen = nil
1764 wv.OnAudioStateChanged = nil1804 wv.OnAudioStateChanged = nil
1805+ wv.OnEditableFocusChanged = nil
1765 return1806 return
1766 }1807 }
1767 1808
@@ -1807,6 +1848,16 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1807 wv.OnEnterFullscreen = callbacks.OnEnterFullscreen1848 wv.OnEnterFullscreen = callbacks.OnEnterFullscreen
1808 wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen1849 wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen
1809 wv.OnAudioStateChanged = callbacks.OnAudioStateChanged1850 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+ }
1810 }1861 }
1811 1862
1812 // ShowDevTools opens the WebKit inspector/developer tools.1863 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1999,6 +2050,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
1999 wv.OnEnterFullscreen = nil2050 wv.OnEnterFullscreen = nil
2000 wv.OnLeaveFullscreen = nil2051 wv.OnLeaveFullscreen = nil
2001 wv.OnAudioStateChanged = nil2052 wv.OnAudioStateChanged = nil
2053+ wv.OnEditableFocusChanged = nil
2002 wv.OnLinkHover = nil2054 wv.OnLinkHover = nil
2003 wv.OnWebProcessTerminated = nil2055 wv.OnWebProcessTerminated = nil
2004 wv.OnPermissionRequest = nil2056 wv.OnPermissionRequest = nil
@@ -2047,6 +2099,7 @@ func (wv *WebView) ResetForPoolReuse() {
2047 return2099 return
2048 }2100 }
2049 wv.generation.Add(1)2101 wv.generation.Add(1)
2102+ wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken())
2050 2103
2051 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.2104 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.
2052 // They will be reconnected when the WebView is re-acquired from the pool.2105 // They will be reconnected when the WebView is re-acquired from the pool.
@@ -2064,6 +2117,7 @@ func (wv *WebView) ResetForPoolReuse() {
2064 wv.OnEnterFullscreen = nil2117 wv.OnEnterFullscreen = nil
2065 wv.OnLeaveFullscreen = nil2118 wv.OnLeaveFullscreen = nil
2066 wv.OnAudioStateChanged = nil2119 wv.OnAudioStateChanged = nil
2120+ wv.OnEditableFocusChanged = nil
2067 wv.OnLinkHover = nil2121 wv.OnLinkHover = nil
2068 wv.OnWebProcessTerminated = nil2122 wv.OnWebProcessTerminated = nil
2069 wv.OnPermissionRequest = nil2123 wv.OnPermissionRequest = nil
@@ -2376,3 +2430,61 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
2376 log.Debug().Msg("frontend assets attached to webview")2430 log.Debug().Msg("frontend assets attached to webview")
2377 return nil2431 return nil
2378 }2432 }
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 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
6+ "fmt"
5 "testing"7 "testing"
6 "time"8 "time"
7 9
10+ "github.com/bnema/dumber/internal/application/port"
8 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
9 )13 )
10 14
11 func TestClassifyRunJSEvaluateError(t *testing.T) {15 func TestClassifyRunJSEvaluateError(t *testing.T) {
@@ -87,3 +91,104 @@ func TestShouldLogRunJSError_FatalAlwaysLogs(t *testing.T) {
87 assert.True(t, shouldLog)91 assert.True(t, shouldLog)
88 assert.Equal(t, uint64(2), count)92 assert.Equal(t, uint64(2), count)
89 }93 }
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 (
52 floatingPaneIDPrefix = "floating-pane:"52 floatingPaneIDPrefix = "floating-pane:"
53 floatingSessionIDDefault = "default"53 floatingSessionIDDefault = "default"
54 floatingPaneVisibleClass = "floating-pane-visible"54 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
55 )61 )
56 62
57 func gtkApplicationFlags() gio.ApplicationFlags {63 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -117,6 +123,10 @@ type App struct {
117 // Focus tracking stays app-global.123 // Focus tracking stays app-global.
118 focusMgr *focus.Manager124 focusMgr *focus.Manager
119 125
126+ vimModePolicyUC *usecase.VimModePolicyUseCase
127+ vimNavigationUC *usecase.VimNavigationUseCase
128+ pageEditableFocusByPane map[entity.PaneID]bool
129+
120 resizeModeBorderTarget layout.Widget130 resizeModeBorderTarget layout.Widget
121 131
122 // Omnibox configuration (omnibox is created per workspace view)132 // Omnibox configuration (omnibox is created per workspace view)
@@ -150,6 +160,12 @@ type App struct {
150 // Accent picker for dead keys support160 // Accent picker for dead keys support
151 accentFocusProvider port.FocusedInputProvider161 accentFocusProvider port.FocusedInputProvider
152 162
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+
153 // Deferred initialization - runs after first load_started to avoid blocking initial navigation169 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
154 deferredInitOnce sync.Once170 deferredInitOnce sync.Once
155 deferredInitFn func()171 deferredInitFn func()
@@ -192,15 +208,18 @@ func New(deps *Dependencies) (*App, error) {
192 ctx, cancel := context.WithCancelCause(deps.Ctx)208 ctx, cancel := context.WithCancelCause(deps.Ctx)
193 209
194 app := &App{210 app := &App{
195- deps: deps,211+ deps: deps,
196- runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig),212+ runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig),
197- tabs: entity.NewTabList(),213+ tabs: entity.NewTabList(),
198- tabsUC: deps.TabsUC,214+ tabsUC: deps.TabsUC,
199- panesUC: deps.PanesUC,215+ panesUC: deps.PanesUC,
200- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),216+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
201- windowForTab: make(map[entity.TabID]*browserWindow),217+ windowForTab: make(map[entity.TabID]*browserWindow),
202- floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),218+ floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
203- browserWindows: make(map[string]*browserWindow),219+ browserWindows: make(map[string]*browserWindow),
220+ vimModePolicyUC: usecase.NewVimModePolicyUseCase(),
221+ vimNavigationUC: usecase.NewVimNavigationUseCase(),
222+ pageEditableFocusByPane: make(map[entity.PaneID]bool),
204 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {223 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
205 if fn != nil {224 if fn != nil {
206 fn()225 fn()
@@ -210,6 +229,9 @@ func New(deps *Dependencies) (*App, error) {
210 engine: deps.Engine,229 engine: deps.Engine,
211 cancel: cancel,230 cancel: cancel,
212 }231 }
232+ if deps.Theme != nil {
233+ deps.Theme.SetTransitionDuration(app.runtimeConfigSnapshot().UI.Workspace.Styling.TransitionDuration)
234+ }
213 var autoCopyConfig port.AutoCopyConfig235 var autoCopyConfig port.AutoCopyConfig
214 var clipboardOrchestrator port.ClipboardTextOrchestrator236 var clipboardOrchestrator port.ClipboardTextOrchestrator
215 if deps.Clipboard != nil {237 if deps.Clipboard != nil {
@@ -814,6 +836,9 @@ func (a *App) wireSessionManagerShortcut() {
814 return nil836 return nil
815 }837 }
816 bw.sessionManager.Toggle(ctx)838 bw.sessionManager.Toggle(ctx)
839+ if bw.sessionManager.IsVisible() {
840+ a.handleVimModeFocusTrigger(ctx, bw, usecase.VimModePolicyTriggerOverlayFocus)
841+ }
817 return nil842 return nil
818 })843 })
819 }844 }
@@ -1065,13 +1090,24 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1065 }1090 }
1066 return a.dispatchBrowserWindowAction(actionCtx, bw, action)1091 return a.dispatchBrowserWindowAction(actionCtx, bw, action)
1067 })1092 })
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+ })
1068 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {1097 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
1069 a.activateBrowserWindow(bw)1098 a.activateBrowserWindow(bw)
1070 return a.handleGlobalEscape(escapeCtx)1099 return a.handleGlobalEscape(escapeCtx)
1071 })1100 })
1072 bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {1101 bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
1073 a.activateBrowserWindow(bw)1102 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)
1075 })1111 })
1076 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {1112 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
1077 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {1113 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
@@ -1099,6 +1135,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1099 1135
1100 return input.RouteHandleShortcuts1136 return input.RouteHandleShortcuts
1101 })1137 })
1138+ bw.keyboardHandler.SetVimModeActivationPassthrough(func() bool {
1139+ return a.shouldBypassVimModeActivation(bw)
1140+ })
1102 bw.keyboardHandler.SetAccentHandler(a)1141 bw.keyboardHandler.SetAccentHandler(a)
1103 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())1142 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())
1104 1143
@@ -1339,6 +1378,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
1339 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))1378 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
1340 }1379 }
1341 }1380 }
1381+ a.handleVimModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.VimModePolicyTriggerOmniboxFocus)
1342 },1382 },
1343 OnFocusOut: func() {1383 OnFocusOut: func() {
1344 // When omnibox loses focus, set WebView as the focused input1384 // When omnibox loses focus, set WebView as the focused input
@@ -1377,6 +1417,7 @@ func (a *App) initFindBarConfig(ctx context.Context) {
1377 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))1417 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
1378 }1418 }
1379 }1419 }
1420+ a.handleVimModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.VimModePolicyTriggerFindBarFocus)
1380 },1421 },
1381 OnFocusOut: func() {1422 OnFocusOut: func() {
1382 // When find bar loses focus, set WebView as the focused input1423 // When find bar loses focus, set WebView as the focused input
@@ -1400,6 +1441,9 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
1400 return1441 return
1401 }1442 }
1402 bw.sessionManager.Toggle(ctx)1443 bw.sessionManager.Toggle(ctx)
1444+ if bw.sessionManager.IsVisible() {
1445+ a.handleVimModeFocusTrigger(ctx, bw, usecase.VimModePolicyTriggerOverlayFocus)
1446+ }
1403 }1447 }
1404 1448
1405 func (a *App) attachTabPickerToActivePane() {1449 func (a *App) attachTabPickerToActivePane() {
@@ -1498,6 +1542,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
1498 1542
1499 a.attachTabPickerToActivePane()1543 a.attachTabPickerToActivePane()
1500 bw.tabPicker.Show(ctx, items)1544 bw.tabPicker.Show(ctx, items)
1545+ a.handleVimModeFocusTrigger(ctx, bw, usecase.VimModePolicyTriggerOverlayFocus)
1501 return nil1546 return nil
1502 }1547 }
1503 1548
@@ -2123,6 +2168,111 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
2123 return paneID, a.contentCoord.GetWebView(paneID)2168 return paneID, a.contentCoord.GetWebView(paneID)
2124 }2169 }
2125 2170
2171+func (a *App) navigatePageFocus(_ context.Context, bw *browserWindow, backward bool) bool {
2172+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2173+ if paneID == "" || !a.pageEditableFocused(paneID) {
2174+ return false
2175+ }
2176+ navigationUC := a.vimNavigationUseCase()
2177+ if navigationUC == nil {
2178+ return false
2179+ }
2180+ return navigationUC.NavigatePageFocus(wv, backward)
2181+}
2182+
2183+func (a *App) vimNavigationUseCase() *usecase.VimNavigationUseCase {
2184+ if a == nil {
2185+ return nil
2186+ }
2187+ if a.vimNavigationUC == nil {
2188+ a.vimNavigationUC = usecase.NewVimNavigationUseCase()
2189+ }
2190+ return a.vimNavigationUC
2191+}
2192+
2193+func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {
2194+ _, wv := a.activeWebViewForBrowserWindow(bw)
2195+ navigationUC := a.vimNavigationUseCase()
2196+ if navigationUC == nil {
2197+ return
2198+ }
2199+ if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil {
2200+ logging.FromContext(ctx).Debug().
2201+ Err(err).
2202+ Str("action", action).
2203+ Msg("vim navigation unavailable")
2204+ 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+
2126 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes2276 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
2127 // navigation through navigate for the supplied browser window. The closure captures2277 // navigation through navigate for the supplied browser window. The closure captures
2128 // bw at creation time, so navigation targets the owning window even when another2278 // bw at creation time, so navigation targets the owning window even when another
@@ -3072,6 +3222,7 @@ func (a *App) initTabCoordinator(ctx context.Context) {
3072 // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.3222 // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.
3073 if bw := a.browserWindowForTabTarget(target); bw != nil {3223 if bw := a.browserWindowForTabTarget(target); bw != nil {
3074 a.activateBrowserWindow(bw)3224 a.activateBrowserWindow(bw)
3225+ a.handleVimModeTabSwitch(ctx, bw)
3075 }3226 }
3076 a.switchWorkspaceView(ctx, tab.ID)3227 a.switchWorkspaceView(ctx, tab.ID)
3077 })3228 })
@@ -3209,8 +3360,13 @@ func (a *App) initCoordinators(ctx context.Context) {
3209 a.deps.HistoryRecorderUC,3360 a.deps.HistoryRecorderUC,
3210 a.contentCoord,3361 a.contentCoord,
3211 )3362 )
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())
3212 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {3367 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
3213 a.navCoord.ClearPaneHistory(paneID)3368 a.navCoord.ClearPaneHistory(paneID)
3369+ a.clearPageEditableFocusState(paneID)
3214 })3370 })
3215 3371
3216 // Wire title updates to history persistence3372 // Wire title updates to history persistence
@@ -3220,6 +3376,7 @@ func (a *App) initCoordinators(ctx context.Context) {
3220 3376
3221 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)3377 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
3222 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {3378 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
3379+ a.clearPageEditableFocusState(paneID)
3223 a.navCoord.RecordHistory(ctx, paneID, url)3380 a.navCoord.RecordHistory(ctx, paneID, url)
3224 })3381 })
3225 3382
@@ -3235,6 +3392,9 @@ func (a *App) initCoordinators(ctx context.Context) {
3235 // Mark dirty so snapshot captures the new URI3392 // Mark dirty so snapshot captures the new URI
3236 a.MarkDirty()3393 a.MarkDirty()
3237 })3394 })
3395+ a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
3396+ a.handlePageEditableFocusChanged(ctx, paneID, editable)
3397+ })
3238 3398
3239 // Hide loading skeleton once the WebView paints3399 // Hide loading skeleton once the WebView paints
3240 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {3400 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
@@ -3253,6 +3413,7 @@ func (a *App) initCoordinators(ctx context.Context) {
3253 a.deps.OnFirstWebViewShown(ctx)3413 a.deps.OnFirstWebViewShown(ctx)
3254 })3414 })
3255 }3415 }
3416+ a.preloadAccessibilityForShownPane(paneID)
3256 3417
3257 // This only updates focus when the shown pane belongs to the last-focused3418 // This only updates focus when the shown pane belongs to the last-focused
3258 // window's active workspace. activeWorkspace() delegates to3419 // window's active workspace. activeWorkspace() delegates to
@@ -3347,6 +3508,9 @@ func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bo
3347 3508
3348 func (a *App) wireKeyboardActions() {3509 func (a *App) wireKeyboardActions() {
3349 a.kbDispatcher.SetOnQuit(a.Quit)3510 a.kbDispatcher.SetOnQuit(a.Quit)
3511+ a.kbDispatcher.SetOnVimModePulse(func(ctx context.Context, fast bool) {
3512+ a.triggerVimModePulse(ctx, fast)
3513+ })
3350 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {3514 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
3351 a.ToggleFindBar(ctx)3515 a.ToggleFindBar(ctx)
3352 return nil3516 return nil
@@ -3678,8 +3842,120 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
3678 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))3842 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
3679 }3843 }
3680 3844
3681-// handleModeChange is called when the input mode changes.3845+func (a *App) vimModePolicy() *usecase.VimModePolicyUseCase {
3682-func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {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) {
3683 log := logging.FromContext(ctx)3959 log := logging.FromContext(ctx)
3684 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")3960 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
3685 3961
@@ -3691,42 +3967,250 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
3691 a.applyResizeModeBorder(ctx, a.activeWorkspace())3967 a.applyResizeModeBorder(ctx, a.activeWorkspace())
3692 }3968 }
3693 3969
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+
3694 // Update global border overlay visibility based on mode.3981 // Update global border overlay visibility based on mode.
3695 // Note: resize mode border is handled per-pane (stack container), not via global overlay.3982 // 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 {
3697 bw.borderMgr.OnModeChange(ctx, from, to)3985 bw.borderMgr.OnModeChange(ctx, from, to)
3698 }3986 }
3699 3987
3700- // Show/hide mode indicator toaster based on config.3988+ // Show/hide this window's mode indicator toaster based on mode and config.
3701- a.updateModeIndicatorToaster(ctx, to)3989+ a.updateModeIndicatorToaster(ctx, bw, to)
3702 }3990 }
3703 3991
3704-// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.3992+// transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent
3705-func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {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+
3706 bw := a.lastFocusedBrowserWindow()4106 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) {
3707 if bw == nil || bw.modeToaster == nil {4153 if bw == nil || bw.modeToaster == nil {
3708 return4154 return
3709 }4155 }
3710 4156
3711- // Check if mode indicator toaster is enabled in config.4157+ if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal {
3712- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
3713 bw.modeToaster.Hide()4158 bw.modeToaster.Hide()
3714 return4159 return
3715 }4160 }
3716 4161
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 {
3718 bw.modeToaster.Hide()4195 bw.modeToaster.Hide()
4196+ log.Debug().Str("window_id", bw.id).Msg("vim mode pending toaster hidden (disabled)")
3719 return4197 return
3720 }4198 }
3721 4199
3722- // Show persistent toaster at bottom-left with mode display name.4200+ text := input.ModeVim.DisplayName()
3723- // Mode class is applied atomically with Show() to avoid visual flicker.4201+ if pending != "" {
3724- modeClass := getModeToastClass(mode)4202+ text = text + " · " + pending
3725- bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,4203+ }
3726- component.WithDuration(0), // Persistent until mode exits.4204+ bw.modeToaster.Show(ctx, text, component.ToastInfo,
4205+ component.WithDuration(0),
3727 component.WithPosition(component.ToastPositionBottomLeft),4206 component.WithPosition(component.ToastPositionBottomLeft),
3728- component.WithModeClass(modeClass),4207+ component.WithModeClass(getModeToastClass(input.ModeVim)),
3729 )4208 )
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")
3730 }4214 }
3731 4215
3732 // getModeToastClass returns the CSS class for the given mode's toast styling.4216 // getModeToastClass returns the CSS class for the given mode's toast styling.
@@ -3734,6 +4218,8 @@ func getModeToastClass(mode input.Mode) string {
3734 switch mode {4218 switch mode {
3735 case input.ModePane:4219 case input.ModePane:
3736 return "toast-pane-mode"4220 return "toast-pane-mode"
4221+ case input.ModeVim:
4222+ return "toast-vim-mode"
3737 case input.ModeTab:4223 case input.ModeTab:
3738 return "toast-tab-mode"4224 return "toast-tab-mode"
3739 case input.ModeSession:4225 case input.ModeSession:
@@ -3843,6 +4329,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
3843 })4329 })
3844 wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {4330 wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {
3845 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")4331 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
4332+ if bw := a.browserWindowForTab(tab.ID); bw != nil {
4333+ a.transferVimModeOwnershipToPane(ctx, bw, paneID)
4334+ }
3846 })4335 })
3847 }4336 }
3848 4337
@@ -4965,6 +5454,11 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
4965 if bw.globalShortcutHandler != nil {5454 if bw.globalShortcutHandler != nil {
4966 bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)5455 bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)
4967 }5456 }
5457+ mode := input.ModeNormal
5458+ if bw.keyboardHandler != nil {
5459+ mode = bw.keyboardHandler.Mode()
5460+ }
5461+ a.updateModeIndicatorToaster(ctx, bw, mode)
4968 }5462 }
4969 }5463 }
4970 5464
@@ -5033,6 +5527,7 @@ func (a *App) applyThemeAppearance(ctx context.Context) {
5033 if resolveThemeUC == nil {5527 if resolveThemeUC == nil {
5034 resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource)5528 resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource)
5035 }5529 }
5530+ a.deps.Theme.SetTransitionDuration(workspaceStyling.TransitionDuration)
5036 resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig(5531 resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig(
5037 &appearanceCfg,5532 &appearanceCfg,
5038 runtimeCfg.DefaultUIScale,5533 runtimeCfg.DefaultUIScale,
M internal/ui/app_browser_launch_test.go +57 -14
@@ -35,7 +35,9 @@ import (
35 "github.com/bnema/dumber/internal/ui/window"35 "github.com/bnema/dumber/internal/ui/window"
36 "github.com/bnema/puregotk/v4/gdk"36 "github.com/bnema/puregotk/v4/gdk"
37 "github.com/bnema/puregotk/v4/gio"37 "github.com/bnema/puregotk/v4/gio"
38+ "github.com/bnema/puregotk/v4/glib"
38 "github.com/bnema/puregotk/v4/gtk"39 "github.com/bnema/puregotk/v4/gtk"
40+ "github.com/stretchr/testify/assert"
39 "github.com/stretchr/testify/mock"41 "github.com/stretchr/testify/mock"
40 "github.com/stretchr/testify/require"42 "github.com/stretchr/testify/require"
41 )43 )
@@ -45,6 +47,10 @@ type testBrowserLaunchRelay struct {
45 closer *testCloser47 closer *testCloser
46 }48 }
47 49
50+func (r *testBrowserLaunchRelay) DeliverOpenExternalURL(context.Context, string) (bool, error) {
51+ return false, nil
52+}
53+
48 func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {54 func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
49 return false, nil55 return false, nil
50 }56 }
@@ -957,6 +963,10 @@ func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
957 return reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().IsNil()963 return reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().IsNil()
958 }964 }
959 965
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.
960 func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {970 func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {
961 t.Helper()971 t.Helper()
962 972
@@ -979,7 +989,9 @@ func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBox
979 label.EXPECT().SetCanTarget(false).Once()989 label.EXPECT().SetCanTarget(false).Once()
980 label.EXPECT().SetCanFocus(false).Once()990 label.EXPECT().SetCanFocus(false).Once()
981 991
982- return component.NewToaster(factory), box, label992+ toaster := component.NewToaster(factory)
993+ t.Cleanup(toaster.CancelAutoDismiss)
994+ return toaster, box, label
983 }995 }
984 996
985 func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {997 func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
@@ -987,6 +999,9 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
987 toaster, box, label := newTestShellToaster(t)999 toaster, box, label := newTestShellToaster(t)
988 box.EXPECT().SetVisible(true).Once()1000 box.EXPECT().SetVisible(true).Once()
989 label.EXPECT().SetText("Ad blocker loading").Once()1001 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()
990 1005
991 bw := &browserWindow{id: "window-1", appToaster: toaster}1006 bw := &browserWindow{id: "window-1", appToaster: toaster}
992 app := &App{1007 app := &App{
@@ -995,6 +1010,30 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
995 }1010 }
996 1011
997 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})1012 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")
998 }1037 }
999 1038
1000 func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {1039 func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
@@ -1002,6 +1041,9 @@ func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.
1002 toaster, box, label := newTestShellToaster(t)1041 toaster, box, label := newTestShellToaster(t)
1003 box.EXPECT().SetVisible(true).Once()1042 box.EXPECT().SetVisible(true).Once()
1004 label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()1043 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()
1005 1047
1006 migrator := portmocks.NewMockConfigMigrator(t)1048 migrator := portmocks.NewMockConfigMigrator(t)
1007 migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()1049 migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()
@@ -1020,6 +1062,7 @@ func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.
1020 }1062 }
1021 1063
1022 app.checkConfigMigration(ctx)1064 app.checkConfigMigration(ctx)
1065+ toaster.Hide()
1023 }1066 }
1024 1067
1025 func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {1068 func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
@@ -1061,7 +1104,7 @@ func TestApp_GetWindowSnapshotStateReturnsUnavailableWhenMainThreadDispatchTimes
1061 require.Equal(t, -1, activeWindowIndex)1104 require.Equal(t, -1, activeWindowIndex)
1062 }1105 }
1063 1106
1064-func TestApp_OpenFreshWindowReportsMainThreadDispatchTimeout(t *testing.T) {1107+func TestApp_OpenExternalURLReportsMainThreadDispatchTimeout(t *testing.T) {
1065 var factoryCalls int1108 var factoryCalls int
1066 app := &App{1109 app := &App{
1067 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {1110 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
@@ -1073,20 +1116,20 @@ func TestApp_OpenFreshWindowReportsMainThreadDispatchTimeout(t *testing.T) {
1073 },1116 },
1074 }1117 }
1075 1118
1076- err := app.OpenFreshWindow(context.Background(), "https://example.com")1119+ err := app.OpenExternalURL(context.Background(), "https://example.com")
1077 1120
1078 if err == nil {1121 if err == nil {
1079- t.Fatal("OpenFreshWindow returned nil error, want dispatch timeout")1122+ t.Fatal("OpenExternalURL returned nil error, want dispatch timeout")
1080 }1123 }
1081 if !strings.Contains(err.Error(), "main thread dispatch did not complete") {1124 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())
1083 }1126 }
1084 if factoryCalls != 0 {1127 if factoryCalls != 0 {
1085 t.Fatalf("browserWindowFactory calls = %d, want 0", factoryCalls)1128 t.Fatalf("browserWindowFactory calls = %d, want 0", factoryCalls)
1086 }1129 }
1087 }1130 }
1088 1131
1089-func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {1132+func TestApp_OpenExternalURLRecordsTabOwnership(t *testing.T) {
1090 existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))1133 existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
1091 existingTabs := entity.NewTabList()1134 existingTabs := entity.NewTabList()
1092 existingTabs.Add(existingTab)1135 existingTabs.Add(existingTab)
@@ -1104,8 +1147,8 @@ func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
1104 },1147 },
1105 }1148 }
1106 1149
1107- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {1150+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1108- t.Fatalf("OpenFreshWindow returned error: %v", err)1151+ t.Fatalf("OpenExternalURL returned error: %v", err)
1109 }1152 }
1110 if got := windowForTabCount(t, app); got != 2 {1153 if got := windowForTabCount(t, app); got != 2 {
1111 t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)1154 t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
@@ -1353,7 +1396,7 @@ func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T
1353 }1396 }
1354 }1397 }
1355 1398
1356-func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {1399+func TestApp_OpenExternalURLRollsBackOnTabCreationFailure(t *testing.T) {
1357 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}1400 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
1358 originalWindow := &window.MainWindow{}1401 originalWindow := &window.MainWindow{}
1359 tabBar := &component.TabBar{}1402 tabBar := &component.TabBar{}
@@ -1384,8 +1427,8 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
1384 MainWindow: &window.MainWindow{},1427 MainWindow: &window.MainWindow{},
1385 })1428 })
1386 1429
1387- if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {1430+ if err := app.OpenExternalURL(context.Background(), "https://example.com/fail"); err == nil {
1388- t.Fatalf("OpenFreshWindow = nil error, want failure")1431+ t.Fatalf("OpenExternalURL = nil error, want failure")
1389 }1432 }
1390 if got := len(app.browserWindows); got != 1 {1433 if got := len(app.browserWindows); got != 1 {
1391 t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)1434 t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
@@ -1404,7 +1447,7 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
1404 }1447 }
1405 }1448 }
1406 1449
1407-func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {1450+func TestApp_OpenExternalURLTargetsNewWindowTabBar(t *testing.T) {
1408 existingTabID := entity.TabID("existing-tab")1451 existingTabID := entity.TabID("existing-tab")
1409 createdTabID := entity.TabID("id-1")1452 createdTabID := entity.TabID("id-1")
1410 oldWindow := &window.MainWindow{}1453 oldWindow := &window.MainWindow{}
@@ -1441,8 +1484,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
1441 app.workspaceViews[tab.ID] = &component.WorkspaceView{}1484 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
1442 })1485 })
1443 1486
1444- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {1487+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1445- t.Fatalf("OpenFreshWindow returned error: %v", err)1488+ t.Fatalf("OpenExternalURL returned error: %v", err)
1446 }1489 }
1447 1490
1448 gotCreatedTabID := app.tabs.ActiveTabID1491 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 (
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ domainurl "github.com/bnema/dumber/internal/domain/url"
12 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/shared/syncdispatch"14 "github.com/bnema/dumber/internal/shared/syncdispatch"
14 "github.com/bnema/dumber/internal/ui/component"15 "github.com/bnema/dumber/internal/ui/component"
@@ -52,6 +53,13 @@ type browserWindow struct {
52 historySidebarReloader historySidebarReloader53 historySidebarReloader historySidebarReloader
53 sidebarVisible bool54 sidebarVisible bool
54 activeSidebarKind nativeSidebarKind55 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{}
55 }63 }
56 64
57 func (bw *browserWindow) detachInputForDestroy() {65 func (bw *browserWindow) detachInputForDestroy() {
@@ -108,6 +116,9 @@ func (bw *browserWindow) clearShellState() {
108 bw.favoritesSidebar = nil116 bw.favoritesSidebar = nil
109 bw.historySidebarReloader = nil117 bw.historySidebarReloader = nil
110 bw.activeSidebarKind = nativeSidebarNone118 bw.activeSidebarKind = nativeSidebarNone
119+ bw.vimModePaneID = ""
120+ bw.vimNavigationHighlightedWebViews = nil
121+ bw.vimNavigationHighlightedWebViewIDs = nil
111 }122 }
112 123
113 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {124 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
@@ -544,11 +555,9 @@ func (a *App) deterministicBrowserWindowFallback() *browserWindow {
544 return a.browserWindows[ids[0]]555 return a.browserWindows[ids[0]]
545 }556 }
546 557
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 {
548 log := logging.FromContext(ctx)559 log := logging.FromContext(ctx)
549- log.Debug().560+ log.Debug().Str("url_host", logging.SafeURLHost(url)).Str("dispatch_label", label).Msg("ui: window URL dispatch requested")
550- Str("url_host", logging.SafeURLHost(url)).
551- Msg("ui: open fresh window dispatch requested")
552 561
553 dispatch := a.dispatchOnMainThread562 dispatch := a.dispatchOnMainThread
554 if dispatch == nil {563 if dispatch == nil {
@@ -560,47 +569,234 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
560 }569 }
561 }570 }
562 571
563- var openErr error572+ var workErr error
564- var windowCountBefore int573+ var windowCountBefore, windowCountAfter int
565- var windowCountAfter int574+ var hasTabCoord, hasTabsUC bool
566- var hasTabCoord bool575+ result := dispatch(label, func() {
567- var hasTabsUC bool
568- result := dispatch("ui.open_fresh_window", func() {
569 windowCountBefore = len(a.browserWindows)576 windowCountBefore = len(a.browserWindows)
570 hasTabCoord = a.tabCoord != nil577 hasTabCoord = a.tabCoord != nil
571 hasTabsUC = a.tabsUC != nil578 hasTabsUC = a.tabsUC != nil
572 log.Debug().579 log.Debug().
573 Str("url_host", logging.SafeURLHost(url)).580 Str("url_host", logging.SafeURLHost(url)).
581+ Str("dispatch_label", label).
574 Int("window_count_before", windowCountBefore).582 Int("window_count_before", windowCountBefore).
575 Bool("has_tab_coord", hasTabCoord).583 Bool("has_tab_coord", hasTabCoord).
576 Bool("has_tabs_uc", hasTabsUC).584 Bool("has_tabs_uc", hasTabsUC).
577- Msg("ui: open fresh window main-thread work started")585+ Msg("ui: window URL main-thread work started")
578- openErr = a.openFreshWindow(ctx, url)586+ workErr = work(ctx)
579 windowCountAfter = len(a.browserWindows)587 windowCountAfter = len(a.browserWindows)
580 })588 })
581 if !result.Completed() {589 if !result.Completed() {
582 log.Warn().590 log.Warn().
583 Str("url_host", logging.SafeURLHost(url)).591 Str("url_host", logging.SafeURLHost(url)).
592+ Str("dispatch_label", label).
584 Dur("elapsed", result.Elapsed).593 Dur("elapsed", result.Elapsed).
585 Str("dispatch_status", string(result.Status)).594 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")
587 return fmt.Errorf("main thread dispatch did not complete: %s", result.Status)596 return fmt.Errorf("main thread dispatch did not complete: %s", result.Status)
588 }597 }
589- if openErr != nil {598+ if workErr != nil {
590- log.Warn().Err(openErr).599+ log.Warn().Err(workErr).
591 Str("url_host", logging.SafeURLHost(url)).600 Str("url_host", logging.SafeURLHost(url)).
601+ Str("dispatch_label", label).
592 Dur("elapsed", result.Elapsed).602 Dur("elapsed", result.Elapsed).
593 Str("dispatch_status", string(result.Status)).603 Str("dispatch_status", string(result.Status)).
594 Int("window_count_after", windowCountAfter).604 Int("window_count_after", windowCountAfter).
595- Msg("ui: open fresh window failed")605+ Msg("ui: window URL operation failed")
596- return openErr606+ return workErr
597 }607 }
598-
599 log.Debug().608 log.Debug().
600 Str("url_host", logging.SafeURLHost(url)).609 Str("url_host", logging.SafeURLHost(url)).
610+ Str("dispatch_label", label).
601 Dur("elapsed", result.Elapsed).611 Dur("elapsed", result.Elapsed).
602 Str("dispatch_status", string(result.Status)).612 Str("dispatch_status", string(result.Status)).
603 Int("window_count_after", windowCountAfter).613 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+ }
605 return nil801 return nil
606 }802 }
M internal/ui/browser_window_favorites_sidebar.go +9 -2
@@ -119,7 +119,8 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
119 if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {119 if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {
120 return fmt.Errorf("favorites unavailable: usecase not configured")120 return fmt.Errorf("favorites unavailable: usecase not configured")
121 }121 }
122- _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())122+ bw := a.lastFocusedBrowserWindow()
123+ _, wv := a.activeWebViewForBrowserWindow(bw)
123 if wv == nil {124 if wv == nil {
124 return fmt.Errorf("favorites unavailable: no active webview")125 return fmt.Errorf("favorites unavailable: no active webview")
125 }126 }
@@ -127,10 +128,16 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
127 if uri == "" {128 if uri == "" {
128 return fmt.Errorf("favorites unavailable: active page has no URI")129 return fmt.Errorf("favorites unavailable: active page has no URI")
129 }130 }
130- _, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())131+ result, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
131 if err != nil {132 if err != nil {
132 return err133 return err
133 }134 }
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+ }
134 a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle")141 a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle")
135 return nil142 return nil
136 }143 }
M internal/ui/browser_window_test.go +11 -11
@@ -447,15 +447,15 @@ func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testi
447 }447 }
448 }448 }
449 449
450-func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {450+func TestOpenExternalURL_DispatchesAndTracksBrowserWindow(t *testing.T) {
451 app := &App{browserWindows: make(map[string]*browserWindow)}451 app := &App{browserWindows: make(map[string]*browserWindow)}
452 452
453 dispatched := false453 dispatched := false
454 createdURL := ""454 createdURL := ""
455 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {455 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
456 dispatched = true456 dispatched = true
457- if label != "ui.open_fresh_window" {457+ if label != "ui.open_external_url" {
458- t.Fatalf("dispatch label = %q, want ui.open_fresh_window", label)458+ t.Fatalf("dispatch label = %q, want ui.open_external_url", label)
459 }459 }
460 fn()460 fn()
461 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}461 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
@@ -465,11 +465,11 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
465 return &browserWindow{id: "window-1", initialURL: url}, nil465 return &browserWindow{id: "window-1", initialURL: url}, nil
466 }466 }
467 467
468- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {468+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
469- t.Fatalf("OpenFreshWindow returned error: %v", err)469+ t.Fatalf("OpenExternalURL returned error: %v", err)
470 }470 }
471 if !dispatched {471 if !dispatched {
472- t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher")472+ t.Fatalf("OpenExternalURL did not use the main-thread dispatcher")
473 }473 }
474 if createdURL != "https://example.com" {474 if createdURL != "https://example.com" {
475 t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")475 t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
@@ -515,7 +515,7 @@ func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T)
515 require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)515 require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)
516 }516 }
517 517
518-func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {518+func TestOpenExternalURL_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
519 app := &App{519 app := &App{
520 tabs: entity.NewTabList(),520 tabs: entity.NewTabList(),
521 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),521 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
@@ -535,8 +535,8 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *
535 return true535 return true
536 }536 }
537 537
538- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {538+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
539- t.Fatalf("OpenFreshWindow returned error: %v", err)539+ t.Fatalf("OpenExternalURL returned error: %v", err)
540 }540 }
541 541
542 windowTabs := app.browserWindows["window-1"].tabs542 windowTabs := app.browserWindows["window-1"].tabs
@@ -553,7 +553,7 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *
553 require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))553 require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
554 }554 }
555 555
556-func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {556+func TestOpenExternalURL_PropagatesFactoryError(t *testing.T) {
557 app := &App{browserWindows: make(map[string]*browserWindow)}557 app := &App{browserWindows: make(map[string]*browserWindow)}
558 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {558 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
559 fn()559 fn()
@@ -564,7 +564,7 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
564 return nil, wantErr564 return nil, wantErr
565 }565 }
566 566
567- err := app.OpenFreshWindow(context.Background(), "https://example.com")567+ err := app.OpenExternalURL(context.Background(), "https://example.com")
568 568
569 if !errors.Is(err, wantErr) {569 if !errors.Is(err, wantErr) {
570 t.Fatalf("expected error %v, got %v", wantErr, err)570 t.Fatalf("expected error %v, got %v", wantErr, err)
M internal/ui/component/favorites_sidebar.go +148 -40
@@ -13,8 +13,6 @@ import (
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14 )14 )
15 15
16-const favoritesSidebarOuterSpacing = 6
17-
18 // FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar.16 // FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar.
19 type FavoritesSidebarConfig struct {17 type FavoritesSidebarConfig struct {
20 FavoritesUC port.FavoritesSidebarFavorites18 FavoritesUC port.FavoritesSidebarFavorites
@@ -26,22 +24,27 @@ type FavoritesSidebarConfig struct {
26 24
27 // FavoritesSidebar is a read-only GTK sidebar component for browsing favorites.25 // FavoritesSidebar is a read-only GTK sidebar component for browsing favorites.
28 type FavoritesSidebar struct {26 type FavoritesSidebar struct {
29- outerBox *gtk.Box27+ outerBox *gtk.Box
30- searchEntry *gtk.SearchEntry28+ searchEntry *gtk.SearchEntry
31- tagBox *gtk.Box29+ tagBox *gtk.Box
32- scrolledWin *gtk.ScrolledWindow30+ tagControls []*gtk.Button
33- listBox *gtk.ListBox31+ tagPromptBox *gtk.Box
34- formBox *gtk.Box32+ tagNameEntry *gtk.Entry
33+ tagPromptSaveBtn *gtk.Button
34+ scrolledWin *gtk.ScrolledWindow
35+ listBox *gtk.ListBox
36+ formBox *gtk.Box
35 37
36 formURLEntry *gtk.SearchEntry38 formURLEntry *gtk.SearchEntry
37 formTitleEntry *gtk.SearchEntry39 formTitleEntry *gtk.SearchEntry
38- formTagsEntry *gtk.SearchEntry40+ formTagSearch *gtk.SearchEntry
41+ formTagMatches *gtk.Box
39 formShortcutEntry *gtk.SearchEntry42 formShortcutEntry *gtk.SearchEntry
40 formSaveButton *gtk.Button43 formSaveButton *gtk.Button
41 formURL string44 formURL string
42 formTitle string45 formTitle string
43- formTags string
44 formShortcut string46 formShortcut string
47+ formTagIDs map[entity.TagID]struct{}
45 48
46 favoritesUC port.FavoritesSidebarFavorites49 favoritesUC port.FavoritesSidebarFavorites
47 onNavigate func(ctx context.Context, url string) error50 onNavigate func(ctx context.Context, url string) error
@@ -60,16 +63,20 @@ type FavoritesSidebar struct {
60 destroyed bool63 destroyed bool
61 visible bool64 visible bool
62 focusZone favoritesSidebarFocusZone65 focusZone favoritesSidebarFocusZone
66+ textInputWidget *gtk.Widget
63 mode favoritesSidebarMode67 mode favoritesSidebarMode
64 editingID entity.FavoriteID68 editingID entity.FavoriteID
65 confirmDelete bool69 confirmDelete bool
66 confirmDeleteID entity.FavoriteID70 confirmDeleteID entity.FavoriteID
67 71
68- retainedCallbacks []any72+ retainedCallbacks []any
69- tagCallbacks []any73+ tagCallbacks []any
70- ctx context.Context74+ tagPromptCallbacks []any
71- cancel context.CancelFunc75+ formCallbacks []any
72- mu sync.RWMutex76+ formTagMatchCallbacks []any
77+ ctx context.Context
78+ cancel context.CancelFunc
79+ mu sync.RWMutex
73 80
74 idleScheduler func(glib.SourceFunc)81 idleScheduler func(glib.SourceFunc)
75 }82 }
@@ -157,33 +164,12 @@ func (fs *FavoritesSidebar) Destroy() {
157 }164 }
158 165
159 func (fs *FavoritesSidebar) createWidgets() error {166 func (fs *FavoritesSidebar) createWidgets() error {
160- fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, favoritesSidebarOuterSpacing)167+ if err := fs.createOuterAndSearch(); err != nil {
161- if fs.outerBox == nil {168+ return err
162- return fmt.Errorf("favorites sidebar: outer box creation failed")
163 }169 }
164- fs.outerBox.AddCssClass("favorites-sidebar-outer")170+ if err := fs.createTagControls(); err != nil {
165- fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)171+ return err
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")
173 }172 }
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)
187 173
188 fs.scrolledWin = gtk.NewScrolledWindow()174 fs.scrolledWin = gtk.NewScrolledWindow()
189 if fs.scrolledWin == nil {175 if fs.scrolledWin == nil {
@@ -192,12 +178,15 @@ func (fs *FavoritesSidebar) createWidgets() error {
192 fs.scrolledWin.SetVexpand(true)178 fs.scrolledWin.SetVexpand(true)
193 fs.scrolledWin.SetHexpand(true)179 fs.scrolledWin.SetHexpand(true)
194 fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)180 fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
181+ fs.scrolledWin.AddCssClass("sidebar-list")
195 182
196 fs.listBox = gtk.NewListBox()183 fs.listBox = gtk.NewListBox()
197 if fs.listBox == nil {184 if fs.listBox == nil {
198 return fmt.Errorf("favorites sidebar: list box creation failed")185 return fmt.Errorf("favorites sidebar: list box creation failed")
199 }186 }
187+ fs.listBox.AddCssClass("sidebar-list")
200 fs.listBox.AddCssClass("favorites-sidebar-list")188 fs.listBox.AddCssClass("favorites-sidebar-list")
189+ fs.trackListFocus(&fs.listBox.Widget)
201 fs.listBox.SetActivateOnSingleClick(true)190 fs.listBox.SetActivateOnSingleClick(true)
202 fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)191 fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
203 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {192 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
@@ -220,6 +209,125 @@ func (fs *FavoritesSidebar) createWidgets() error {
220 return nil209 return nil
221 }210 }
222 211
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+
223 func (fs *FavoritesSidebar) focusSearch() {331 func (fs *FavoritesSidebar) focusSearch() {
224 if fs == nil {332 if fs == nil {
225 return333 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 (
18 favoritesSidebarModeNone favoritesSidebarMode = iota18 favoritesSidebarModeNone favoritesSidebarMode = iota
19 favoritesSidebarModeAdd19 favoritesSidebarModeAdd
20 favoritesSidebarModeEdit20 favoritesSidebarModeEdit
21- favoritesSidebarModeTag21+ favoritesSidebarModeCreateTag
22+ favoritesSidebarModeBindTag
22 favoritesSidebarModeShortcut23 favoritesSidebarModeShortcut
23 )24 )
24 25
@@ -42,7 +43,7 @@ func (fs *FavoritesSidebar) beginAddForm() {
42 fs.editingID = 043 fs.editingID = 0
43 fs.confirmDelete = false44 fs.confirmDelete = false
44 fs.confirmDeleteID = 045 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.")
46 fs.mu.Unlock()47 fs.mu.Unlock()
47 fs.renderForm(nil)48 fs.renderForm(nil)
48 fs.rebuildList()49 fs.rebuildList()
@@ -63,7 +64,7 @@ func (fs *FavoritesSidebar) beginEditForm() {
63 fs.editingID = fav.ID64 fs.editingID = fav.ID
64 fs.confirmDelete = false65 fs.confirmDelete = false
65 fs.confirmDeleteID = 066 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.")
67 fs.mu.Unlock()68 fs.mu.Unlock()
68 fs.renderForm(fav)69 fs.renderForm(fav)
69 fs.rebuildList()70 fs.rebuildList()
@@ -74,20 +75,23 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
74 return75 return
75 }76 }
76 clearBoxChildren(fs.formBox)77 clearBoxChildren(fs.formBox)
78+ fs.formCallbacks = nil
79+ fs.formTagMatchCallbacks = nil
77 fs.formURLEntry = gtk.NewSearchEntry()80 fs.formURLEntry = gtk.NewSearchEntry()
78 fs.formTitleEntry = gtk.NewSearchEntry()81 fs.formTitleEntry = gtk.NewSearchEntry()
79- fs.formTagsEntry = nil82+ fs.formTagSearch = gtk.NewSearchEntry()
80- if fav == nil {83+ fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3)
81- fs.formTagsEntry = gtk.NewSearchEntry()
82- }
83 fs.formShortcutEntry = gtk.NewSearchEntry()84 fs.formShortcutEntry = gtk.NewSearchEntry()
85+ fs.mu.Lock()
86+ fs.formTagIDs = tagIDSet(favoriteTags(fav))
87+ fs.mu.Unlock()
84 entries := []struct {88 entries := []struct {
85 entry *gtk.SearchEntry89 entry *gtk.SearchEntry
86 label string90 label string
87 }{91 }{
88 {entry: fs.formURLEntry, label: "URL"},92 {entry: fs.formURLEntry, label: "URL"},
89 {entry: fs.formTitleEntry, label: "Title"},93 {entry: fs.formTitleEntry, label: "Title"},
90- {entry: fs.formTagsEntry, label: "Tag IDs"},94+ {entry: fs.formTagSearch, label: "Add tags..."},
91 {entry: fs.formShortcutEntry, label: "Shortcut"},95 {entry: fs.formShortcutEntry, label: "Shortcut"},
92 }96 }
93 for _, item := range entries {97 for _, item := range entries {
@@ -96,21 +100,26 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
96 }100 }
97 placeholder := item.label101 placeholder := item.label
98 item.entry.SetPlaceholderText(&placeholder)102 item.entry.SetPlaceholderText(&placeholder)
103+ fs.trackTextInputFocus(&item.entry.Widget, &fs.formCallbacks)
99 fs.formBox.Append(&item.entry.Widget)104 fs.formBox.Append(&item.entry.Widget)
100 }105 }
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()
101 fs.formSaveButton = gtk.NewButtonWithLabel("Save")111 fs.formSaveButton = gtk.NewButtonWithLabel("Save")
102 if fs.formSaveButton != nil {112 if fs.formSaveButton != nil {
103 cb := func(_ gtk.Button) {113 cb := func(_ gtk.Button) {
104 fs.submitForm()114 fs.submitForm()
105 }115 }
106- fs.retainedCallbacks = append(fs.retainedCallbacks, cb)116+ fs.formCallbacks = append(fs.formCallbacks, cb)
107 fs.formSaveButton.ConnectClicked(&cb)117 fs.formSaveButton.ConnectClicked(&cb)
108 fs.formBox.Append(&fs.formSaveButton.Widget)118 fs.formBox.Append(&fs.formSaveButton.Widget)
109 }119 }
110 if fav != nil {120 if fav != nil {
111 fs.formURL = fav.URL121 fs.formURL = fav.URL
112 fs.formTitle = fav.Title122 fs.formTitle = fav.Title
113- fs.formTags = joinFavoriteTagIDs(fav.Tags)
114 fs.formShortcut = ""123 fs.formShortcut = ""
115 if fav.ShortcutKey != nil {124 if fav.ShortcutKey != nil {
116 fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)125 fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)
@@ -122,15 +131,13 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
122 if fs.formTitleEntry != nil {131 if fs.formTitleEntry != nil {
123 fs.formTitleEntry.SetText(fs.formTitle)132 fs.formTitleEntry.SetText(fs.formTitle)
124 }133 }
125- if fs.formTagsEntry != nil {
126- fs.formTagsEntry.SetText(fs.formTags)
127- }
128 if fs.formShortcutEntry != nil {134 if fs.formShortcutEntry != nil {
129 fs.formShortcutEntry.SetText(fs.formShortcut)135 fs.formShortcutEntry.SetText(fs.formShortcut)
130 }136 }
131 } else {137 } else {
132- fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", ""138+ fs.formURL, fs.formTitle, fs.formShortcut = "", "", ""
133 }139 }
140+ fs.renderFormTagMatches("")
134 fs.formBox.SetVisible(true)141 fs.formBox.SetVisible(true)
135 fs.focusForm()142 fs.focusForm()
136 }143 }
@@ -176,6 +183,11 @@ func (fs *FavoritesSidebar) cancelManagement() bool {
176 if fs.formBox != nil {183 if fs.formBox != nil {
177 fs.formBox.SetVisible(false)184 fs.formBox.SetVisible(false)
178 clearBoxChildren(fs.formBox)185 clearBoxChildren(fs.formBox)
186+ fs.formCallbacks = nil
187+ fs.formTagMatchCallbacks = nil
188+ }
189+ if fs.tagPromptBox != nil {
190+ fs.hideTagPrompt()
179 }191 }
180 fs.rebuildList()192 fs.rebuildList()
181 return active193 return active
@@ -194,13 +206,9 @@ func (fs *FavoritesSidebar) submitForm() bool {
194 if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {206 if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {
195 return false207 return false
196 }208 }
197- url, title, tagsText, shortcutText := fs.formValues()209+ url, title, shortcutText := fs.formValues()
210+ tags := fs.formTagIDsSnapshot()
198 if mode == favoritesSidebarModeAdd {211 if mode == favoritesSidebarModeAdd {
199- tags, err := parseTagIDs(tagsText)
200- if err != nil {
201- fs.setNotice(err.Error())
202- return true
203- }
204 key, err := parseShortcut(shortcutText)212 key, err := parseShortcut(shortcutText)
205 if err != nil {213 if err != nil {
206 fs.setNotice(err.Error())214 fs.setNotice(err.Error())
@@ -231,15 +239,15 @@ func (fs *FavoritesSidebar) submitForm() bool {
231 fs.setNotice(err.Error())239 fs.setNotice(err.Error())
232 return true240 return true
233 }241 }
242+ if !fs.updateFavoriteTags(id, tags) {
243+ return true
244+ }
234 }245 }
235 fs.cancelManagement()246 fs.cancelManagement()
236 fs.startLoad()247 fs.startLoad()
237 return true248 return true
238 }249 }
239 250
240-func (fs *FavoritesSidebar) enterTagMode() {
241- fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite")
242-}
243 func (fs *FavoritesSidebar) enterShortcutMode() {251 func (fs *FavoritesSidebar) enterShortcutMode() {
244 fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")252 fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")
245 }253 }
@@ -256,43 +264,6 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri
256 fs.rebuildList()264 fs.rebuildList()
257 }265 }
258 266
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-
296 func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {267 func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {
297 fav := fs.selectedFavorite()268 fav := fs.selectedFavorite()
298 if fav == nil {269 if fav == nil {
@@ -402,41 +373,18 @@ func (fs *FavoritesSidebar) setNotice(notice string) {
402 fs.rebuildList()373 fs.rebuildList()
403 }374 }
404 375
405-func (fs *FavoritesSidebar) formValues() (string, string, string, string) {376+func (fs *FavoritesSidebar) formValues() (string, string, string) {
406- url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut377+ url, title, shortcut := fs.formURL, fs.formTitle, fs.formShortcut
407 if fs.formURLEntry != nil {378 if fs.formURLEntry != nil {
408 url = fs.formURLEntry.GetText()379 url = fs.formURLEntry.GetText()
409 }380 }
410 if fs.formTitleEntry != nil {381 if fs.formTitleEntry != nil {
411 title = fs.formTitleEntry.GetText()382 title = fs.formTitleEntry.GetText()
412 }383 }
413- if fs.formTagsEntry != nil {
414- tagsText = fs.formTagsEntry.GetText()
415- }
416 if fs.formShortcutEntry != nil {384 if fs.formShortcutEntry != nil {
417 shortcut = fs.formShortcutEntry.GetText()385 shortcut = fs.formShortcutEntry.GetText()
418 }386 }
419- return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut)387+ return strings.TrimSpace(url), strings.TrimSpace(title), 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
440 }388 }
441 389
442 func parseShortcut(text string) (*int, error) {390 func parseShortcut(text string) (*int, error) {
@@ -450,11 +398,3 @@ func parseShortcut(text string) (*int, error) {
450 }398 }
451 return &v, nil399 return &v, nil
452 }400 }
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() {
17 if keyController == nil {17 if keyController == nil {
18 return18 return
19 }19 }
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.
20 keyController.SetPropagationPhase(gtk.PhaseCaptureValue)23 keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
21 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {24 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
22 fs.mu.RLock()25 fs.mu.RLock()
@@ -29,6 +32,18 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
29 fs.mu.RUnlock()32 fs.mu.RUnlock()
30 33
31 textEditing := fs.inTextEditContext()34 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+ }
32 switch keyval {47 switch keyval {
33 case uint(gdk.KEY_Escape):48 case uint(gdk.KEY_Escape):
34 if fs.cancelManagement() {49 if fs.cancelManagement() {
@@ -43,6 +58,12 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
43 }58 }
44 return true59 return true
45 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):60 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+ }
46 return fs.handleReturnKey(state)67 return fs.handleReturnKey(state)
47 case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):68 case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
48 fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)69 fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
@@ -89,6 +110,11 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
89 }110 }
90 fs.selectAdjacentRow(1)111 fs.selectAdjacentRow(1)
91 return true112 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
92 case uint(gdk.KEY_slash):118 case uint(gdk.KEY_slash):
93 if textEditing {119 if textEditing {
94 return false120 return false
@@ -279,21 +305,97 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) {
279 305
280 func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }306 func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
281 307
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 {
283 if fs == nil {317 if fs == nil {
284 return false318 return false
285 }319 }
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.
287 return true326 return true
288- }327+ case keyval == uint(gdk.KEY_Down) && zone == favoritesSidebarFocusSearch:
289- for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} {328+ fs.focusListAndSelectFirst()
290- if entry != nil && entry.HasFocus() {329+ return true
291- return true330+ case keyval == uint(gdk.KEY_Up) && zone == favoritesSidebarFocusList && fs.selectedRowIsFirst():
331+ fs.setFocusZone(favoritesSidebarFocusSearch)
332+ if fs.searchEntry != nil {
333+ fs.searchEntry.GrabFocus()
292 }334 }
335+ return true
293 }336 }
294 return false337 return false
295 }338 }
296 339
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+
297 func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {399 func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
298 shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue400 shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
299 if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {401 if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
@@ -303,9 +405,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
303 mode := fs.mode405 mode := fs.mode
304 confirm := fs.confirmDelete406 confirm := fs.confirmDelete
305 fs.mu.RUnlock()407 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- }
309 if mode == favoritesSidebarModeShortcut {408 if mode == favoritesSidebarModeShortcut {
310 if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {409 if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
311 v := int(keyval - uint(gdk.KEY_0))410 v := int(keyval - uint(gdk.KEY_0))
@@ -328,9 +427,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
328 case uint(gdk.KEY_e):427 case uint(gdk.KEY_e):
329 fs.beginEditForm()428 fs.beginEditForm()
330 return true429 return true
331- case uint(gdk.KEY_t):
332- fs.enterTagMode()
333- return true
334 case uint(gdk.KEY_s):430 case uint(gdk.KEY_s):
335 fs.enterShortcutMode()431 fs.enterShortcutMode()
336 return true432 return true
@@ -366,13 +462,21 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
366 if fs == nil {462 if fs == nil {
367 return463 return
368 }464 }
369- zones := fs.availableFocusZones()465+ if fs.cycleTagControlFocus(reverse) {
370- if len(zones) == 0 {
371 return466 return
372 }467 }
373 fs.mu.RLock()468 fs.mu.RLock()
374 current := fs.focusZone469 current := fs.focusZone
470+ tagCount := len(fs.tagControls)
375 fs.mu.RUnlock()471 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+ }
376 idx := -1480 idx := -1
377 for i, zone := range zones {481 for i, zone := range zones {
378 if zone == current {482 if zone == current {
@@ -394,6 +498,37 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
394 fs.focusZoneWidget(zones[idx])498 fs.focusZoneWidget(zones[idx])
395 }499 }
396 500
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+
397 func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {532 func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
398 zones := []favoritesSidebarFocusZone{}533 zones := []favoritesSidebarFocusZone{}
399 if fs.searchEntry != nil {534 if fs.searchEntry != nil {
@@ -428,9 +563,7 @@ func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
428 case favoritesSidebarFocusSearch:563 case favoritesSidebarFocusSearch:
429 fs.focusSearch()564 fs.focusSearch()
430 case favoritesSidebarFocusTags:565 case favoritesSidebarFocusTags:
431- if fs.tagBox != nil {566+ fs.focusTagControl(0)
432- fs.tagBox.GrabFocus()
433- }
434 case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:567 case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
435 if fs.listBox != nil {568 if fs.listBox != nil {
436 fs.listBox.GrabFocus()569 fs.listBox.GrabFocus()
M internal/ui/component/favorites_sidebar_rows.go +74 -49
@@ -72,70 +72,90 @@ func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction in
72 }72 }
73 73
74 func (fs *FavoritesSidebar) renderTags() {74 func (fs *FavoritesSidebar) renderTags() {
75- if fs == nil || fs.tagBox == nil {75+ tagBox, tags, selectedTagIDs, ok := fs.tagRenderState()
76+ if !ok {
76 return77 return
77 }78 }
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+ }
78 fs.mu.RLock()103 fs.mu.RLock()
104+ defer fs.mu.RUnlock()
79 if fs.destroyed {105 if fs.destroyed {
80- fs.mu.RUnlock()106+ return nil, nil, nil, false
81- return
82 }107 }
83- tags := append([]*entity.Tag(nil), fs.allTags...)108+ selected := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
84- selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
85 for id := range fs.selectedTagIDs {109 for id := range fs.selectedTagIDs {
86- selectedTagIDs[id] = struct{}{}110+ selected[id] = struct{}{}
87 }111 }
88- tagBox := fs.tagBox112+ return fs.tagBox, append([]*entity.Tag(nil), fs.allTags...), selected, true
89- fs.mu.RUnlock()113+}
90-
91- clearBoxChildren(tagBox)
92- callbacks := make([]any, 0, len(tags)+1)
93 114
94- allButton := gtk.NewButtonWithLabel("All")115+func (fs *FavoritesSidebar) appendTagFilter(
95- if allButton != nil {116+ box *gtk.Box,
96- if len(selectedTagIDs) == 0 {117+ label string,
97- allButton.AddCssClass("suggested-action")118+ selected bool,
98- }119+ action func(),
99- cb := func(_ gtk.Button) {120+ callbacks *[]any,
100- fs.mu.Lock()121+ controls *[]*gtk.Button,
101- if fs.destroyed {122+) {
102- fs.mu.Unlock()123+ button := gtk.NewButtonWithLabel(label)
103- return124+ if button == nil {
104- }125+ return
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)
114 }126 }
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+}
115 137
116- for _, tag := range tags {138+func (fs *FavoritesSidebar) clearTagFilters() {
117- if tag == nil {139+ if fs == nil {
118- continue140+ return
119- }141+ }
120- t := tag142+ fs.mu.Lock()
121- button := gtk.NewButtonWithLabel(t.Name)143+ if fs.destroyed {
122- if button == nil {144+ fs.mu.Unlock()
123- continue145+ return
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)
134 }146 }
147+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
148+ fs.rebuildDisplayRowsLocked()
149+ fs.mu.Unlock()
150+ fs.renderTags()
151+ fs.rebuildList()
152+}
135 153
154+func (fs *FavoritesSidebar) storeTagControls(callbacks []any, controls []*gtk.Button) {
136 fs.mu.Lock()155 fs.mu.Lock()
137 if !fs.destroyed {156 if !fs.destroyed {
138 fs.tagCallbacks = callbacks157 fs.tagCallbacks = callbacks
158+ fs.tagControls = controls
139 }159 }
140 fs.mu.Unlock()160 fs.mu.Unlock()
141 }161 }
@@ -212,6 +232,7 @@ func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
212 if label == nil {232 if label == nil {
213 return233 return
214 }234 }
235+ label.AddCssClass("sidebar-empty")
215 label.AddCssClass("favorites-sidebar-empty")236 label.AddCssClass("favorites-sidebar-empty")
216 label.SetXalign(0.0)237 label.SetXalign(0.0)
217 row := gtk.NewListBoxRow()238 row := gtk.NewListBoxRow()
@@ -243,6 +264,7 @@ func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow f
243 if row == nil {264 if row == nil {
244 return265 return
245 }266 }
267+ row.AddCssClass("sidebar-row")
246 row.AddCssClass("favorites-sidebar-row")268 row.AddCssClass("favorites-sidebar-row")
247 row.SetSelectable(displayRow.Selectable)269 row.SetSelectable(displayRow.Selectable)
248 row.SetActivatable(displayRow.Selectable)270 row.SetActivatable(displayRow.Selectable)
@@ -257,6 +279,7 @@ func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) {
257 if title == nil {279 if title == nil {
258 return280 return
259 }281 }
282+ title.AddCssClass("sidebar-row-title")
260 title.AddCssClass("favorites-sidebar-row-title")283 title.AddCssClass("favorites-sidebar-row-title")
261 title.SetText(safeSidebarString(fav.Title, fav.URL))284 title.SetText(safeSidebarString(fav.Title, fav.URL))
262 title.SetXalign(0.0)285 title.SetXalign(0.0)
@@ -273,6 +296,7 @@ func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) {
273 sub.SetHexpand(true)296 sub.SetHexpand(true)
274 url := gtk.NewLabel(nil)297 url := gtk.NewLabel(nil)
275 if url != nil {298 if url != nil {
299+ url.AddCssClass("sidebar-row-subtitle")
276 url.AddCssClass("favorites-sidebar-row-subtitle")300 url.AddCssClass("favorites-sidebar-row-subtitle")
277 url.SetText(readableURL(fav.URL))301 url.SetText(readableURL(fav.URL))
278 url.SetXalign(0.0)302 url.SetXalign(0.0)
@@ -299,6 +323,7 @@ func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) {
299 if tags == nil {323 if tags == nil {
300 return324 return
301 }325 }
326+ tags.AddCssClass("favorites-sidebar-row-tags")
302 for _, tag := range favoriteTags {327 for _, tag := range favoriteTags {
303 name := tag.Name328 name := tag.Name
304 label := gtk.NewLabel(&name)329 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 (
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/puregotk/v4/gdk"12 "github.com/bnema/puregotk/v4/gdk"
13 "github.com/bnema/puregotk/v4/glib"13 "github.com/bnema/puregotk/v4/glib"
14+ "github.com/bnema/puregotk/v4/gtk"
14 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
16 )17 )
@@ -34,8 +35,15 @@ type fakeFavoritesSidebarUC struct {
34 favID entity.FavoriteID35 favID entity.FavoriteID
35 tagID entity.TagID36 tagID entity.TagID
36 }37 }
38+ createdTags []struct {
39+ name string
40+ color string
41+ }
37 err error42 err error
38 errOnSetShortcut error43 errOnSetShortcut error
44+ errOnTag map[entity.TagID]error
45+ errOnUntag map[entity.TagID]error
46+ failUntagOnCall int
39 getAllCalled chan struct{}47 getAllCalled chan struct{}
40 }48 }
41 49
@@ -51,6 +59,16 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er
51 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {59 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
52 return f.tags, f.err60 return f.tags, f.err
53 }61 }
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+}
54 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {72 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
55 f.addInputs = append(f.addInputs, input)73 f.addInputs = append(f.addInputs, input)
56 if f.err != nil {74 if f.err != nil {
@@ -84,6 +102,9 @@ func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.Fav
84 favID entity.FavoriteID102 favID entity.FavoriteID
85 tagID entity.TagID103 tagID entity.TagID
86 }{favID: favID, tagID: tagID})104 }{favID: favID, tagID: tagID})
105+ if err := f.errOnTag[tagID]; err != nil {
106+ return err
107+ }
87 return f.err108 return f.err
88 }109 }
89 func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {110 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
91 favID entity.FavoriteID112 favID entity.FavoriteID
92 tagID entity.TagID113 tagID entity.TagID
93 }{favID: favID, tagID: tagID})114 }{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+ }
94 return f.err121 return f.err
95 }122 }
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+}
96 154
97 func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {155 func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
98 fs := &FavoritesSidebar{156 fs := &FavoritesSidebar{
@@ -340,20 +398,19 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
340 fs.favoritesUC = uc398 fs.favoritesUC = uc
341 fs.formURL = "https://add.test"399 fs.formURL = "https://add.test"
342 fs.formTitle = "Added"400 fs.formTitle = "Added"
343- fs.formTags = "1, 2"401+ fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}}
344 fs.formShortcut = "3"402 fs.formShortcut = "3"
345 fs.mode = favoritesSidebarModeAdd403 fs.mode = favoritesSidebarModeAdd
346 404
347 assert.True(t, fs.submitForm())405 assert.True(t, fs.submitForm())
348 require.Len(t, uc.addInputs, 1)406 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)
350 require.Len(t, uc.shortcuts, 1)408 require.Len(t, uc.shortcuts, 1)
351 assert.Equal(t, 3, *uc.shortcuts[0].key)409 assert.Equal(t, 3, *uc.shortcuts[0].key)
352 410
353 fs.mode = favoritesSidebarModeEdit411 fs.mode = favoritesSidebarModeEdit
354 fs.editingID = 7412 fs.editingID = 7
355 fs.formTitle = "Edited"413 fs.formTitle = "Edited"
356- fs.formTags = "999"
357 fs.formShortcut = "4"414 fs.formShortcut = "4"
358 assert.True(t, fs.submitForm())415 assert.True(t, fs.submitForm())
359 require.Len(t, uc.updateInputs, 1)416 require.Len(t, uc.updateInputs, 1)
@@ -372,23 +429,15 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
372 assert.Nil(t, uc.updateInputs[1].ShortcutKey)429 assert.Nil(t, uc.updateInputs[1].ShortcutKey)
373 }430 }
374 431
375-func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {432+func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) {
376 uc := &fakeFavoritesSidebarUC{}433 uc := &fakeFavoritesSidebarUC{}
377 fs := newFavoritesSidebarHarness(nil, nil)434 fs := newFavoritesSidebarHarness(nil, nil)
378 fs.favoritesUC = uc435 fs.favoritesUC = uc
379 fs.formURL = "https://add.test"436 fs.formURL = "https://add.test"
380 fs.formTitle = "Added"437 fs.formTitle = "Added"
381- fs.formTags = "1, nope"438+ fs.formShortcut = "10"
382 fs.mode = favoritesSidebarModeAdd439 fs.mode = favoritesSidebarModeAdd
383 440
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"
392 assert.True(t, fs.submitForm())441 assert.True(t, fs.submitForm())
393 assert.Empty(t, uc.addInputs)442 assert.Empty(t, uc.addInputs)
394 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)443 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
@@ -396,6 +445,88 @@ func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
396 assert.Equal(t, "10", fs.formShortcut)445 assert.Equal(t, "10", fs.formShortcut)
397 }446 }
398 447
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+
399 func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {530 func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
400 fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}531 fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
401 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)532 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
@@ -436,7 +567,6 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
436 fs.mode = favoritesSidebarModeEdit567 fs.mode = favoritesSidebarModeEdit
437 fs.editingID = 7568 fs.editingID = 7
438 fs.formTitle = "Edited"569 fs.formTitle = "Edited"
439- fs.formTags = "999"
440 570
441 assert.True(t, fs.submitForm())571 assert.True(t, fs.submitForm())
442 require.Len(t, uc.updateInputs, 1)572 require.Len(t, uc.updateInputs, 1)
@@ -445,6 +575,90 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
445 assert.Empty(t, uc.untagged)575 assert.Empty(t, uc.untagged)
446 }576 }
447 577
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+
448 func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {662 func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
449 fs := newFavoritesSidebarHarness(nil, nil)663 fs := newFavoritesSidebarHarness(nil, nil)
450 fs.searchEntry = nil664 fs.searchEntry = nil
@@ -505,23 +719,14 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
505 assert.Contains(t, fs.notice, "failed to set shortcut")719 assert.Contains(t, fs.notice, "failed to set shortcut")
506 }720 }
507 721
508-func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {722+func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) {
509 dev := entity.Tag{ID: 10, Name: "dev"}723 dev := entity.Tag{ID: 10, Name: "dev"}
510- news := entity.Tag{ID: 11, Name: "news"}
511 fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}724 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})
513 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)726 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
514 fs.allTags = uc.tags727 fs.allTags = uc.tags
515 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}728 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
516 729
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-
525 fs.mode = favoritesSidebarModeShortcut730 fs.mode = favoritesSidebarModeShortcut
526 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))731 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
527 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0))732 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) {
103 if label == nil {103 if label == nil {
104 return104 return
105 }105 }
106+ label.AddCssClass("sidebar-empty")
106 label.AddCssClass("history-sidebar-empty")107 label.AddCssClass("history-sidebar-empty")
107 108
108 if query != "" {109 if query != "" {
@@ -163,6 +164,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His
163 if titleLabel == nil {164 if titleLabel == nil {
164 return165 return
165 }166 }
167+ titleLabel.AddCssClass("sidebar-row-title")
166 titleLabel.AddCssClass("history-sidebar-row-title")168 titleLabel.AddCssClass("history-sidebar-row-title")
167 titleLabel.SetText(safeSidebarString(entry.Title, entry.URL))169 titleLabel.SetText(safeSidebarString(entry.Title, entry.URL))
168 titleLabel.SetXalign(0.0)170 titleLabel.SetXalign(0.0)
@@ -180,6 +182,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His
180 if urlLabel == nil {182 if urlLabel == nil {
181 return183 return
182 }184 }
185+ urlLabel.AddCssClass("sidebar-row-subtitle")
183 urlLabel.AddCssClass("history-sidebar-row-subtitle")186 urlLabel.AddCssClass("history-sidebar-row-subtitle")
184 urlLabel.SetText(readableURL(entry.URL))187 urlLabel.SetText(readableURL(entry.URL))
185 urlLabel.SetXalign(0.0)188 urlLabel.SetXalign(0.0)
@@ -205,6 +208,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His
205 if row == nil {208 if row == nil {
206 return209 return
207 }210 }
211+ row.AddCssClass("sidebar-row")
208 row.AddCssClass("history-sidebar-row")212 row.AddCssClass("history-sidebar-row")
209 row.SetSelectable(true)213 row.SetSelectable(true)
210 row.SetActivatable(true)214 row.SetActivatable(true)
M internal/ui/component/history_sidebar_widgets.go +5 -0
@@ -28,6 +28,7 @@ func (hs *HistorySidebar) initOuterBox() error {
28 if hs.outerBox == nil {28 if hs.outerBox == nil {
29 return fmt.Errorf("history sidebar: outer box creation failed")29 return fmt.Errorf("history sidebar: outer box creation failed")
30 }30 }
31+ hs.outerBox.AddCssClass("sidebar-outer")
31 hs.outerBox.AddCssClass("history-sidebar-outer")32 hs.outerBox.AddCssClass("history-sidebar-outer")
32 hs.outerBox.SetSizeRequest(sidebarMinWidth, -1)33 hs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
33 hs.outerBox.SetHexpand(false)34 hs.outerBox.SetHexpand(false)
@@ -41,6 +42,7 @@ func (hs *HistorySidebar) initSearchBox() error {
41 if hs.searchBox == nil {42 if hs.searchBox == nil {
42 return fmt.Errorf("history sidebar: search box creation failed")43 return fmt.Errorf("history sidebar: search box creation failed")
43 }44 }
45+ hs.searchBox.AddCssClass("sidebar-search-box")
44 hs.searchBox.AddCssClass("history-sidebar-search-box")46 hs.searchBox.AddCssClass("history-sidebar-search-box")
45 hs.searchBox.SetHexpand(true)47 hs.searchBox.SetHexpand(true)
46 48
@@ -48,6 +50,7 @@ func (hs *HistorySidebar) initSearchBox() error {
48 if hs.searchEntry == nil {50 if hs.searchEntry == nil {
49 return fmt.Errorf("history sidebar: search entry creation failed")51 return fmt.Errorf("history sidebar: search entry creation failed")
50 }52 }
53+ hs.searchEntry.AddCssClass("sidebar-search")
51 hs.searchEntry.AddCssClass("history-sidebar-search")54 hs.searchEntry.AddCssClass("history-sidebar-search")
52 hs.searchEntry.SetHexpand(true)55 hs.searchEntry.SetHexpand(true)
53 placeholder := "Search history..."56 placeholder := "Search history..."
@@ -66,12 +69,14 @@ func (hs *HistorySidebar) initListArea() error {
66 hs.scrolledWin.SetVexpand(true)69 hs.scrolledWin.SetVexpand(true)
67 hs.scrolledWin.SetHexpand(true)70 hs.scrolledWin.SetHexpand(true)
68 hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)71 hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
72+ hs.scrolledWin.AddCssClass("sidebar-list")
69 hs.scrolledWin.AddCssClass("history-sidebar-groups")73 hs.scrolledWin.AddCssClass("history-sidebar-groups")
70 74
71 hs.listBox = gtk.NewListBox()75 hs.listBox = gtk.NewListBox()
72 if hs.listBox == nil {76 if hs.listBox == nil {
73 return fmt.Errorf("history sidebar: list box creation failed")77 return fmt.Errorf("history sidebar: list box creation failed")
74 }78 }
79+ hs.listBox.AddCssClass("sidebar-list")
75 hs.listBox.AddCssClass("history-sidebar-groups")80 hs.listBox.AddCssClass("history-sidebar-groups")
76 hs.listBox.SetActivateOnSingleClick(true)81 hs.listBox.SetActivateOnSingleClick(true)
77 hs.listBox.SetSelectionMode(gtk.SelectionSingleValue)82 hs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
M internal/ui/component/omnibox_callback_pressure_stress_test.go +5 -1
@@ -8,6 +8,7 @@ import (
8 "time"8 "time"
9 9
10 "github.com/bnema/purego"10 "github.com/bnema/purego"
11+ "github.com/bnema/puregotk/v4/gdk"
11 "github.com/bnema/puregotk/v4/glib"12 "github.com/bnema/puregotk/v4/glib"
12 "github.com/bnema/puregotk/v4/gobject"13 "github.com/bnema/puregotk/v4/gobject"
13 "github.com/bnema/puregotk/v4/gtk"14 "github.com/bnema/puregotk/v4/gtk"
@@ -32,7 +33,10 @@ func runGTKCallbackLifecycleStress(t *testing.T) {
32 if !gtk.InitCheck() {33 if !gtk.InitCheck() {
33 t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")34 t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
34 }35 }
35-36+ display := gdk.DisplayGetDefault()
37+ if display == nil {
38+ t.Skip("GTK native display prerequisite unavailable (no default GDK display)")
39+ }
36 mainContext := glib.MainContextDefault()40 mainContext := glib.MainContextDefault()
37 require.NotNil(t, mainContext)41 require.NotNil(t, mainContext)
38 done := make(chan struct{})42 done := make(chan struct{})
M internal/ui/component/omnibox_navigation_state_test.go +7 -7
@@ -73,7 +73,7 @@ func TestEffectiveSearchQuery(t *testing.T) {
73 hasGhost bool73 hasGhost bool
74 want string74 want string
75 }{75 }{
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"},
77 {name: "falls back to entry when no ghost", entryText: "dumb", realInput: "dumb", hasGhost: false, want: "dumb"},77 {name: "falls back to entry when no ghost", entryText: "dumb", realInput: "dumb", hasGhost: false, want: "dumb"},
78 {name: "uses entry when real input unavailable", entryText: "dumb", realInput: "", hasGhost: true, want: "dumb"},78 {name: "uses entry when real input unavailable", entryText: "dumb", realInput: "", hasGhost: true, want: "dumb"},
79 }79 }
@@ -136,8 +136,8 @@ func TestResolveTargetURLForSelection(t *testing.T) {
136 {URL: "https://github.com/bnema/dumber/issues"},136 {URL: "https://github.com/bnema/dumber/issues"},
137 }137 }
138 favorites := []Favorite{138 favorites := []Favorite{
139- {URL: "https://dumber.bnema.dev"},139+ {URL: "https://bnema.dev/dumber"},
140- {URL: "https://docs.dumber.bnema.dev"},140+ {URL: "https://bnema.dev/dumber/docs"},
141 }141 }
142 142
143 tests := []struct {143 tests := []struct {
@@ -148,7 +148,7 @@ func TestResolveTargetURLForSelection(t *testing.T) {
148 wantURL string148 wantURL string
149 }{149 }{
150 {name: "history index", mode: ViewModeHistory, index: 1, limit: 10, wantURL: "https://github.com/bnema/dumber/pulls"},150 {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"},
152 {name: "invalid index", mode: ViewModeHistory, index: 99, limit: 10, wantURL: ""},152 {name: "invalid index", mode: ViewModeHistory, index: 99, limit: 10, wantURL: ""},
153 {name: "history index beyond visible limit is hidden", mode: ViewModeHistory, index: 2, limit: 2, wantURL: ""},153 {name: "history index beyond visible limit is hidden", mode: ViewModeHistory, index: 2, limit: 2, wantURL: ""},
154 {name: "favorites index beyond visible limit is hidden", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: ""},154 {name: "favorites index beyond visible limit is hidden", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: ""},
@@ -170,8 +170,8 @@ func TestSelectedTargetURL(t *testing.T) {
170 {URL: "https://github.com/bnema/dumber/pulls"},170 {URL: "https://github.com/bnema/dumber/pulls"},
171 }171 }
172 favorites := []Favorite{172 favorites := []Favorite{
173- {URL: "https://dumber.bnema.dev"},173+ {URL: "https://bnema.dev/dumber"},
174- {URL: "https://docs.dumber.bnema.dev"},174+ {URL: "https://bnema.dev/dumber/docs"},
175 }175 }
176 176
177 tests := []struct {177 tests := []struct {
@@ -184,7 +184,7 @@ func TestSelectedTargetURL(t *testing.T) {
184 }{184 }{
185 {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, limit: 10, wantURL: "", wantBool: false},185 {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, limit: 10, wantURL: "", wantBool: false},
186 {name: "history selection is explicit", mode: ViewModeHistory, index: 0, limit: 10, wantURL: "https://github.com/bnema/dumber", wantBool: true},186 {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},
188 {name: "hidden history selection is ignored", mode: ViewModeHistory, index: 1, limit: 1, wantURL: "", wantBool: true},188 {name: "hidden history selection is ignored", mode: ViewModeHistory, index: 1, limit: 1, wantURL: "", wantBool: true},
189 {name: "hidden favorites selection is ignored", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: "", wantBool: true},189 {name: "hidden favorites selection is ignored", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: "", wantBool: true},
190 }190 }
M internal/ui/component/pane_view.go +86 -0
@@ -14,6 +14,27 @@ import (
14 const (14 const (
15 // CSS class applied to active pane's border overlay15 // CSS class applied to active pane's border overlay
16 activePaneClass = "pane-active"16 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
17 )38 )
18 39
19 // PaneView is a container for a single WebView with active state indication.40 // PaneView is a container for a single WebView with active state indication.
@@ -31,6 +52,10 @@ type PaneView struct {
31 paneID entity.PaneID52 paneID entity.PaneID
32 isActive bool53 isActive bool
33 54
55+ // Vim Mode pane-local visual state.
56+ vimMode bool
57+ pulseCycle bool
58+
34 onFocusIn func(paneID entity.PaneID)59 onFocusIn func(paneID entity.PaneID)
35 onFocusOut func(paneID entity.PaneID)60 onFocusOut func(paneID entity.PaneID)
36 onHover func(paneID entity.PaneID)61 onHover func(paneID entity.PaneID)
@@ -92,6 +117,7 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit
92 loading: loading,117 loading: loading,
93 paneID: paneID,118 paneID: paneID,
94 isActive: false,119 isActive: false,
120+ vimMode: false,
95 }121 }
96 }122 }
97 123
@@ -520,6 +546,66 @@ func (pv *PaneView) HideLinkStatus() {
520 }546 }
521 }547 }
522 548
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+
523 // Cleanup removes the WebView widget from the overlay and clears references.609 // Cleanup removes the WebView widget from the overlay and clears references.
524 // This must be called before destroying the WebView to ensure proper GTK cleanup.610 // This must be called before destroying the WebView to ensure proper GTK cleanup.
525 // After calling Cleanup, the PaneView should not be reused.611 // 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(
681 mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()681 mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
682 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()682 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
683 }683 }
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() {
276 t.hide()276 t.hide()
277 }277 }
278 278
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+
279 // hide performs the actual hide operation (must be called with lock held).291 // hide performs the actual hide operation (must be called with lock held).
280 func (t *Toaster) hide() {292 func (t *Toaster) hide() {
281 if !t.visible {293 if !t.visible {
M internal/ui/component/workspace_view.go +10 -0
@@ -73,6 +73,16 @@ type WorkspaceView struct {
73 mu sync.RWMutex73 mu sync.RWMutex
74 }74 }
75 75
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+
76 // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface86 // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
77 // so TreeRenderer can create PaneViews.87 // so TreeRenderer can create PaneViews.
78 type paneViewFactoryAdapter struct {88 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
179 }179 }
180 }180 }
181 181
182+ callbacks.OnEditableFocusChanged = func(editable bool) {
183+ if c.onEditableFocusChanged != nil {
184+ c.onEditableFocusChanged(paneID, editable)
185+ }
186+ }
187+
182 // Add popup create handler if popup handling is configured188 // Add popup create handler if popup handling is configured
183 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)189 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
184 190
M internal/ui/coordinator/content/coordinator.go +8 -0
@@ -109,6 +109,9 @@ type Coordinator struct {
109 // Callback when WebView gains focus (for accent picker text input targeting)109 // Callback when WebView gains focus (for accent picker text input targeting)
110 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)110 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
111 111
112+ // Callback when a page editable target gains or loses focus.
113+ onEditableFocusChanged func(paneID entity.PaneID, editable bool)
114+
112 // Callback for first load_started event (triggers deferred initialization)115 // Callback for first load_started event (triggers deferred initialization)
113 onFirstLoadStarted func()116 onFirstLoadStarted func()
114 loadStartedOnce sync.Once117 loadStartedOnce sync.Once
@@ -243,6 +246,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.
243 c.onWebViewFocused = fn246 c.onWebViewFocused = fn
244 }247 }
245 248
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+
246 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.254 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.
247 // This is used to trigger deferred initialization after the initial load_uri()255 // This is used to trigger deferred initialization after the initial load_uri()
248 // has been processed by the GTK main loop.256 // 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 {
23 contextProvider func() context.Context23 contextProvider func() context.Context
24 navigateUC *usecase.NavigateUseCase24 navigateUC *usecase.NavigateUseCase
25 historyRecorder *usecase.HistoryRecorderUseCase25 historyRecorder *usecase.HistoryRecorderUseCase
26+ pageScrollUC *usecase.PageScrollUseCase
26 contentCoord *content.Coordinator27 contentCoord *content.Coordinator
27 omniboxProvider OmniboxProvider28 omniboxProvider OmniboxProvider
28 }29 }
@@ -275,6 +276,56 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
275 c.historyRecorder.ClearPaneHistory(string(paneID))276 c.historyRecorder.ClearPaneHistory(string(paneID))
276 }277 }
277 278
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+
278 // NotifyZoomChanged updates the omnibox zoom indicator.329 // NotifyZoomChanged updates the omnibox zoom indicator.
279 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {330 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {
280 if c.omniboxProvider != nil {331 if c.omniboxProvider != nil {
M internal/ui/coordinator/navigation_webview_targeted_test.go +74 -0
@@ -6,9 +6,15 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/port/mocks"8 "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
10 )11 )
11 12
13+type mockScrollableWebView struct {
14+ *mocks.MockWebView
15+ *mocks.MockPageScrollable
16+}
17+
12 type mockDevToolsWebView struct {18 type mockDevToolsWebView struct {
13 *mocks.MockWebView19 *mocks.MockWebView
14 *mocks.MockDevToolsOpener20 *mocks.MockDevToolsOpener
@@ -76,6 +82,15 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi
76 t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {82 t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {
77 testPrintWebViewTargetsProvidedWebView(t, ctx)83 testPrintWebViewTargetsProvidedWebView(t, ctx)
78 })84 })
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+ })
79 }94 }
80 95
81 func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {96 func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {
@@ -268,3 +283,62 @@ func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
268 t.Fatalf("unexpected error: %v", err)283 t.Fatalf("unexpected error: %v", err)
269 }284 }
270 }285 }
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
163 163
164 // Split splits the active pane in the given direction.164 // Split splits the active pane in the given direction.
165 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {165 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)
167 }167 }
168 168
169 // SplitWithURL splits the active pane in the given direction and loads initialURL.169 // SplitWithURL splits the active pane in the given direction and loads initialURL.
170 func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {170 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)
172 }172 }
173 173
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 {
175 log := logging.FromContext(ctx)185 log := logging.FromContext(ctx)
176 186
177- splitCtx, ok := c.prepareSplit(ctx, direction)187+ splitCtx, err := c.prepareSplit(ctx, direction)
178- if !ok {188+ if err != nil {
179- return nil189+ return err
180 }190 }
181 191
182 output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{192 output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
@@ -198,11 +208,20 @@ func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, directio
198 208
199 // Update the workspace view209 // Update the workspace view
200 if splitCtx.wsView != nil {210 if splitCtx.wsView != nil {
201- c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)211+ if err := c.applySplitToView(
202- }212+ ctx, splitCtx.wsView, splitCtx.ws, output, direction,
203-213+ splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID,
204- if splitCtx.wsView != nil {214+ ); err != nil {
205- splitCtx.wsView.NotifyNewPaneCreated(ctx)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+ }
206 }225 }
207 226
208 // Notify state change for session snapshots227 // Notify state change for session snapshots
@@ -332,23 +351,27 @@ func setActiveStackIndexForChild(parent, child *entity.PaneNode) {
332 func (c *WorkspaceCoordinator) prepareSplit(351 func (c *WorkspaceCoordinator) prepareSplit(
333 ctx context.Context,352 ctx context.Context,
334 direction usecase.SplitDirection,353 direction usecase.SplitDirection,
335-) (*splitContext, bool) {354+) (*splitContext, error) {
336 log := logging.FromContext(ctx)355 log := logging.FromContext(ctx)
337 if c.panesUC == nil {356 if c.panesUC == nil {
338 log.Warn().Msg("panes use case not available")357 log.Warn().Msg("panes use case not available")
339- return nil, false358+ 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")
340 }363 }
341 364
342 ws, wsView := c.getActiveWS()365 ws, wsView := c.getActiveWS()
343 if ws == nil {366 if ws == nil {
344 log.Warn().Msg("no active workspace")367 log.Warn().Msg("no active workspace")
345- return nil, false368+ return nil, fmt.Errorf("no active workspace")
346 }369 }
347 370
348 activePane := ws.ActivePane()371 activePane := ws.ActivePane()
349- if activePane == nil {372+ if activePane == nil || activePane.Pane == nil {
350 log.Warn().Msg("no active pane to split")373 log.Warn().Msg("no active pane to split")
351- return nil, false374+ return nil, fmt.Errorf("no active pane to split")
352 }375 }
353 376
354 // Check if active pane is inside a stack377 // Check if active pane is inside a stack
@@ -367,7 +390,7 @@ func (c *WorkspaceCoordinator) prepareSplit(
367 activePane: activePane,390 activePane: activePane,
368 existingWidget: existingWidget,391 existingWidget: existingWidget,
369 isStackSplit: isStackSplit,392 isStackSplit: isStackSplit,
370- }, true393+ }, nil
371 }394 }
372 395
373 func (c *WorkspaceCoordinator) resolveSplitWidget(396 func (c *WorkspaceCoordinator) resolveSplitWidget(
@@ -407,7 +430,7 @@ func (c *WorkspaceCoordinator) applySplitToView(
407 existingWidget layout.Widget,430 existingWidget layout.Widget,
408 isStackSplit bool,431 isStackSplit bool,
409 oldActivePaneID entity.PaneID,432 oldActivePaneID entity.PaneID,
410-) {433+) error {
411 log := logging.FromContext(ctx)434 log := logging.FromContext(ctx)
412 needsAttach := false435 needsAttach := false
413 436
@@ -423,12 +446,14 @@ func (c *WorkspaceCoordinator) applySplitToView(
423 log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")446 log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
424 if err := wsView.Rebuild(ctx); err != nil {447 if err := wsView.Rebuild(ctx); err != nil {
425 log.Error().Err(err).Msg("failed to rebuild workspace view")448 log.Error().Err(err).Msg("failed to rebuild workspace view")
449+ return fmt.Errorf("rebuild workspace view after split: %w", err)
426 }450 }
427 needsAttach = true451 needsAttach = true
428 }452 }
429 } else {453 } else {
430 if err := wsView.Rebuild(ctx); err != nil {454 if err := wsView.Rebuild(ctx); err != nil {
431 log.Error().Err(err).Msg("failed to rebuild workspace view")455 log.Error().Err(err).Msg("failed to rebuild workspace view")
456+ return fmt.Errorf("rebuild workspace view after split: %w", err)
432 }457 }
433 needsAttach = true458 needsAttach = true
434 }459 }
@@ -439,8 +464,10 @@ func (c *WorkspaceCoordinator) applySplitToView(
439 }464 }
440 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {465 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
441 log.Warn().Err(err).Msg("failed to set active pane in workspace view")466 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
467+ return fmt.Errorf("activate split pane: %w", err)
442 }468 }
443 wsView.FocusPane(ws.ActivePaneID)469 wsView.FocusPane(ws.ActivePaneID)
470+ return nil
444 }471 }
445 472
446 // doIncrementalStackSplit performs an incremental split around an existing stacked pane.473 // doIncrementalStackSplit performs an incremental split around an existing stacked pane.
@@ -582,6 +609,13 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
582 wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)609 wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
583 if err != nil {610 if err != nil {
584 log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")611 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()
585 return err619 return err
586 }620 }
587 621
@@ -722,6 +756,11 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
722 wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)756 wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
723 if err != nil {757 if err != nil {
724 log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")758 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()
725 return err764 return err
726 }765 }
727 766
@@ -1453,71 +1492,32 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView
1453 // StackPane adds a new pane stacked on top of the active pane.1492 // StackPane adds a new pane stacked on top of the active pane.
1454 // Uses CreateStack use case for new stacks, AddToStack for existing stacks.1493 // Uses CreateStack use case for new stacks, AddToStack for existing stacks.
1455 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {1494 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1456- log := logging.FromContext(ctx)1495+ return c.stackPaneWithURL(ctx, c.newPaneURL, true)
1496+}
1457 1497
1458- stackCtx, ok := c.prepareStackPane(ctx)1498+// StackPaneWithURL adds a new pane containing initialURL to the active stack.
1459- if !ok {1499+func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL string) error {
1460- return nil1500+ return c.stackPaneWithURL(ctx, initialURL, true)
1461- }1501+}
1462 1502
1463- // Determine if we need to create a new stack or add to existing.1503+// StackPaneWithURLWithoutOmnibox adds a stacked pane without opening the omnibox.
1464- var stackNode *entity.PaneNode1504+func (c *WorkspaceCoordinator) StackPaneWithURLWithoutOmnibox(ctx context.Context, initialURL string) error {
1465- var newPane *entity.Pane1505+ return c.stackPaneWithURL(ctx, initialURL, false)
1466- var newPaneID entity.PaneID1506+}
1467- var needsFirstPaneTitleUpdate bool
1468 1507
1469- if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {1508+func (c *WorkspaceCoordinator) stackPaneWithURL(ctx context.Context, initialURL string, notifyNewPane bool) error {
1470- // Already in a stack - use AddToStack use case1509+ log := logging.FromContext(ctx)
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
1511 1510
1512- // Update the original pane's title in the domain1511+ stackCtx, err := c.prepareStackPane(ctx)
1513- if output.OriginalNode != nil && output.OriginalNode.Pane != nil {1512+ if err != nil {
1514- output.OriginalNode.Pane.Title = stackCtx.originalTitle1513+ return err
1515- }1514+ }
1516 1515
1517- log.Debug().1516+ stackNode, newPane, needsFirstPaneTitleUpdate, err := c.createOrAddStackPane(ctx, stackCtx, initialURL)
1518- Int("stack_size", len(stackNode.Children)).1517+ if err != nil {
1519- Msg("created new stack via use case")1518+ return err
1520 }1519 }
1520+ newPaneID := newPane.ID
1521 1521
1522 // Create PaneView for the new pane1522 // Create PaneView for the new pane
1523 newPaneView := component.NewPaneView(ctx, c.widgetFactory, newPaneID, nil)1523 newPaneView := component.NewPaneView(ctx, c.widgetFactory, newPaneID, nil)
@@ -1525,9 +1525,11 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1525 stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)1525 stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
1526 1526
1527 // Add to the UI StackedView1527 // Add to the UI StackedView
1528- if err := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle); err != nil {1528+ addErr := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle)
1529- log.Error().Err(err).Msg("failed to add pane to stack")1529+ if addErr != nil {
1530- return err1530+ log.Error().Err(addErr).Msg("failed to add pane to stack")
1531+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1532+ return addErr
1531 }1533 }
1532 1534
1533 // Update the first pane's title if we just converted from leaf to stacked1535 // 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 {
1538 // Get WebView and attach1540 // Get WebView and attach
1539 wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)1541 wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
1540 if err != nil {1542 if err != nil {
1541- log.Warn().Err(err).Msg("failed to get webview for new pane")1543+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1542- } else {1544+ return err
1543- widget := c.contentCoord.WrapWidget(ctx, wv)1545+ }
1544- if widget != nil {1546+ widget := c.contentCoord.WrapWidget(ctx, wv)
1545- if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {1547+ if widget == nil {
1546- log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")1548+ err := fmt.Errorf("wrap webview for stacked pane")
1547- }1549+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1548- }1550+ return err
1549- // Load initial page for the new pane1551+ }
1550- if err := wv.LoadURI(ctx, newPane.URI); err != nil {1552+ if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
1551- log.Warn().Err(err).Str("uri", newPane.URI).Msg("failed to load initial page")1553+ log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1552- }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")
1553 }1560 }
1554 1561
1555 // Update workspace view1562 // Update workspace view
1556 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {1563 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
1557 log.Warn().Err(err).Msg("failed to set active pane")1564 log.Warn().Err(err).Msg("failed to set active pane")
1565+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1566+ return err
1558 }1567 }
1568+ stackCtx.wsView.FocusPane(newPaneID)
1559 1569
1560- stackCtx.wsView.NotifyNewPaneCreated(ctx)1570+ if notifyNewPane {
1571+ stackCtx.wsView.NotifyNewPaneCreated(ctx)
1572+ }
1561 1573
1562 // Set up title bar click and close callbacks1574 // Set up title bar click and close callbacks
1563 tr := stackCtx.wsView.TreeRenderer()1575 tr := stackCtx.wsView.TreeRenderer()
@@ -1586,30 +1598,91 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1586 return nil1598 return nil
1587 }1599 }
1588 1600
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+
1589 func (c *WorkspaceCoordinator) prepareStackPane(1646 func (c *WorkspaceCoordinator) prepareStackPane(
1590 ctx context.Context,1647 ctx context.Context,
1591-) (*stackPaneContext, bool) {1648+) (*stackPaneContext, error) {
1592 log := logging.FromContext(ctx)1649 log := logging.FromContext(ctx)
1593 if c.stackedPaneMgr == nil {1650 if c.stackedPaneMgr == nil {
1594 log.Warn().Msg("stacked pane manager not available")1651 log.Warn().Msg("stacked pane manager not available")
1595- return nil, false1652+ 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")
1596 }1669 }
1597 1670
1598 ws, wsView := c.getActiveWS()1671 ws, wsView := c.getActiveWS()
1599 if ws == nil {1672 if ws == nil {
1600 log.Warn().Msg("no active workspace")1673 log.Warn().Msg("no active workspace")
1601- return nil, false1674+ return nil, fmt.Errorf("no active workspace")
1602 }1675 }
1603 1676
1604 activeNode := ws.ActivePane()1677 activeNode := ws.ActivePane()
1605 if activeNode == nil || activeNode.Pane == nil {1678 if activeNode == nil || activeNode.Pane == nil {
1606 log.Warn().Msg("no active pane")1679 log.Warn().Msg("no active pane")
1607- return nil, false1680+ return nil, fmt.Errorf("no active pane")
1608 }1681 }
1609 1682
1610 if wsView == nil {1683 if wsView == nil {
1611 log.Warn().Msg("no workspace view")1684 log.Warn().Msg("no workspace view")
1612- return nil, false1685+ return nil, fmt.Errorf("no workspace view")
1613 }1686 }
1614 1687
1615 activePaneID := activeNode.Pane.ID1688 activePaneID := activeNode.Pane.ID
@@ -1627,7 +1700,7 @@ func (c *WorkspaceCoordinator) prepareStackPane(
1627 activeNode: activeNode,1700 activeNode: activeNode,
1628 activePaneID: activePaneID,1701 activePaneID: activePaneID,
1629 originalTitle: originalTitle,1702 originalTitle: originalTitle,
1630- }, true1703+ }, nil
1631 }1704 }
1632 1705
1633 func (c *WorkspaceCoordinator) updateFirstStackTitle(1706 func (c *WorkspaceCoordinator) updateFirstStackTitle(
@@ -1942,7 +2015,14 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input conte
1942 2015
1943 // Update UI2016 // Update UI
1944 if wsView != nil {2017 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+ }
1946 c.attachPopupWebView(ctx, wsView, input)2026 c.attachPopupWebView(ctx, wsView, input)
1947 }2027 }
1948 2028
@@ -2057,6 +2137,7 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input con
2057 2137
2058 // Revert stack conversion if we created a new stack2138 // Revert stack conversion if we created a new stack
2059 conversionInfo.revert()2139 conversionInfo.revert()
2140+ c.notifyStateChanged()
2060 2141
2061 return err2142 return err
2062 }2143 }
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 {
52 onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error52 onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
53 onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error53 onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
54 onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error54 onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55+ onVimModePulse func(ctx context.Context, fast bool)
55 onToggleHistorySidebar func(ctx context.Context) error56 onToggleHistorySidebar func(ctx context.Context) error
56 onToggleFavoritesSidebar func(ctx context.Context) error57 onToggleFavoritesSidebar func(ctx context.Context) error
57 onToggleCurrentFavorite func(ctx context.Context) error58 onToggleCurrentFavorite func(ctx context.Context) error
@@ -156,6 +157,22 @@ func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context
156 d.onOpenFloating = fn157 d.onOpenFloating = fn
157 }158 }
158 159
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+
159 func (d *KeyboardDispatcher) initActionHandlers() {176 func (d *KeyboardDispatcher) initActionHandlers() {
160 const (177 const (
161 firstTabIndex = 0178 firstTabIndex = 0
@@ -249,6 +266,13 @@ func (d *KeyboardDispatcher) initActionHandlers() {
249 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },266 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
250 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },267 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
251 input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },268 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),
252 // UI276 // UI
253 input.ActionOpenOmnibox: d.navCoord.OpenOmnibox,277 input.ActionOpenOmnibox: d.navCoord.OpenOmnibox,
254 input.ActionOpenFind: d.handleFindOpen,278 input.ActionOpenFind: d.handleFindOpen,
@@ -271,7 +295,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
271 }295 }
272 return d.onToggleHistorySidebar(ctx)296 return d.onToggleHistorySidebar(ctx)
273 },297 },
274- input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {298+ input.ActionToggleFavoritesSidebar: func(ctx context.Context) error {
275 if d.onToggleFavoritesSidebar == nil {299 if d.onToggleFavoritesSidebar == nil {
276 return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")300 return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
277 }301 }
@@ -298,6 +322,73 @@ func (d *KeyboardDispatcher) initActionHandlers() {
298 }322 }
299 }323 }
300 324
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+
301 // Dispatch routes a keyboard action to the appropriate coordinator.392 // Dispatch routes a keyboard action to the appropriate coordinator.
302 func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {393 func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
303 log := logging.FromContext(ctx)394 log := logging.FromContext(ctx)
M internal/ui/dispatcher/keyboard_test.go +185 -3
@@ -5,6 +5,9 @@ import (
5 "fmt"5 "fmt"
6 "testing"6 "testing"
7 7
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"
8 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/ui/coordinator"12 "github.com/bnema/dumber/internal/ui/coordinator"
10 "github.com/bnema/dumber/internal/ui/input"13 "github.com/bnema/dumber/internal/ui/input"
@@ -119,19 +122,19 @@ func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErro
119 ctx := context.Background()122 ctx := context.Background()
120 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })123 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
121 124
122- missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)125+ missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
123 require.Error(t, missingErr)126 require.Error(t, missingErr)
124 require.ErrorContains(t, missingErr, "favorites sidebar unavailable")127 require.ErrorContains(t, missingErr, "favorites sidebar unavailable")
125 128
126 wantErr := fmt.Errorf("favorites failed")129 wantErr := fmt.Errorf("favorites failed")
127 d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })130 d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
128- err := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)131+ err := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
129 require.Error(t, err)132 require.Error(t, err)
130 require.ErrorIs(t, err, wantErr)133 require.ErrorIs(t, err, wantErr)
131 134
132 var called bool135 var called bool
133 d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })136 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))
135 assert.True(t, called)138 assert.True(t, called)
136 }139 }
137 140
@@ -202,3 +205,182 @@ func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
202 })205 })
203 }206 }
204 }207 }
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)
61 bm.borderOverlay.RemoveCssClass(tabModeClass)61 bm.borderOverlay.RemoveCssClass(tabModeClass)
62 case input.ModeSession:62 case input.ModeSession:
63 bm.borderOverlay.RemoveCssClass(sessionModeClass)63 bm.borderOverlay.RemoveCssClass(sessionModeClass)
64+ case input.ModeVim:
65+ // Vim mode never activates the global border overlay.
66+ // No class to remove.
64 }67 }
65 68
66 // Add new mode class and set visibility69 // Add new mode class and set visibility
@@ -74,6 +77,11 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
74 case input.ModeSession:77 case input.ModeSession:
75 bm.borderOverlay.AddCssClass(sessionModeClass)78 bm.borderOverlay.AddCssClass(sessionModeClass)
76 bm.borderOverlay.SetVisible(true)79 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)
77 case input.ModeNormal:85 case input.ModeNormal:
78 bm.borderOverlay.SetVisible(false)86 bm.borderOverlay.SetVisible(false)
79 default:87 default:
M internal/ui/input/global_shortcuts.go +41 -17
@@ -31,7 +31,6 @@ type GlobalShortcutHandler struct {
31 shortcutRefs map[string]globalShortcutRegistration31 shortcutRefs map[string]globalShortcutRegistration
32 lastDispatchAt map[Action]time.Time32 lastDispatchAt map[Action]time.Time
33 heldShortcuts map[globalShortcutHoldKey]struct{}33 heldShortcuts map[globalShortcutHoldKey]struct{}
34- shortcutAction *gtk.NamedAction
35 globalAction *gio.SimpleAction34 globalAction *gio.SimpleAction
36 globalActionCb func(gio.SimpleAction, uintptr)35 globalActionCb func(gio.SimpleAction, uintptr)
37 globalActionHandlerID uint36 globalActionHandlerID uint
@@ -113,10 +112,16 @@ func NewGlobalShortcutHandler(
113 }112 }
114 }113 }
115 114
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.)
117 // so they work even when WebView has focus.116 // 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.
118 shortcuts := NewShortcutSet(ctx, workspace, session)120 shortcuts := NewShortcutSet(ctx, workspace, session)
119 for binding, action := range shortcuts.Global {121 for binding, action := range shortcuts.Global {
122+ if !shouldRegisterGTKGlobalShortcut(action) {
123+ continue
124+ }
120 if _, exists := h.registered[binding]; exists {125 if _, exists := h.registered[binding]; exists {
121 continue126 continue
122 }127 }
@@ -139,7 +144,6 @@ func (h *GlobalShortcutHandler) configureGlobalControllers() {
139 // Set global scope - this is the key to making shortcuts work144 // Set global scope - this is the key to making shortcuts work
140 // even when WebView has focus.145 // even when WebView has focus.
141 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)146 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
142- h.shortcutAction = gtk.NewNamedAction(globalShortcutActionFullName)
143 h.globalAction = gio.NewSimpleAction(globalShortcutActionName, glib.NewVariantType("s"))147 h.globalAction = gio.NewSimpleAction(globalShortcutActionName, glib.NewVariantType("s"))
144 if h.globalAction != nil {148 if h.globalAction != nil {
145 h.globalActionCb = func(_ gio.SimpleAction, parameter uintptr) {149 h.globalActionCb = func(_ gio.SimpleAction, parameter uintptr) {
@@ -212,23 +216,26 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
212 return false216 return false
213 }217 }
214 218
215- if h.shortcutAction == nil {219+ // Create trigger for this key combination.
220+ trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
221+ if trigger == nil {
216 logging.FromContext(h.ctx).Error().222 logging.FromContext(h.ctx).Error().
217 Uint("keyval", keyval).223 Uint("keyval", keyval).
218- Msg("global shortcut action is unavailable")224+ Msg("failed to create keyval trigger")
219 return false225 return false
220 }226 }
221 227
222- // Create trigger for this key combination.228+ // GtkShortcut takes ownership of its action. Each shortcut needs its own
223- trigger := gtk.NewKeyvalTrigger(keyval, modifiers)229+ // named action even though they all dispatch through the same window action.
224- if trigger == nil {230+ shortcutAction := gtk.NewNamedAction(globalShortcutActionFullName)
231+ if shortcutAction == nil {
225 logging.FromContext(h.ctx).Error().232 logging.FromContext(h.ctx).Error().
226 Uint("keyval", keyval).233 Uint("keyval", keyval).
227- Msg("failed to create keyval trigger")234+ Msg("failed to create global shortcut action")
228 return false235 return false
229 }236 }
230 237
231- shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &h.shortcutAction.ShortcutAction)238+ shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
232 if shortcut == nil {239 if shortcut == nil {
233 logging.FromContext(h.ctx).Error().240 logging.FromContext(h.ctx).Error().
234 Uint("keyval", keyval).241 Uint("keyval", keyval).
@@ -292,6 +299,14 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action,
292 Msg("inactive window global shortcut callback ignored")299 Msg("inactive window global shortcut callback ignored")
293 return false300 return false
294 }301 }
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+ }
295 eventInfo := h.inspectCurrentShortcutEvent()310 eventInfo := h.inspectCurrentShortcutEvent()
296 if !shouldDispatchGlobalShortcutEvent(eventInfo) {311 if !shouldDispatchGlobalShortcutEvent(eventInfo) {
297 appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).312 appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
@@ -318,8 +333,7 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action,
318 // Mode-enter/exit actions go through KeyboardHandler for modal state.333 // Mode-enter/exit actions go through KeyboardHandler for modal state.
319 if isModeAction(actionToDispatch) {334 if isModeAction(actionToDispatch) {
320 if h.kbHandler != nil {335 if h.kbHandler != nil {
321- h.kbHandler.DispatchAction(actionToDispatch)336+ return h.kbHandler.DispatchAction(actionToDispatch)
322- return true
323 }337 }
324 log.Warn().338 log.Warn().
325 Str("action", string(actionToDispatch)).339 Str("action", string(actionToDispatch)).
@@ -481,6 +495,14 @@ func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, act
481 return evt495 return evt
482 }496 }
483 497
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+
484 func globalShortcutActionMap() map[string]Action {506 func globalShortcutActionMap() map[string]Action {
485 return map[string]Action{507 return map[string]Action{
486 "toggle_floating_pane": ActionToggleFloatingPane,508 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -495,8 +517,8 @@ func globalShortcutActionMap() map[string]Action {
495 "consume-or-expel-down": ActionConsumeOrExpelDown,517 "consume-or-expel-down": ActionConsumeOrExpelDown,
496 "toggle_history_systemview": ActionToggleHistorySystemView,518 "toggle_history_systemview": ActionToggleHistorySystemView,
497 "toggle-history-systemview": ActionToggleHistorySystemView,519 "toggle-history-systemview": ActionToggleHistorySystemView,
498- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,520+ "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
499- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,521+ "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
500 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,522 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
501 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,523 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
502 "toggle_config_systemview": ActionToggleConfigSystemView,524 "toggle_config_systemview": ActionToggleConfigSystemView,
@@ -563,6 +585,9 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
563 585
564 shortcuts := NewShortcutSet(ctx, workspace, session)586 shortcuts := NewShortcutSet(ctx, workspace, session)
565 for binding, action := range shortcuts.Global {587 for binding, action := range shortcuts.Global {
588+ if !shouldRegisterGTKGlobalShortcut(action) {
589+ continue
590+ }
566 if _, exists := h.registered[binding]; exists {591 if _, exists := h.registered[binding]; exists {
567 continue592 continue
568 }593 }
@@ -610,7 +635,6 @@ func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
610 }635 }
611 h.controller = nil636 h.controller = nil
612 h.releaseController = nil637 h.releaseController = nil
613- h.shortcutAction = nil
614 h.globalAction = nil638 h.globalAction = nil
615 h.globalActionCb = nil639 h.globalActionCb = nil
616 h.globalActionHandlerID = 0640 h.globalActionHandlerID = 0
@@ -709,7 +733,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
709 ActionToggleFullscreen,733 ActionToggleFullscreen,
710 ActionToggleFloatingPane,734 ActionToggleFloatingPane,
711 ActionToggleHistorySystemView,735 ActionToggleHistorySystemView,
712- ActionToggleFavoritesSystemView,736+ ActionToggleFavoritesSidebar,
713 ActionToggleCurrentPageFavorite,737 ActionToggleCurrentPageFavorite,
714 ActionToggleConfigSystemView,738 ActionToggleConfigSystemView,
715 ActionCopyURL,739 ActionCopyURL,
@@ -816,7 +840,7 @@ func formatEventType(eventType gdk.EventType) string {
816 840
817 func isModeAction(action Action) bool {841 func isModeAction(action Action) bool {
818 switch action {842 switch action {
819- case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:843+ case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionExitMode:
820 return true844 return true
821 default:845 default:
822 return false846 return false
M internal/ui/input/global_shortcuts_test.go +85 -4
@@ -1,6 +1,7 @@
1 package input1 package input
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -9,6 +10,27 @@ import (
9 "github.com/bnema/puregotk/v4/gtk"10 "github.com/bnema/puregotk/v4/gtk"
10 )11 )
11 12
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+
12 func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) {34 func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) {
13 h := &GlobalShortcutHandler{35 h := &GlobalShortcutHandler{
14 registered: make(map[KeyBinding]Action),36 registered: make(map[KeyBinding]Action),
@@ -33,6 +55,68 @@ func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *test
33 }55 }
34 }56 }
35 57
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+
36 func TestGlobalShortcutIDIncludesBinding(t *testing.T) {120 func TestGlobalShortcutIDIncludesBinding(t *testing.T) {
37 first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}, 0)121 first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
38 second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)122 second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
@@ -153,9 +237,6 @@ func TestGlobalShortcutHandlerDetachForDestroyClearsRetainedState(t *testing.T)
153 if h.releaseController != nil {237 if h.releaseController != nil {
154 t.Fatal("releaseController should be cleared during destroy detach")238 t.Fatal("releaseController should be cleared during destroy detach")
155 }239 }
156- if h.shortcutAction != nil {
157- t.Fatal("shortcutAction should be cleared during destroy detach")
158- }
159 if h.globalAction != nil {240 if h.globalAction != nil {
160 t.Fatal("globalAction should be cleared during destroy detach")241 t.Fatal("globalAction should be cleared during destroy detach")
161 }242 }
@@ -230,7 +311,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
230 ActionFindPrev,311 ActionFindPrev,
231 ActionOpenDevTools,312 ActionOpenDevTools,
232 ActionToggleFloatingPane,313 ActionToggleFloatingPane,
233- ActionToggleFavoritesSystemView,314+ ActionToggleFavoritesSidebar,
234 ActionToggleCurrentPageFavorite,315 ActionToggleCurrentPageFavorite,
235 ActionToggleConfigSystemView,316 ActionToggleConfigSystemView,
236 ActionCopyURL,317 ActionCopyURL,
M internal/ui/input/keyboard.go +373 -22
@@ -36,6 +36,28 @@ import (
36 // Return an error if the action fails.36 // Return an error if the action fails.
37 type ActionHandler func(ctx context.Context, action Action) error37 type ActionHandler func(ctx context.Context, action Action) error
38 38
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+
39 // AccentHandler handles long-press accent detection.61 // AccentHandler handles long-press accent detection.
40 // Called for character keys that may have accent variants.62 // Called for character keys that may have accent variants.
41 type AccentHandler interface {63 type AccentHandler interface {
@@ -58,8 +80,10 @@ type KeyboardHandler struct {
58 workspace *entity.WorkspaceConfig80 workspace *entity.WorkspaceConfig
59 session *entity.SessionConfig81 session *entity.SessionConfig
60 82
61- // Action handler callback83+ // Action handler callbacks.
62- onAction ActionHandler84+ onAction ActionHandler
85+ onVimScrollLifecycle VimScrollLifecycleHandler
86+ onPageFocusNavigation PageFocusNavigationHandler
63 // Optional routing callback that determines how a key should be handled.87 // Optional routing callback that determines how a key should be handled.
64 // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused88 // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused
65 // widget handle it), or RouteAccentDetection (long-press accent for GTK entries).89 // widget handle it), or RouteAccentDetection (long-press accent for GTK entries).
@@ -68,6 +92,9 @@ type KeyboardHandler struct {
68 accentHandler AccentHandler92 accentHandler AccentHandler
69 // Optional escape hook for app-level overlays93 // Optional escape hook for app-level overlays
70 onEscape func(ctx context.Context) bool94 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
71 98
72 // GTK controller (nil until attached)99 // GTK controller (nil until attached)
73 controller *gtk.EventControllerKey100 controller *gtk.EventControllerKey
@@ -82,6 +109,18 @@ type KeyboardHandler struct {
82 activePressedActions map[Action]uint109 activePressedActions map[Action]uint
83 ctx context.Context110 ctx context.Context
84 mu sync.RWMutex111 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
85 }124 }
86 125
87 // NewKeyboardHandler creates a new keyboard handler.126 // NewKeyboardHandler creates a new keyboard handler.
@@ -90,14 +129,26 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
90 129
91 log.Debug().Msg("creating keyboard handler")130 log.Debug().Msg("creating keyboard handler")
92 131
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+ }
93 h := &KeyboardHandler{137 h := &KeyboardHandler{
94- shortcuts: NewShortcutSet(ctx, workspace, session),138+ shortcuts: shortcuts,
95 modal: NewModalState(ctx),139 modal: NewModalState(ctx),
96 workspace: workspace,140 workspace: workspace,
97 session: session,141 session: session,
98 activePressedActions: make(map[Action]uint),142 activePressedActions: make(map[Action]uint),
99 ctx: ctx,143 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),
100 }150 }
151+ h.SetOnModeChange(nil)
101 152
102 return h153 return h
103 }154 }
@@ -109,11 +160,35 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
109 h.onAction = fn160 h.onAction = fn
110 }161 }
111 162
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+
112 // ReloadShortcuts rebuilds the shortcut set from new config values.187 // ReloadShortcuts rebuilds the shortcut set from new config values.
113 // This enables hot-reloading of keybindings without restarting.188 // This enables hot-reloading of keybindings without restarting.
114 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {189 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
190+ h.stopVimScrollRepeat()
115 h.mu.Lock()191 h.mu.Lock()
116- defer h.mu.Unlock()
117 192
118 log := logging.FromContext(ctx)193 log := logging.FromContext(ctx)
119 log.Debug().Msg("reloading shortcuts from config")194 log.Debug().Msg("reloading shortcuts from config")
@@ -121,6 +196,14 @@ func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity
121 h.shortcuts = NewShortcutSet(ctx, workspace, session)196 h.shortcuts = NewShortcutSet(ctx, workspace, session)
122 h.workspace = workspace197 h.workspace = workspace
123 h.session = session198 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)
124 }207 }
125 208
126 // SetOnModeChange sets the callback for mode changes (for UI updates).209 // SetOnModeChange sets the callback for mode changes (for UI updates).
@@ -134,6 +217,11 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
134 } else if from == ModeNormal {217 } else if from == ModeNormal {
135 h.setControllerPhase(gtk.PhaseCaptureValue)218 h.setControllerPhase(gtk.PhaseCaptureValue)
136 }219 }
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+ }
137 // Forward to app-level callback225 // Forward to app-level callback
138 if fn != nil {226 if fn != nil {
139 fn(from, to)227 fn(from, to)
@@ -212,16 +300,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
212 // so app shortcuts still work.300 // so app shortcuts still work.
213 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)301 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)
214 302
215- // Wire GTK main thread scheduler for modal timeouts. Timer goroutines303+ // Wire GTK main thread schedulers for modal and sequence ambiguity timeouts.
216- // must dispatch ExitMode to the GTK thread because onModeChange may304+ // Timer goroutines must dispatch back to the GTK thread because callbacks
217- // call setControllerPhase (a GTK operation).305+ // may touch GTK widgets / EventController phase.
218- h.modal.SetMainThreadScheduler(func(fn func()) {306+ h.installGTKMainThreadSchedulers()
219- cb := glib.SourceFunc(func(_ uintptr) bool {
220- fn()
221- return false
222- })
223- glib.IdleAdd(&cb, 0)
224- })
225 307
226 // Connect key pressed handler (retain callback to prevent GC).308 // Connect key pressed handler (retain callback to prevent GC).
227 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)309 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
@@ -243,6 +325,24 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
243 log.Debug().Msg("keyboard handler attached to window")325 log.Debug().Msg("keyboard handler attached to window")
244 }326 }
245 327
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+
246 // Detach removes the keyboard handler from a live GTK window and releases its346 // Detach removes the keyboard handler from a live GTK window and releases its
247 // retained GTK signal callbacks.347 // retained GTK signal callbacks.
248 func (h *KeyboardHandler) Detach() {348 func (h *KeyboardHandler) Detach() {
@@ -257,8 +357,11 @@ func (h *KeyboardHandler) DetachForDestroy() {
257 }357 }
258 358
259 func (h *KeyboardHandler) detach(removeController bool) {359 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()
260 h.mu.Lock()364 h.mu.Lock()
261- defer h.mu.Unlock()
262 if h.controller != nil && h.keyPressedHandlerID != 0 {365 if h.controller != nil && h.keyPressedHandlerID != 0 {
263 h.controller.DisconnectSignal(h.keyPressedHandlerID)366 h.controller.DisconnectSignal(h.keyPressedHandlerID)
264 }367 }
@@ -275,6 +378,7 @@ func (h *KeyboardHandler) detach(removeController bool) {
275 h.keyPressedHandlerID = 0378 h.keyPressedHandlerID = 0
276 h.keyReleasedHandlerID = 0379 h.keyReleasedHandlerID = 0
277 h.activePressedActions = nil380 h.activePressedActions = nil
381+ h.mu.Unlock()
278 }382 }
279 383
280 // handleKeyPress processes a key press event.384 // handleKeyPress processes a key press event.
@@ -294,17 +398,38 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
294 }398 }
295 399
296 modifiers := Modifier(state) & modifierMask400 modifiers := Modifier(state) & modifierMask
401+ mode := h.modal.Mode()
297 402
298 // Escape in normal mode: check app-level escape hook first403 // 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 {
300 if onEscape != nil && onEscape(h.ctx) {405 if onEscape != nil && onEscape(h.ctx) {
301 return true406 return true
302 }407 }
303 }408 }
304 409
410+ // Escape / Enter should always leave modal modes even if the current config
411+ // omits explicit confirm/cancel bindings.
412+ if mode != ModeNormal && modifiers == 0 {
413+ switch keyval {
414+ case uint(gdk.KEY_Escape), uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
415+ if mode == ModeVim {
416+ h.stopVimScrollRepeat()
417+ }
418+ h.modal.ExitMode(h.ctx)
419+ return true
420+ }
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+
305 // Determine routing for this key event430 // Determine routing for this key event
306 route := RouteHandleShortcuts // default: process through shortcut system431 route := RouteHandleShortcuts // default: process through shortcut system
307- if routeKey != nil && h.modal.Mode() == ModeNormal {432+ if routeKey != nil && mode == ModeNormal {
308 route = routeKey(KeyContext{433 route = routeKey(KeyContext{
309 Keyval: keyval,434 Keyval: keyval,
310 Keycode: keycode,435 Keycode: keycode,
@@ -332,28 +457,153 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
332 // Fall through to shortcut processing below457 // Fall through to shortcut processing below
333 }458 }
334 459
460+ // Vim-mode multi-key sequences run before legacy single-chord lookup.
461+ if h.feedVimModeSequence(keyval, state) {
462+ return true
463+ }
464+
335 // --- Shortcut processing path ---465 // --- Shortcut processing path ---
336 466
337 // Normalize uppercase letters for consistent binding lookup467 // Normalize uppercase letters for consistent binding lookup
338 keyval = normalizeKeyval(keyval)468 keyval = normalizeKeyval(keyval)
339 469
340 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}470 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
341- mode := h.modal.Mode()
342 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)471 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
472+ return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers)
473+}
343 474
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 {
344 if !found {483 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+ }
345 return mode != ModeNormal // Consume unrecognized keys in modal mode488 return mode != ModeNormal // Consume unrecognized keys in modal mode
346 }489 }
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+ }
347 if h.suppressHeldAction(action, keyval) {496 if h.suppressHeldAction(action, keyval) {
348 log.Trace().497 log.Trace().
349 Str("action", string(action)).498 Str("action", string(action)).
350 Msg("held keyboard action repeat suppressed")499 Msg("held keyboard action repeat suppressed")
351 return true500 return true
352 }501 }
353-
354 return h.dispatchAction(action, mode)502 return h.dispatchAction(action, mode)
355 }503 }
356 504
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+
357 // tryAccentDetection starts long-press detection for accent-eligible keys.607 // tryAccentDetection starts long-press detection for accent-eligible keys.
358 // It is used only for the RouteAccentDetection route (GTK Entry widgets such as608 // It is used only for the RouteAccentDetection route (GTK Entry widgets such as
359 // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;609 // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;
@@ -382,6 +632,43 @@ func normalizeKeyval(keyval uint) uint {
382 return keyval632 return keyval
383 }633 }
384 634
635+func (h *KeyboardHandler) handlePageFocusNavigation(keyval uint, modifiers Modifier) bool {
636+ h.mu.RLock()
637+ handler := h.onPageFocusNavigation
638+ h.mu.RUnlock()
639+ if handler == nil {
640+ return false
641+ }
642+ backward := keyval == uint(gdk.KEY_ISO_Left_Tab) || modifiers == ModShift
643+ return handler(h.ctx, backward)
644+}
645+
646+func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool {
647+ if mode != ModeNormal && mode != ModeVim {
648+ return false
649+ }
650+ switch keyval {
651+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
652+ return modifiers == ModNone || modifiers == ModShift
653+ default:
654+ return false
655+ }
656+}
657+
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+
385 // dispatchAction dispatches the action and handles mode-related logic.672 // dispatchAction dispatches the action and handles mode-related logic.
386 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {673 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
387 if h.handleModeAction(action) {674 if h.handleModeAction(action) {
@@ -402,6 +689,9 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
402 if mode == ModeResize && isResizeAction(action) {689 if mode == ModeResize && isResizeAction(action) {
403 h.modal.ResetTimeout(h.ctx)690 h.modal.ResetTimeout(h.ctx)
404 }691 }
692+ if mode == ModeVim && isVimScrollAction(action) {
693+ h.modal.ResetTimeout(h.ctx)
694+ }
405 695
406 if mode != ModeNormal && ShouldAutoExitMode(action) {696 if mode != ModeNormal && ShouldAutoExitMode(action) {
407 h.modal.ExitMode(h.ctx)697 h.modal.ExitMode(h.ctx)
@@ -436,6 +726,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool {
436 ActionEnterPaneMode,726 ActionEnterPaneMode,
437 ActionEnterSessionMode,727 ActionEnterSessionMode,
438 ActionEnterResizeMode,728 ActionEnterResizeMode,
729+ ActionEnterVimMode,
439 ActionNewTab,730 ActionNewTab,
440 ActionRenameTab,731 ActionRenameTab,
441 ActionSplitRight,732 ActionSplitRight,
@@ -512,6 +803,20 @@ func isResizeAction(action Action) bool {
512 }803 }
513 }804 }
514 805
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+
515 func (h *KeyboardHandler) handleModeAction(action Action) bool {820 func (h *KeyboardHandler) handleModeAction(action Action) bool {
516 h.mu.RLock()821 h.mu.RLock()
517 workspace := h.workspace822 workspace := h.workspace
@@ -551,7 +856,22 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
551 }856 }
552 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)857 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
553 return true858 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
554 case ActionExitMode:871 case ActionExitMode:
872+ if h.modal.Mode() == ModeVim {
873+ h.stopVimScrollRepeat()
874+ }
555 h.modal.ExitMode(h.ctx)875 h.modal.ExitMode(h.ctx)
556 return true876 return true
557 default:877 default:
@@ -598,33 +918,64 @@ func (h *KeyboardHandler) EnterSessionMode() {
598 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)918 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
599 }919 }
600 920
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+
601 // ExitMode programmatically exits modal mode.934 // ExitMode programmatically exits modal mode.
602 // Useful for testing or programmatic mode changes.935 // Useful for testing or programmatic mode changes.
603 func (h *KeyboardHandler) ExitMode() {936 func (h *KeyboardHandler) ExitMode() {
604 h.modal.ExitMode(h.ctx)937 h.modal.ExitMode(h.ctx)
605 }938 }
606 939
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+
607 // DispatchAction processes an action externally triggered (e.g., by a global950 // DispatchAction processes an action externally triggered (e.g., by a global
608 // shortcut). Mode-enter actions update modal state; other actions are forwarded951 // shortcut). Mode-enter actions update modal state; other actions are forwarded
609-// to the registered action handler.952+// to the registered action handler. It returns whether the action was consumed.
610-func (h *KeyboardHandler) DispatchAction(action Action) {953+func (h *KeyboardHandler) DispatchAction(action Action) bool {
611 mode := h.modal.Mode()954 mode := h.modal.Mode()
955+ if h.shouldPassthroughVimModeActivation(action, mode) {
956+ return false
957+ }
612 h.dispatchAction(action, mode)958 h.dispatchAction(action, mode)
959+ return true
613 }960 }
614 961
615 // handleKeyRelease processes a key release event for accent detection.962 // handleKeyRelease processes a key release event for accent detection.
616 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {963 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
964+ keyval = normalizeKeyval(keyval)
617 h.mu.Lock()965 h.mu.Lock()
618 accentHandler := h.accentHandler966 accentHandler := h.accentHandler
619 if len(h.activePressedActions) > 0 {967 if len(h.activePressedActions) > 0 {
620- keyval = normalizeKeyval(keyval)
621 for action, pressedKeyval := range h.activePressedActions {968 for action, pressedKeyval := range h.activePressedActions {
622 if pressedKeyval == keyval {969 if pressedKeyval == keyval {
623 delete(h.activePressedActions, action)970 delete(h.activePressedActions, action)
624 }971 }
625 }972 }
626 }973 }
974+ stopVimScroll := h.vimScrollRepeatKeyval == keyval
627 h.mu.Unlock()975 h.mu.Unlock()
976+ if stopVimScroll {
977+ h.stopVimScrollRepeat()
978+ }
628 979
629 if accentHandler == nil {980 if accentHandler == nil {
630 return981 return
M internal/ui/input/keyboard_test.go +787 -0
@@ -3,9 +3,11 @@ package input
3 import (3 import (
4 "context"4 "context"
5 "testing"5 "testing"
6+ "time"
6 7
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/puregotk/v4/gdk"9 "github.com/bnema/puregotk/v4/gdk"
10+ "github.com/bnema/puregotk/v4/glib"
9 "github.com/bnema/puregotk/v4/gtk"11 "github.com/bnema/puregotk/v4/gtk"
10 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
11 )13 )
@@ -27,6 +29,10 @@ func newTestSession() *entity.SessionConfig {
27 }29 }
28 }30 }
29 31
32+type fakeModalTimer struct{}
33+
34+func (*fakeModalTimer) Stop() bool { return true }
35+
30 // stubAccentHandler is a test stub for the AccentHandler interface.36 // stubAccentHandler is a test stub for the AccentHandler interface.
31 type stubAccentHandler struct {37 type stubAccentHandler struct {
32 onKeyPressedResult bool38 onKeyPressedResult bool
@@ -362,3 +368,784 @@ func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T
362 t.Fatal("ActionResizeIncreaseLeft should not be suppressed")368 t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
363 }369 }
364 }370 }
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 (
8 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
9 )9 )
10 10
11+type modalTimer interface {
12+ Stop() bool
13+}
14+
11 // Mode represents the current input mode.15 // Mode represents the current input mode.
12 type Mode int16 type Mode int
13 17
@@ -22,6 +26,8 @@ const (
22 ModeSession26 ModeSession
23 // ModeResize is the modal pane resizing mode.27 // ModeResize is the modal pane resizing mode.
24 ModeResize28 ModeResize
29+ // ModeVim is the modal Vim-style scrolling mode.
30+ ModeVim
25 )31 )
26 32
27 // String returns a human-readable mode name.33 // String returns a human-readable mode name.
@@ -37,6 +43,8 @@ func (m Mode) String() string {
37 return "session"43 return "session"
38 case ModeResize:44 case ModeResize:
39 return "resize"45 return "resize"
46+ case ModeVim:
47+ return "vim"
40 default:48 default:
41 return "unknown"49 return "unknown"
42 }50 }
@@ -53,6 +61,8 @@ func (m Mode) DisplayName() string {
53 return "SESSION MODE"61 return "SESSION MODE"
54 case ModeResize:62 case ModeResize:
55 return "RESIZE MODE"63 return "RESIZE MODE"
64+ case ModeVim:
65+ return "VIM MODE"
56 default:66 default:
57 return ""67 return ""
58 }68 }
@@ -62,9 +72,11 @@ func (m Mode) DisplayName() string {
62 type ModalState struct {72 type ModalState struct {
63 mode Mode73 mode Mode
64 timeout time.Duration74 timeout time.Duration
65- timer *time.Timer75+ timer modalTimer
66 timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks76 timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks
67 77
78+ afterFunc func(time.Duration, func()) modalTimer
79+
68 // Callback for mode changes (called synchronously under lock).80 // Callback for mode changes (called synchronously under lock).
69 onModeChange func(from, to Mode)81 onModeChange func(from, to Mode)
70 82
@@ -84,10 +96,16 @@ func NewModalState(ctx context.Context) *ModalState {
84 log.Debug().Msg("creating modal state")96 log.Debug().Msg("creating modal state")
85 return &ModalState{97 return &ModalState{
86 mode: ModeNormal,98 mode: ModeNormal,
99+ afterFunc: func(d time.Duration, fn func()) modalTimer { return time.AfterFunc(d, fn) },
87 scheduleOnMainThread: func(fn func()) { fn() },100 scheduleOnMainThread: func(fn func()) { fn() },
88 }101 }
89 }102 }
90 103
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+
91 // SetMainThreadScheduler sets the function used to dispatch timer callbacks109 // SetMainThreadScheduler sets the function used to dispatch timer callbacks
92 // to the GTK main thread. In the real app this should be a glib.IdleAdd110 // to the GTK main thread. In the real app this should be a glib.IdleAdd
93 // wrapper. In tests, the default (direct execution) is used.111 // wrapper. In tests, the default (direct execution) is used.
@@ -223,7 +241,7 @@ func (m *ModalState) startTimeoutLocked(ctx context.Context, timeout time.Durati
223 m.timerGen++241 m.timerGen++
224 gen := m.timerGen242 gen := m.timerGen
225 schedule := m.scheduleOnMainThread243 schedule := m.scheduleOnMainThread
226- m.timer = time.AfterFunc(timeout, func() {244+ m.timer = m.afterFunc(timeout, func() {
227 schedule(func() {245 schedule(func() {
228 // Ignore stale callback: time.Timer.Stop can race with the246 // Ignore stale callback: time.Timer.Stop can race with the
229 // timer firing, so a canceled timer's callback may still run.247 // timer firing, so a canceled timer's callback may still run.
M internal/ui/input/shortcuts.go +76 -4
@@ -8,6 +8,7 @@ import (
8 "strings"8 "strings"
9 9
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/vimkeys"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/gdk"13 "github.com/bnema/puregotk/v4/gdk"
13 )14 )
@@ -190,7 +191,7 @@ const (
190 ActionToggleFullscreen Action = "toggle_fullscreen"191 ActionToggleFullscreen Action = "toggle_fullscreen"
191 ActionToggleFloatingPane Action = "toggle_floating_pane"192 ActionToggleFloatingPane Action = "toggle_floating_pane"
192 ActionToggleHistorySystemView Action = "toggle_history_systemview"193 ActionToggleHistorySystemView Action = "toggle_history_systemview"
193- ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"194+ ActionToggleFavoritesSidebar Action = "toggle_favorites_sidebar"
194 ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"195 ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
195 ActionToggleConfigSystemView Action = "toggle_config_systemview"196 ActionToggleConfigSystemView Action = "toggle_config_systemview"
196 197
@@ -202,6 +203,15 @@ const (
202 203
203 // Application204 // Application
204 ActionQuit Action = "quit"205 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"
205 )215 )
206 216
207 const floatingProfileActionPrefix = "open_floating_profile:"217 const floatingProfileActionPrefix = "open_floating_profile:"
@@ -222,6 +232,10 @@ type ShortcutSet struct {
222 SessionMode ShortcutTable232 SessionMode ShortcutTable
223 // ResizeMode shortcuts are only active in resize mode.233 // ResizeMode shortcuts are only active in resize mode.
224 ResizeMode ShortcutTable234 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
225 }239 }
226 240
227 // NewShortcutSet creates a ShortcutSet from workspace and session configuration.241 // NewShortcutSet creates a ShortcutSet from workspace and session configuration.
@@ -235,6 +249,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
235 PaneMode: make(ShortcutTable),249 PaneMode: make(ShortcutTable),
236 SessionMode: make(ShortcutTable),250 SessionMode: make(ShortcutTable),
237 ResizeMode: make(ShortcutTable),251 ResizeMode: make(ShortcutTable),
252+ VimMode: make(ShortcutTable),
238 }253 }
239 254
240 set.buildGlobalShortcutsFromParts(ctx, workspace, session)255 set.buildGlobalShortcutsFromParts(ctx, workspace, session)
@@ -242,6 +257,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
242 set.buildTabModeShortcuts(ctx, workspace)257 set.buildTabModeShortcuts(ctx, workspace)
243 set.buildPaneModeShortcuts(ctx, workspace)258 set.buildPaneModeShortcuts(ctx, workspace)
244 set.buildResizeModeShortcuts(ctx, workspace)259 set.buildResizeModeShortcuts(ctx, workspace)
260+ set.buildVimModeShortcuts(ctx, workspace)
245 }261 }
246 if session != nil {262 if session != nil {
247 set.buildSessionModeShortcuts(ctx, session)263 set.buildSessionModeShortcuts(ctx, session)
@@ -253,6 +269,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
253 Int("pane", len(set.PaneMode)).269 Int("pane", len(set.PaneMode)).
254 Int("resize", len(set.ResizeMode)).270 Int("resize", len(set.ResizeMode)).
255 Int("session", len(set.SessionMode)).271 Int("session", len(set.SessionMode)).
272+ Int("vim", len(set.VimMode)).
256 Msg("shortcuts registered")273 Msg("shortcuts registered")
257 274
258 return set275 return set
@@ -288,6 +305,30 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.
288 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")305 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
289 }306 }
290 307
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+
291 func (s *ShortcutSet) registerActivationShortcutsFromParts(332 func (s *ShortcutSet) registerActivationShortcutsFromParts(
292 ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,333 ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
293 ) {334 ) {
@@ -308,6 +349,16 @@ func (s *ShortcutSet) registerActivationShortcutsFromParts(
308 }349 }
309 return350 return
310 }351 }
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+ }
311 if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {362 if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
312 s.Global[binding] = ActionEnterTabMode363 s.Global[binding] = ActionEnterTabMode
313 log.Trace().364 log.Trace().
@@ -568,8 +619,8 @@ var configActionToAction = map[string]Action{
568 "toggle-floating-pane": ActionToggleFloatingPane,619 "toggle-floating-pane": ActionToggleFloatingPane,
569 "toggle_history_systemview": ActionToggleHistorySystemView,620 "toggle_history_systemview": ActionToggleHistorySystemView,
570 "toggle-history-systemview": ActionToggleHistorySystemView,621 "toggle-history-systemview": ActionToggleHistorySystemView,
571- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,622+ "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
572- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,623+ "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
573 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,624 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
574 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,625 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
575 "toggle_config_systemview": ActionToggleConfigSystemView,626 "toggle_config_systemview": ActionToggleConfigSystemView,
@@ -642,6 +693,20 @@ var configActionToAction = map[string]Action{
642 693
643 // Session actions694 // Session actions
644 "session-manager": ActionOpenSessionManager,695 "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,
645 }710 }
646 711
647 // FloatingProfileTarget carries the session identity and URL for a floating profile action.712 // FloatingProfileTarget carries the session identity and URL for a floating profile action.
@@ -791,7 +856,9 @@ func stringToKeyval(s string) (uint, bool) {
791 }856 }
792 857
793 // Lookup finds an action for the given key binding in the appropriate table.858 // 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.
795 func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {862 func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
796 // Normalize the binding modifiers863 // Normalize the binding modifiers
797 binding.Modifiers &= modifierMask864 binding.Modifiers &= modifierMask
@@ -807,12 +874,17 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
807 modeTable = s.ResizeMode874 modeTable = s.ResizeMode
808 case ModeSession:875 case ModeSession:
809 modeTable = s.SessionMode876 modeTable = s.SessionMode
877+ case ModeVim:
878+ modeTable = s.VimMode
810 }879 }
811 880
812 if modeTable != nil {881 if modeTable != nil {
813 if action, ok := modeTable[binding]; ok {882 if action, ok := modeTable[binding]; ok {
814 return action, true883 return action, true
815 }884 }
885+ if mode == ModeVim {
886+ return "", false
887+ }
816 }888 }
817 889
818 // Fall back to global890 // Fall back to global
M internal/ui/input/shortcuts_test.go +143 -2
@@ -1,8 +1,10 @@
1 package input1 package input
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
7+ "github.com/bnema/dumber/internal/domain/entity"
6 "github.com/bnema/puregotk/v4/gdk"8 "github.com/bnema/puregotk/v4/gdk"
7 )9 )
8 10
@@ -401,7 +403,7 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
401 want Action403 want Action
402 }{404 }{
403 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},405 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
404- {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},406+ {name: "toggle_favorites_sidebar", want: ActionToggleFavoritesSidebar},
405 {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},407 {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},
406 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},408 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
407 }409 }
@@ -421,7 +423,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
421 want Action423 want Action
422 }{424 }{
423 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},425 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
424- {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},426+ {name: "toggle-favorites-sidebar", want: ActionToggleFavoritesSidebar},
425 {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},427 {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
426 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},428 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
427 }429 }
@@ -462,3 +464,142 @@ func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) {
462 })464 })
463 }465 }
464 }466 }
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 {
51 `51 `
52 }52 }
53 53
54+const defaultTransitionDurationMs = 120
55+
54 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.56 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
55 // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.57 // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
56 // Uses default mode colors.58 // Uses default mode colors.
@@ -58,9 +60,16 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
58 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())60 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
59 }61 }
60 62
61-// GenerateCSSFull creates GTK4 CSS using all provided configuration.63+// GenerateCSSFull creates GTK4 CSS using all provided configuration with the
64+// default transition duration.
62 // Text scaling is handled via GtkSettings; CSS here stays scale-independent.65 // Text scaling is handled via GtkSettings; CSS here stays scale-independent.
63 func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColors) string {66 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 {
64 defaults := DefaultFontConfig()73 defaults := DefaultFontConfig()
65 fonts = FontConfig{74 fonts = FontConfig{
66 SansFont: Coalesce(fonts.SansFont, defaults.SansFont),75 SansFont: Coalesce(fonts.SansFont, defaults.SansFont),
@@ -102,6 +111,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
102 sb.WriteString(generateStackedPaneCSS(p))111 sb.WriteString(generateStackedPaneCSS(p))
103 sb.WriteString("\n")112 sb.WriteString("\n")
104 113
114+ // Vim mode styling
115+ sb.WriteString(generateVimModeCSS(transitionDurationMs))
116+ sb.WriteString("\n")
117+
105 // Progress bar styling118 // Progress bar styling
106 sb.WriteString(generateProgressBarCSS(p))119 sb.WriteString(generateProgressBarCSS(p))
107 sb.WriteString("\n")120 sb.WriteString("\n")
@@ -146,12 +159,20 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
146 sb.WriteString(generateAccentPickerCSS(p))159 sb.WriteString(generateAccentPickerCSS(p))
147 sb.WriteString("\n")160 sb.WriteString("\n")
148 161
149- // History sidebar styling162+ appendNativeSidebarCSS(&sb, p)
150- sb.WriteString(generateHistorySidebarCSS(p))
151 163
152 return sb.String()164 return sb.String()
153 }165 }
154 166
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+
155 // generateTabBarCSS creates tab bar styles.176 // generateTabBarCSS creates tab bar styles.
156 // Uses em units for scalable UI.177 // Uses em units for scalable UI.
157 func generateTabBarCSS(p Palette) string {178 func generateTabBarCSS(p Palette) string {
@@ -572,6 +593,70 @@ entry.find-bar-entry:focus-visible {
572 593
573 // generatePaneCSS creates pane border styles.594 // generatePaneCSS creates pane border styles.
574 // Uses em units for scalable UI.595 // 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+
575 func generatePaneCSS(p Palette) string {660 func generatePaneCSS(p Palette) string {
576 return `/* ===== Pane Styling ===== */661 return `/* ===== Pane Styling ===== */
577 662
@@ -1130,6 +1215,11 @@ func generateToasterCSS(p Palette) string {
1130 color: #ffffff;1215 color: #ffffff;
1131 }1216 }
1132 1217
1218+.toast-vim-mode {
1219+ background-color: var(--pane-mode-color);
1220+ color: #ffffff;
1221+}
1222+
1133 .toast-tab-mode {1223 .toast-tab-mode {
1134 background-color: var(--tab-mode-color);1224 background-color: var(--tab-mode-color);
1135 color: #ffffff;1225 color: #ffffff;
M internal/ui/theme/css_test.go +10 -0
@@ -34,6 +34,16 @@ func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing
34 }34 }
35 }35 }
36 36
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+
37 func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) {47 func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) {
38 css := GenerateCSS(DefaultDarkPalette())48 css := GenerateCSS(DefaultDarkPalette())
39 49
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
4 func generateHistorySidebarCSS(_ Palette) string {4 func generateHistorySidebarCSS(_ Palette) string {
5 return `/* ===== History Sidebar Styling ===== */5 return `/* ===== History Sidebar Styling ===== */
6 6
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-
27 .history-sidebar-group-header {7 .history-sidebar-group-header {
28 padding: 0.25em 0.625em;8 padding: 0.25em 0.625em;
29 padding-top: 0.375em;9 padding-top: 0.375em;
@@ -36,37 +16,6 @@ func generateHistorySidebarCSS(_ Palette) string {
36 border-bottom: 0.0625em solid var(--border);16 border-bottom: 0.0625em solid var(--border);
37 }17 }
38 18
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-
70 .history-sidebar-row-time {19 .history-sidebar-row-time {
71 font-size: 0.68em;20 font-size: 0.68em;
72 color: var(--muted);21 color: var(--muted);
@@ -74,13 +23,6 @@ func generateHistorySidebarCSS(_ Palette) string {
74 opacity: 0.75;23 opacity: 0.75;
75 }24 }
76 25
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-
84 .history-sidebar-loading {26 .history-sidebar-loading {
85 padding: 1.5em 0.75em;27 padding: 1.5em 0.75em;
86 font-size: 0.82em;28 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) {
12 css := generateHistorySidebarCSS(DefaultDarkPalette())12 css := generateHistorySidebarCSS(DefaultDarkPalette())
13 13
14 selectors := []string{14 selectors := []string{
15- ".history-sidebar-outer",
16- ".history-sidebar-search-box",
17- ".history-sidebar-search",
18- ".history-sidebar-groups",
19 ".history-sidebar-group-header",15 ".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",
26 ".history-sidebar-row-time",16 ".history-sidebar-row-time",
27- ".history-sidebar-empty",
28 ".history-sidebar-loading",17 ".history-sidebar-loading",
29 }18 }
30 19
@@ -34,8 +23,8 @@ func TestGenerateHistorySidebarCSS_ContainsExpectedSelectors(t *testing.T) {
34 }23 }
35 }24 }
36 25
37-func TestGenerateHistorySidebarCSS_AccentAlphaInterpolation(t *testing.T) {26+func TestGenerateSidebarCSS_AccentAlphaInterpolation(t *testing.T) {
38- css := generateHistorySidebarCSS(DefaultDarkPalette())27+ css := generateSidebarCSS(DefaultDarkPalette())
39 28
40 expectedAlpha := "alpha(var(--accent), 0.18)"29 expectedAlpha := "alpha(var(--accent), 0.18)"
41 assert.Contains(t, css, expectedAlpha,30 assert.Contains(t, css, expectedAlpha,
@@ -60,9 +49,9 @@ func TestGenerateHistorySidebarCSS_DeterministicOutput(t *testing.T) {
60 assert.Equal(t, hash1, hash2, "CSS output must be deterministic for the same palette")49 assert.Equal(t, hash1, hash2, "CSS output must be deterministic for the same palette")
61 }50 }
62 51
63-func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) {52+func TestGenerateSidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) {
64- darkCSS := generateHistorySidebarCSS(DefaultDarkPalette())53+ darkCSS := generateSidebarCSS(DefaultDarkPalette())
65- lightCSS := generateHistorySidebarCSS(DefaultLightPalette())54+ lightCSS := generateSidebarCSS(DefaultLightPalette())
66 55
67 assert.Equal(t, darkCSS, lightCSS,56 assert.Equal(t, darkCSS, lightCSS,
68 "history sidebar fragment should be palette-independent when it relies on CSS variables")57 "history sidebar fragment should be palette-independent when it relies on CSS variables")
@@ -73,12 +62,12 @@ func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t
73 func TestGenerateHistorySidebarCSS_ThroughGenerateCSS(t *testing.T) {62 func TestGenerateHistorySidebarCSS_ThroughGenerateCSS(t *testing.T) {
74 fullCSS := GenerateCSS(DefaultDarkPalette())63 fullCSS := GenerateCSS(DefaultDarkPalette())
75 64
76- assert.Contains(t, fullCSS, ".history-sidebar-outer {",65+ assert.Contains(t, fullCSS, ".sidebar-outer {",
77- "full CSS must contain history sidebar selectors")66+ "full CSS must contain shared sidebar selectors")
78 assert.Contains(t, fullCSS, ".history-sidebar-group-header {",67 assert.Contains(t, fullCSS, ".history-sidebar-group-header {",
79 "full CSS must contain group header selector")68 "full CSS must contain group header selector")
80- assert.Contains(t, fullCSS, ".history-sidebar-row-title {",69+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
81- "full CSS must contain row title selector")70+ "full CSS must contain shared row title selector")
82 assert.Contains(t, fullCSS, "History Sidebar Styling",71 assert.Contains(t, fullCSS, "History Sidebar Styling",
83 "full CSS must contain the history sidebar comment marker")72 "full CSS must contain the history sidebar comment marker")
84 }73 }
@@ -100,7 +89,7 @@ func TestGenerateHistorySidebarCSS_NoEmptyCSSBlocks(t *testing.T) {
100 }89 }
101 }90 }
102 91
103-func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) {92+func TestGenerateSidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) {
104 customPalette := Palette{93 customPalette := Palette{
105 Background: "#111111",94 Background: "#111111",
106 Surface: "#222222",95 Surface: "#222222",
@@ -114,22 +103,28 @@ func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T)
114 Destructive: "#cc2222",103 Destructive: "#cc2222",
115 }104 }
116 105
117- css := generateHistorySidebarCSS(customPalette)106+ css := generateSidebarCSS(customPalette)
118 107
119 assert.Contains(t, css, "alpha(var(--accent), 0.18)",108 assert.Contains(t, css, "alpha(var(--accent), 0.18)",
120 "sidebar CSS should use the shared accent variable")109 "sidebar CSS should use the shared accent variable")
121 assert.NotContains(t, css, "#ff6600",110 assert.NotContains(t, css, "#ff6600",
122 "sidebar fragment should not inline palette-specific accent values")111 "sidebar fragment should not inline palette-specific accent values")
123 112
124- assert.Contains(t, css, ".history-sidebar-outer {")113+ assert.Contains(t, css, ".sidebar-outer {")
125- assert.Contains(t, css, ".history-sidebar-row-title {")114+ assert.Contains(t, css, ".sidebar-row-title {")
126- assert.Contains(t, css, ".history-sidebar-row-subtitle {")115+ assert.Contains(t, css, ".sidebar-row-subtitle {")
127- assert.Contains(t, css, ".history-sidebar-row-time {")116+ assert.Contains(t, css, ".sidebar-empty {")
128- assert.Contains(t, css, ".history-sidebar-empty {")
129 }117 }
130 118
131-func TestGenerateHistorySidebarCSS_ContainsTransition(t *testing.T) {119+func TestGenerateSidebarCSS_UsesCompactBalancedSearchChrome(t *testing.T) {
132- css := generateHistorySidebarCSS(DefaultDarkPalette())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())
133 128
134 assert.Contains(t, css, "transition:",129 assert.Contains(t, css, "transition:",
135 "CSS should include transition property for smooth hover effects")130 "CSS should include transition property for smooth hover effects")
@@ -149,27 +144,27 @@ func TestGenerateHistorySidebarCSS_ContainsUppercaseGroupHeader(t *testing.T) {
149 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) {144 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) {
150 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())145 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
151 146
152- assert.Contains(t, fullCSS, ".history-sidebar-outer {",147+ assert.Contains(t, fullCSS, ".sidebar-outer {",
153- "GenerateCSSFull must include history sidebar outer selector for dark palette")148+ "GenerateCSSFull must include shared sidebar outer selector for dark palette")
154- assert.Contains(t, fullCSS, ".history-sidebar-row-title {",149+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
155- "GenerateCSSFull must include history sidebar row title selector for dark palette")150+ "GenerateCSSFull must include shared sidebar row title selector for dark palette")
156- assert.Contains(t, fullCSS, ".history-sidebar-search {",151+ assert.Contains(t, fullCSS, ".sidebar-search {",
157- "GenerateCSSFull must include history sidebar search selector for dark palette")152+ "GenerateCSSFull must include shared sidebar search selector for dark palette")
158- assert.Contains(t, fullCSS, ".history-sidebar-empty {",153+ assert.Contains(t, fullCSS, ".sidebar-empty {",
159- "GenerateCSSFull must include history sidebar empty selector for dark palette")154+ "GenerateCSSFull must include shared sidebar empty selector for dark palette")
160 }155 }
161 156
162 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) {157 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) {
163 fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())158 fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
164 159
165- assert.Contains(t, fullCSS, ".history-sidebar-outer {",160+ assert.Contains(t, fullCSS, ".sidebar-outer {",
166- "GenerateCSSFull must include history sidebar outer selector for light palette")161+ "GenerateCSSFull must include shared sidebar outer selector for light palette")
167- assert.Contains(t, fullCSS, ".history-sidebar-row-title {",162+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
168- "GenerateCSSFull must include history sidebar row title selector for light palette")163+ "GenerateCSSFull must include shared sidebar row title selector for light palette")
169- assert.Contains(t, fullCSS, ".history-sidebar-search {",164+ assert.Contains(t, fullCSS, ".sidebar-search {",
170- "GenerateCSSFull must include history sidebar search selector for light palette")165+ "GenerateCSSFull must include shared sidebar search selector for light palette")
171- assert.Contains(t, fullCSS, ".history-sidebar-empty {",166+ assert.Contains(t, fullCSS, ".sidebar-empty {",
172- "GenerateCSSFull must include history sidebar empty selector for light palette")167+ "GenerateCSSFull must include shared sidebar empty selector for light palette")
173 }168 }
174 169
175 func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) {170 func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) {
@@ -181,8 +176,8 @@ func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T
181 lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())176 lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
182 177
183 // Both must contain history sidebar selectors.178 // Both must contain history sidebar selectors.
184- assert.Contains(t, darkCSS, ".history-sidebar-outer {", "dark CSS must have sidebar styles")179+ assert.Contains(t, darkCSS, ".sidebar-outer {", "dark CSS must have shared sidebar styles")
185- assert.Contains(t, lightCSS, ".history-sidebar-outer {", "light CSS must have sidebar styles")180+ assert.Contains(t, lightCSS, ".sidebar-outer {", "light CSS must have shared sidebar styles")
186 181
187 // The CSS must differ between palettes (different color values).182 // The CSS must differ between palettes (different color values).
188 if darkCSS == lightCSS {183 if darkCSS == lightCSS {
@@ -211,14 +206,14 @@ func TestGenerateHistorySidebarCSS_InGenerateCSSFull_WithCustomScaleAndFonts(t *
211 206
212 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors)207 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors)
213 208
214- assert.Contains(t, fullCSS, ".history-sidebar-outer {",209+ assert.Contains(t, fullCSS, ".sidebar-outer {",
215- "GenerateCSSFull with custom scale/fonts must include history sidebar selectors")210+ "GenerateCSSFull with custom scale/fonts must include shared sidebar selectors")
216- assert.Contains(t, fullCSS, ".history-sidebar-row:hover {",211+ assert.Contains(t, fullCSS, ".sidebar-row:hover {",
217- "GenerateCSSFull must include hover state for history rows")212+ "GenerateCSSFull must include hover state for shared sidebar rows")
218- assert.Contains(t, fullCSS, ".history-sidebar-row:selected {",213+ assert.Contains(t, fullCSS, ".sidebar-row:selected {",
219- "GenerateCSSFull must include selected state for history rows")214+ "GenerateCSSFull must include selected state for shared sidebar rows")
220- assert.Contains(t, fullCSS, ".history-sidebar-row:focus {",215+ assert.Contains(t, fullCSS, ".sidebar-row:focus {",
221- "GenerateCSSFull must include focus state for history rows")216+ "GenerateCSSFull must include focus state for shared sidebar rows")
222 }217 }
223 218
224 // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the219 // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the
@@ -247,21 +242,21 @@ func TestGenerateHistorySidebarCSS_LiveReloadFullPath(t *testing.T) {
247 lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors())242 lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
248 customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors())243 customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
249 244
250- // All three must contain the full set of history sidebar selectors.245+ // All three must contain the shared foundation and history-specific rules.
251 sidebarSelectors := []string{246 sidebarSelectors := []string{
252- ".history-sidebar-outer",247+ ".sidebar-outer",
253- ".history-sidebar-search-box",248+ ".sidebar-search-box",
254- ".history-sidebar-search",249+ ".sidebar-search",
255- ".history-sidebar-groups",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",
256 ".history-sidebar-group-header",258 ".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",
263 ".history-sidebar-row-time",259 ".history-sidebar-row-time",
264- ".history-sidebar-empty",
265 ".history-sidebar-loading",260 ".history-sidebar-loading",
266 }261 }
267 262
@@ -324,9 +319,9 @@ func TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch(t *testing.T) {
324 assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n")319 assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n")
325 }320 }
326 321
327- // History sidebar selectors must be present in both.322+ // Shared sidebar selectors must be present in both.
328- assert.Contains(t, cssBefore, ".history-sidebar-row:selected {")323+ assert.Contains(t, cssBefore, ".sidebar-row:selected {")
329- assert.Contains(t, cssAfter, ".history-sidebar-row:selected {")324+ assert.Contains(t, cssAfter, ".sidebar-row:selected {")
330 325
331 // The before and after CSS must be different (palette changed).326 // The before and after CSS must be different (palette changed).
332 assert.NotEqual(t, cssBefore, cssAfter, "CSS must change when palette switches")327 assert.NotEqual(t, cssBefore, cssAfter, "CSS must change when palette switches")
M internal/ui/theme/manager.go +23 -12
@@ -15,22 +15,23 @@ import (
15 // Manager handles theme state and CSS application.15 // Manager handles theme state and CSS application.
16 // Theme precedence and fallback are resolved before data reaches this adapter.16 // Theme precedence and fallback are resolved before data reaches this adapter.
17 type Manager struct {17 type Manager struct {
18- prefersDark bool // Resolved dark mode preference18+ prefersDark bool // Resolved dark mode preference
19- lightPalette Palette // Light theme colors19+ lightPalette Palette // Light theme colors
20- darkPalette Palette // Dark theme colors20+ darkPalette Palette // Dark theme colors
21- uiScale float64 // UI scaling factor (1.0 = 100%)21+ uiScale float64 // UI scaling factor (1.0 = 100%)
22- fonts FontConfig22+ fonts FontConfig
23- gtkFont string23+ gtkFont string
24- modeColors ModeColors // Modal mode indicator colors24+ modeColors ModeColors // Modal mode indicator colors
25- cssProvider *gtk.CssProvider25+ transitionDurationMs int
26- appliedFont string26+ cssProvider *gtk.CssProvider
27+ appliedFont string
27 }28 }
28 29
29 // NewManager creates a new theme manager from an already-resolved theme.30 // NewManager creates a new theme manager from an already-resolved theme.
30 func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager {31 func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager {
31 log := logging.FromContext(ctx)32 log := logging.FromContext(ctx)
32 33
33- m := &Manager{}34+ m := &Manager{transitionDurationMs: defaultTransitionDurationMs}
34 m.applyResolvedTheme(resolved)35 m.applyResolvedTheme(resolved)
35 36
36 log.Debug().37 log.Debug().
@@ -60,6 +61,15 @@ func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) {
60 m.modeColors = ModeColorsFromEntity(resolved.ModeColors)61 m.modeColors = ModeColorsFromEntity(resolved.ModeColors)
61 }62 }
62 63
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+
63 // PrefersDark returns true if dark mode is active.73 // PrefersDark returns true if dark mode is active.
64 func (m *Manager) PrefersDark() bool {74 func (m *Manager) PrefersDark() bool {
65 return m.prefersDark75 return m.prefersDark
@@ -135,9 +145,10 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
135 return145 return
136 }146 }
137 147
138- // Generate CSS with current palette, UI scale, fonts, and mode colors148+ // Generate CSS with current palette, UI scale, fonts, mode colors, and the
149+ // configured transition duration.
139 palette := m.GetCurrentPalette()150 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)
141 fontName := formatGTKFontName(m.gtkFont, m.uiScale)152 fontName := formatGTKFontName(m.gtkFont, m.uiScale)
142 153
143 settings := gtk.SettingsGetForDisplay(display)154 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