Merge pull request #383 from bnema/chore/changelog-v0.31.0
release: merge next into main
147 files · +17883 -912
M .github/workflows/ci.yml +18 -4
| @@ -54,8 +54,15 @@ jobs: | |||
| 54 | 54 | steps: | |
| 55 | 55 | - name: Install dependencies | |
| 56 | 56 | run: | | |
| 57 | - | pacman -Syu --noconfirm | |
| 58 | - | pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell gobject-introspection base-devel brotli | |
| 57 | + | for attempt in 1 2 3; do | |
| 58 | + | if pacman -Syu --noconfirm && pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell gobject-introspection base-devel brotli; then | |
| 59 | + | exit 0 | |
| 60 | + | fi | |
| 61 | + | if [ "$attempt" -eq 3 ]; then | |
| 62 | + | exit 1 | |
| 63 | + | fi | |
| 64 | + | sleep 5 | |
| 65 | + | done | |
| 59 | 66 | - uses: actions/checkout@v7.0.1 | |
| 60 | 67 | - name: Allow git commands in container checkout | |
| 61 | 68 | run: git config --global --add safe.directory "$GITHUB_WORKSPACE" | |
| @@ -98,8 +105,15 @@ jobs: | |||
| 98 | 105 | steps: | |
| 99 | 106 | - name: Install dependencies | |
| 100 | 107 | run: | | |
| 101 | - | pacman -Syu --noconfirm | |
| 102 | - | pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell libadwaita gobject-introspection base-devel brotli dbus | |
| 108 | + | for attempt in 1 2 3; do | |
| 109 | + | if pacman -Syu --noconfirm && pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell libadwaita gobject-introspection base-devel brotli dbus; then | |
| 110 | + | exit 0 | |
| 111 | + | fi | |
| 112 | + | if [ "$attempt" -eq 3 ]; then | |
| 113 | + | exit 1 | |
| 114 | + | fi | |
| 115 | + | sleep 5 | |
| 116 | + | done | |
| 103 | 117 | - uses: actions/checkout@v7.0.1 | |
| 104 | 118 | - name: Set up pinned Go toolchain | |
| 105 | 119 | uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e | |
M .mockery.yaml +1 -0
| @@ -59,6 +59,7 @@ packages: | |||
| 59 | 59 | SessionSpawner: {} | |
| 60 | 60 | WindowStateProvider: {} | |
| 61 | 61 | WebView: {} | |
| 62 | + | PageScrollable: {} | |
| 62 | 63 | WebViewFactory: {} | |
| 63 | 64 | WebViewPool: {} | |
| 64 | 65 | MemoryPressureApplier: {} | |
M AGENTS.md +1 -1
| @@ -40,4 +40,4 @@ Run before committing: | |||
| 40 | 40 | ## Misc | |
| 41 | 41 | ||
| 42 | 42 | - 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 | 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 | 33 | ## [0.30.0] - 2026-06-27 | |
| 8 | 34 | ||
| 9 | 35 | ### Added | |
M README.md +27 -29
| @@ -1,21 +1,18 @@ | |||
| 1 | - | # Dumber | |
| 1 | + | <h1 align="center">Dumber</h1> | |
| 2 | 2 | ||
| 3 | - | [](https://opensource.org/licenses/MIT) [](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. | |
| 8 | - | ||
| 9 | - | The layout model is inspired by terminal multiplexers such as Zellij and tmux, but applied to web browsing this is particularly suited for Wayland compositors such as Niri or Hyprland. | |
| 10 | - | ||
| 11 | - | [Website](https://dumber.bnema.dev) · [Documentation](https://dumber.bnema.dev/docs) · [Keybindings](https://dumber.bnema.dev/docs/reference/keybindings) | |
| 12 | - | ||
| 13 | - | ## Demo | |
| 12 | + | <p align="center"><a href="https://bnema.dev/dumber">Website</a> · <a href="https://bnema.dev/dumber/docs">Documentation</a> · <a href="https://bnema.dev/dumber/docs/reference/keybindings">Keybindings</a></p> | |
| 14 | 13 | ||
| 15 | 14 | 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 | 16 | ## Overview | |
| 20 | 17 | ||
| 21 | 18 | Dumber uses three layout levels: | |
| @@ -24,7 +21,7 @@ Dumber uses three layout levels: | |||
| 24 | 21 | - **Workspaces** hold a layout of panes. | |
| 25 | 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 | 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 | 44 | ### Keyboard workflow | |
| 48 | 45 | ||
| 49 | - | - Pane, tab, resize, and session modes | |
| 46 | + | - Pane, tab, Vim, resize, and session modes | |
| 50 | 47 | - Vim/Zellij-style navigation patterns | |
| 51 | 48 | - Search bangs such as `!g`, `!gi`, and `!ddg` | |
| 52 | 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 | 67 | ### Install script | |
| 71 | 68 | ||
| 72 | 69 | ```bash | |
| 73 | - | curl -fsSL https://dumber.bnema.dev/install | sh | |
| 70 | + | curl -fsSL https://raw.githubusercontent.com/bnema/dumber/v0.31.0/install.sh | DUMBER_VERSION=v0.31.0 bash | |
| 74 | 71 | dumber browse | |
| 75 | 72 | ``` | |
| 76 | 73 | ||
| @@ -89,7 +86,7 @@ flatpak install --user dumber.flatpak | |||
| 89 | 86 | 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 | 91 | ## Keyboard modes | |
| 95 | 92 | ||
| @@ -99,6 +96,7 @@ Dumber uses modal keybindings for browser management. | |||
| 99 | 96 | |------|-------------|----------| | |
| 100 | 97 | | Pane | `Ctrl+P` | Split, stack, close, and move panes | | |
| 101 | 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 | 100 | | Resize | `Ctrl+N` | Resize panes with `hjkl` or arrow keys | | |
| 103 | 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 | 109 | - Profile shortcuts such as `Alt+G` are optional and configured under `workspace.floating_pane.profiles`. | |
| 112 | 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 | 114 | ## Configuration | |
| 117 | 115 | ||
| @@ -125,11 +123,19 @@ type = "cef" | |||
| 125 | 123 | 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 | 136 | ## 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 | 140 | On Arch Linux, install the CEF runtime with: | |
| 135 | 141 | ||
| @@ -166,7 +172,7 @@ WebKit can be selected explicitly: | |||
| 166 | 172 | type = "webkit" | |
| 167 | 173 | ``` | |
| 168 | 174 | ||
| 169 | - | ### Rendering notes | |
| 175 | + | ### Rendering | |
| 170 | 176 | ||
| 171 | 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 | 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 | 199 | ## Development | |
| 202 | 200 | ||
| 203 | 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 | 205 | ### 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 | 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 | 116 | if relay == nil { | |
| 100 | 117 | 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 | 124 | if err != nil { | |
| 105 | - | if delivered && errors.Is(err, desktop.ErrBrowserLaunchRelayUnconfirmed) { | |
| 106 | - | return true, nil | |
| 107 | - | } | |
| 108 | 125 | return false, err | |
| 109 | 126 | } | |
| 110 | - | return delivered, nil | |
| 127 | + | return false, nil | |
| 111 | 128 | } | |
| 112 | 129 | ||
| 113 | 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 | 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 | 172 | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | |
| 151 | 173 | // invalidate frames retained across those boundaries and make the GC abort | |
| 152 | 174 | // with "traceback did not unwind completely". Re-exec once so the runtime | |
| @@ -193,7 +215,15 @@ func main() { | |||
| 193 | 215 | timing.configComplete = time.Now() | |
| 194 | 216 | configureBrowserLaunchRelay(cfg) | |
| 195 | 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 | 227 | fmt.Fprintf( | |
| 198 | 228 | os.Stderr, | |
| 199 | 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 | 328 | if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok { | |
| 299 | 329 | relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) { | |
| 300 | - | if err := app.OpenFreshWindow(ctx, url); err != nil { | |
| 301 | - | log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window") | |
| 330 | + | if err := app.OpenExternalURL(ctx, url); err != nil { | |
| 331 | + | log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch external URL") | |
| 302 | 332 | } | |
| 303 | 333 | }) | |
| 304 | 334 | } | |
| @@ -961,7 +991,7 @@ func buildUIDependencies( | |||
| 961 | 991 | }, | |
| 962 | 992 | LaunchExternalURL: desktop.LaunchExternalURL, | |
| 963 | 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 | 996 | BrowserLaunchRelay: browserLaunchRelay, | |
| 967 | 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 | 231 | func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testing.T) { | |
| 232 | 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 | 235 | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "dumb://history") | |
| 236 | 236 | ||
| @@ -244,7 +244,7 @@ func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testi | |||
| 244 | 244 | ||
| 245 | 245 | func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) { | |
| 246 | 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 | 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 | 273 | func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) { | |
| 260 | 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 | 277 | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 264 | 278 | ||
| @@ -272,7 +286,7 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing | |||
| 272 | 286 | ||
| 273 | 287 | func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnUnconfirmedRelayDelivery(t *testing.T) { | |
| 274 | 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 | 291 | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 278 | 292 | ||
| @@ -309,7 +323,7 @@ func TestLaunchStandaloneBrowserURL_PropagatesOtherErrors(t *testing.T) { | |||
| 309 | 323 | func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) { | |
| 310 | 324 | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 311 | 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 | 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 | 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 | 471 | ### Resize Mode | |
| 402 | 472 | ||
| 403 | 473 | | Key | Type | Default | Description | | |
| @@ -529,20 +599,21 @@ desc = "Open floating pane on GitHub" | |||
| 529 | 599 | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay | | |
| 530 | 600 | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color | | |
| 531 | 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 | 603 | | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster | | |
| 534 | 604 | | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster | | |
| 535 | 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 | | |
| 537 | - | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | |
| 606 | + | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show bottom-left mode toaster when modal modes are active; Vim Mode keeps a persistent `VIM MODE` indicator until exit or toggle off | | |
| 607 | + | | `workspace.styling.transition_duration` | int | `120` | Border and pulse transition duration (ms) | | |
| 538 | 608 | ||
| 539 | 609 | **Example:** | |
| 610 | + | ||
| 540 | 611 | ```toml | |
| 541 | 612 | [workspace.styling] | |
| 542 | 613 | border_width = 1 | |
| 543 | 614 | border_color = "@theme_selected_bg_color" | |
| 544 | 615 | mode_border_width = 4 | |
| 545 | - | pane_mode_color = "#4A90E2" # Blue for pane mode | |
| 616 | + | pane_mode_color = "#4A90E2" # Blue for pane mode and Vim mode local accent | |
| 546 | 617 | tab_mode_color = "#FFA500" # Orange for tab mode | |
| 547 | 618 | session_mode_color = "#9B59B6" # Purple for session mode | |
| 548 | 619 | 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 | 17 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | |
| 18 | 18 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | |
| 19 | + | | Vim Mode | `Ctrl+Y` | Scroll, 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 | 20 | | Resize Mode | `Ctrl+N` | Resize pane splits | | |
| 20 | 21 | | Session Mode | `Ctrl+O` | Session management | | |
| 21 | 22 | ||
| 22 | 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 | 26 | ## Pane Mode Quick Reference | |
| 25 | 27 | ||
| @@ -28,6 +30,25 @@ Press `Escape` or `Enter` to exit any mode. | |||
| 28 | 30 | 3. `Shift+arrows` to focus adjacent panes | |
| 29 | 31 | 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 | 52 | ## Omnibox | |
| 32 | 53 | ||
| 33 | 54 | Press `Ctrl+L` to open the omnibox for: | |
M docs/installation.md +4 -4
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | ## Quick Install (Linux x86_64) | |
| 4 | 4 | ||
| 5 | 5 | ```bash | |
| 6 | - | curl -fsSL https://dumber.bnema.dev/install | bash | |
| 6 | + | curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | bash | |
| 7 | 7 | ``` | |
| 8 | 8 | ||
| 9 | 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 | 13 | Install a specific version: | |
| 14 | 14 | ||
| 15 | 15 | ```bash | |
| 16 | - | DUMBER_VERSION=v0.26.2 curl -fsSL https://dumber.bnema.dev/install | bash | |
| 16 | + | curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_VERSION=v0.26.2 bash | |
| 17 | 17 | ``` | |
| 18 | 18 | ||
| 19 | 19 | Install the latest prerelease: | |
| 20 | 20 | ||
| 21 | 21 | ```bash | |
| 22 | - | DUMBER_PRERELEASE=1 curl -fsSL https://dumber.bnema.dev/install | bash | |
| 22 | + | curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_PRERELEASE=1 bash | |
| 23 | 23 | ``` | |
| 24 | 24 | ||
| 25 | 25 | Combine both to install a specific prerelease: | |
| 26 | 26 | ||
| 27 | 27 | ```bash | |
| 28 | - | DUMBER_VERSION=v0.27.0-rc.1 DUMBER_PRERELEASE=1 curl -fsSL https://dumber.bnema.dev/install | bash | |
| 28 | + | curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_VERSION=v0.27.0-rc.1 DUMBER_PRERELEASE=1 bash | |
| 29 | 29 | ``` | |
| 30 | 30 | ||
| 31 | 31 | ## Flatpak | |
M docs/reference/configuration.md +9 -0
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | # 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 | 5 | | Key | Type | Default | Valid Values | | |
| 4 | 6 | |-----|------|---------|--------------| | |
| 5 | 7 | | `database.path` | string | `~/.local/share/dumber/dumber.db` | | | |
| @@ -98,6 +100,11 @@ | |||
| 98 | 100 | | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | | | |
| 99 | 101 | | `workspace.tab_mode.timeout_ms` | int | `3000` | | | |
| 100 | 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 | 108 | | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | | | |
| 102 | 109 | | `workspace.resize_mode.timeout_ms` | int | `3000` | | | |
| 103 | 110 | | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings | | |
| @@ -111,6 +118,8 @@ | |||
| 111 | 118 | | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key | | |
| 112 | 119 | | `workspace.floating_pane.profiles.<name>.url` | string | | required URL | | |
| 113 | 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 | 123 | | `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` | | |
| 115 | 124 | | `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` | | |
| 116 | 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 | 9 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | |
| 10 | 10 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | |
| 11 | + | | Vim Mode | `Ctrl+Y` | Scroll, 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 | 12 | | Resize Mode | `Ctrl+N` | Resize pane splits | | |
| 12 | 13 | | Session Mode | `Ctrl+O` | Session management | | |
| 13 | 14 | ||
| 14 | 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 | 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 | 53 | | Confirm | `Enter` | | |
| 52 | 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 | 79 | ## Resize Mode (`Ctrl+N`) | |
| 55 | 80 | ||
| 56 | 81 | | Action | Keys | | |
| @@ -98,7 +123,7 @@ These work outside modal modes: | |||
| 98 | 123 | - `Alt+F` is the only floating-pane shortcut enabled by default. | |
| 99 | 124 | - `Alt+F` toggles floating visibility and keeps floating pane state intact. | |
| 100 | 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 | 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 | 128 | - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session. | |
| 104 | 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 | 143 | split-right = ["arrowright", "r"] | |
| 119 | 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 | 177 | [workspace.shortcuts.actions.close-pane] | |
| 122 | 178 | keys = ["ctrl+w"] | |
| 123 | 179 | ||
| @@ -127,7 +183,7 @@ keys = ["alt+f"] | |||
| 127 | 183 | [workspace.shortcuts.actions.toggle-history-systemview] | |
| 128 | 184 | keys = ["ctrl+h"] | |
| 129 | 185 | ||
| 130 | - | [workspace.shortcuts.actions.toggle-favorites-systemview] | |
| 186 | + | [workspace.shortcuts.actions.toggle-favorites-sidebar] | |
| 131 | 187 | keys = ["ctrl+b"] | |
| 132 | 188 | ||
| 133 | 189 | [workspace.shortcuts.actions.toggle-current-page-favorite] | |
M go.mod +5 -5
| @@ -1,17 +1,17 @@ | |||
| 1 | 1 | module github.com/bnema/dumber | |
| 2 | 2 | ||
| 3 | - | go 1.26.3 | |
| 3 | + | go 1.26 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/a-h/templ v0.3.1020 | |
| 7 | 7 | github.com/andybalholm/brotli v1.2.2 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.4 | |
| 9 | 9 | github.com/bnema/purego-cef v0.14.2 | |
| 10 | - | github.com/bnema/purego-cef2gtk v0.9.0 | |
| 11 | - | github.com/bnema/purego-pipewire v0.1.5 | |
| 12 | - | github.com/bnema/purego-sqlite v0.1.4 | |
| 10 | + | github.com/bnema/purego-cef2gtk v0.9.3 | |
| 11 | + | github.com/bnema/purego-pipewire v0.1.6 | |
| 12 | + | github.com/bnema/purego-sqlite v0.1.5 | |
| 13 | 13 | github.com/bnema/purego-webp v0.2.1 | |
| 14 | - | github.com/bnema/puregotk v0.7.1 | |
| 14 | + | github.com/bnema/puregotk v0.7.2 | |
| 15 | 15 | github.com/charmbracelet/bubbles v1.0.0 | |
| 16 | 16 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 17 | 17 | 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 | 2 | set -e | |
| 3 | 3 | ||
| 4 | 4 | # Dumber installer script | |
| 5 | - | # Usage: curl -fsSL https://dumber.bnema.dev/install | sh | |
| 6 | - | # Usage with pre-release: curl -fsSL https://dumber.bnema.dev/install | DUMBER_PRERELEASE=1 sh | |
| 5 | + | # Usage: curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | sh | |
| 6 | + | # Usage with pre-release: curl -fsSL https://raw.githubusercontent.com/bnema/dumber/main/install.sh | DUMBER_PRERELEASE=1 sh | |
| 7 | 7 | ||
| 8 | 8 | REPO="bnema/dumber" | |
| 9 | 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 | 5 | "io" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | - | // BrowserWindowOpener opens a fresh browser window. | |
| 8 | + | // BrowserWindowOpener opens browser URLs received through the launch relay. | |
| 9 | 9 | type BrowserWindowOpener interface { | |
| 10 | + | OpenExternalURL(ctx context.Context, url string) error | |
| 10 | 11 | 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 | 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 | 17 | // The bool reports whether the relay accepted the request; false means the | |
| 17 | 18 | // caller should treat it as undelivered and try another path. | |
| 18 | 19 | // An error may still be returned with delivered=true when the relay accepted | |
| 19 | 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 | 24 | DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) | |
| 21 | 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 | 11 | type FavoritesSidebarFavorites interface { | |
| 12 | 12 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | |
| 13 | 13 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | |
| 14 | + | AddTag(ctx context.Context, name, color string) (*entity.Tag, error) | |
| 14 | 15 | AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | |
| 15 | 16 | UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) | |
| 16 | 17 | DeleteFavorite(ctx context.Context, id entity.FavoriteID) error | |
| 17 | 18 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | |
| 18 | 19 | TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error | |
| 19 | 20 | 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 | 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 | 99 | // OpenFreshWindow provides a mock function for the type MockBrowserWindowOpener | |
| 43 | 100 | func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error { | |
| 44 | 101 | ret := _mock.Called(ctx, url) | |
| @@ -123,6 +180,72 @@ func (_m *MockBrowserLaunchRelay) EXPECT() *MockBrowserLaunchRelay_Expecter { | |||
| 123 | 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 | 249 | // DeliverOpenFreshWindow provides a mock function for the type MockBrowserLaunchRelay | |
| 127 | 250 | func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | |
| 128 | 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 | 3112 | _c.Call.Return(run) | |
| 3113 | 3113 | 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 | 6 | import ( | |
| 7 | 7 | "context" | |
| 8 | + | "fmt" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -142,6 +143,10 @@ type WebViewCallbacks struct { | |||
| 142 | 143 | // OnTouchpadNavigationGesture is called while a two-finger touchpad history | |
| 143 | 144 | // navigation gesture is progressing or finishing. | |
| 144 | 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 | 152 | // FindOptions configures search behavior. | |
| @@ -390,3 +395,93 @@ type OAuthCallbackCapable interface { | |||
| 390 | 395 | type TextInputTargetProvider interface { | |
| 391 | 396 | 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 | 18 | "global": true, | |
| 19 | 19 | "pane": true, | |
| 20 | 20 | "tab": true, | |
| 21 | + | "vim": true, | |
| 21 | 22 | "resize": true, | |
| 22 | 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 | 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 | 86 | t.Run("successfully sets keybinding", func(t *testing.T) { | |
| 76 | 87 | // Arrange | |
| 77 | 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 | 599 | 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 | 679 | // GetTagsForFavorite retrieves all tags for a favorite. | |
| 603 | 680 | func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) { | |
| 604 | 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 | 140 | func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig { | |
| 141 | 141 | in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions) | |
| 142 | 142 | in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions) | |
| 143 | + | in.VimMode.Actions = cloneActionBindings(in.VimMode.Actions) | |
| 143 | 144 | in.ResizeMode.Actions = cloneActionBindings(in.ResizeMode.Actions) | |
| 144 | 145 | in.Shortcuts.Actions = cloneActionBindings(in.Shortcuts.Actions) | |
| 145 | 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 | 258 | cfg.Workspace.TabMode.Actions = map[string]entity.ActionBinding{ | |
| 259 | 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 | 264 | cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{ | |
| 262 | 265 | "grow-right": {Keys: []string{"right"}, Desc: "Grow right"}, | |
| 263 | 266 | } | |
| @@ -280,6 +283,9 @@ func mutateNestedRuntimeConfigSnapshot(snapshot entity.RuntimeConfigSnapshot) { | |||
| 280 | 283 | mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab") | |
| 281 | 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 | 289 | mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize") | |
| 284 | 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 | 316 | assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l") | |
| 311 | 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 | 322 | assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right") | |
| 314 | 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 | 366 | assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l") | |
| 358 | 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 | 372 | assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right") | |
| 361 | 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 | 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 | 95 | // ResizeModeConfig holds resize mode shortcut configuration. | |
| 82 | 96 | type ResizeModeConfig struct { | |
| 83 | 97 | 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 | 211 | // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig. | |
| 198 | 212 | 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 | 240 | // WorkspaceConfig holds all workspace layout and behavior settings. | |
| 201 | 241 | type WorkspaceConfig struct { | |
| 202 | - | NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"` | |
| 203 | - | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | |
| 204 | - | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | |
| 205 | - | ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"` | |
| 206 | - | Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"` | |
| 207 | - | FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"` | |
| 242 | + | NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"` | |
| 243 | + | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | |
| 244 | + | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | |
| 245 | + | VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"` | |
| 246 | + | ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"` | |
| 247 | + | Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"` | |
| 248 | + | FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"` | |
| 249 | + | ExternalLinks ExternalLinksConfig `mapstructure:"external_links" yaml:"external_links" toml:"external_links" json:"external_links"` | |
| 208 | 250 | ||
| 209 | 251 | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"` | |
| 210 | 252 | 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 | 280 | func newDumberApp(engine *Engine) purecef.App { | |
| 281 | 281 | app := &dumberApp{engine: engine} | |
| 282 | 282 | app.bph = &dumberBPH{engine: engine} | |
| 283 | - | app.rph = newPopupOpenerRenderProcessHandler() | |
| 283 | + | app.rph = newComposedRenderProcessHandler() | |
| 284 | 284 | return app | |
| 285 | 285 | } | |
| 286 | 286 | ||
| 287 | 287 | // NewSubprocessApp returns a lightweight raw App implementation for CEF | |
| 288 | - | // subprocesses re-executed from the main dumber binary. Keep the legacy | |
| 289 | - | // renderer bridge disabled, but allow the minimal popup-opener render handler | |
| 290 | - | // needed to install synthetic opener state before page scripts run. | |
| 288 | + | // subprocesses re-executed from the main dumber binary. Keep the legacy V8 | |
| 289 | + | // renderer bridge disabled while composing editable-focus reporting with the | |
| 290 | + | // popup-opener callbacks needed before page scripts run. | |
| 291 | 291 | func NewSubprocessApp() purecef.App { | |
| 292 | - | return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()} | |
| 292 | + | return &subprocessApp{rph: newComposedRenderProcessHandler()} | |
| 293 | 293 | } | |
| 294 | 294 | ||
| 295 | 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 | 156 | wv.handlers = handlers | |
| 157 | 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 | 167 | // Build a CEF client backed by our handlerSet. | |
| 160 | 168 | // Store on WebView to prevent GC collection before CEF AddRef's it. | |
| 161 | 169 | wv.client = purecef.NewClient(wv.handlers) | |
| @@ -164,10 +172,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||
| 164 | 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() | |
| 168 | - | if !viewportHooksResult.Completed() { | |
| 169 | - | wv.destroyViewBridgeOnGTKAsync() | |
| 170 | - | return nil, errGTKSyncDispatchIncomplete("install CEF viewport sync hooks", viewportHooksResult) | |
| 175 | + | if err := wv.installNewWebViewViewportHooksAfterCapture(); err != nil { | |
| 176 | + | return nil, err | |
| 171 | 177 | } | |
| 172 | 178 | wv.startRenderStallWatchdog() | |
| 173 | 179 | 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 | 50 | 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 | 78 | type popupOpenerRenderProcessHandler struct { | |
| 54 | 79 | mu sync.RWMutex | |
| 55 | 80 | byBrowserID map[int32]popupOpenerRenderMetadata | |
M internal/infrastructure/cef/render_handler_adapter.go +11 -2
| @@ -19,6 +19,7 @@ type dumberRenderHandler struct { | |||
| 19 | 19 | main purecef.RenderHandler | |
| 20 | 20 | popup purecef.RenderHandler | |
| 21 | 21 | popupSurface popupSurface | |
| 22 | + | a11y purecef.AccessibilityHandler | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | var _ purecef.RenderHandler = (*dumberRenderHandler)(nil) | |
| @@ -48,6 +49,14 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler { | |||
| 48 | 49 | return nil | |
| 49 | 50 | } | |
| 50 | 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 | 61 | var unsupportedPaintOnce sync.Once | |
| 53 | 62 | hooks := startupPresentationHooks(activeStartupTrace()) | |
| @@ -110,10 +119,10 @@ func (h *dumberRenderHandler) renderTargetForPaint(elementType purecef.PaintElem | |||
| 110 | 119 | } | |
| 111 | 120 | ||
| 112 | 121 | func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler { | |
| 113 | - | if h == nil || h.main == nil { | |
| 122 | + | if h == nil { | |
| 114 | 123 | return nil | |
| 115 | 124 | } | |
| 116 | - | return h.main.GetAccessibilityHandler() | |
| 125 | + | return h.a11y | |
| 117 | 126 | } | |
| 118 | 127 | ||
| 119 | 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 | 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 | 21 | "github.com/bnema/dumber/internal/application/dto" | |
| 22 | 22 | "github.com/bnema/dumber/internal/application/port" | |
| 23 | 23 | "github.com/bnema/dumber/internal/domain/entity" | |
| 24 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 24 | 25 | "github.com/bnema/dumber/internal/logging" | |
| 25 | 26 | "github.com/bnema/dumber/internal/shared/syncdispatch" | |
| 26 | 27 | ) | |
| @@ -34,6 +35,9 @@ var ( | |||
| 34 | 35 | _ port.PopupOpenerCapable = (*WebView)(nil) | |
| 35 | 36 | _ port.ViewportSyncCapable = (*WebView)(nil) | |
| 36 | 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 | 43 | // errDestroyed is returned when an operation is attempted on a destroyed WebView. | |
| @@ -69,8 +73,24 @@ var cefLoadWatchdogDelays = []time.Duration{ | |||
| 69 | 73 | const ( | |
| 70 | 74 | cefGTKSyncDispatchTimeout = 2 * time.Second | |
| 71 | 75 | 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 | 94 | var ( | |
| 75 | 95 | cefNewTask = purecef.NewTask | |
| 76 | 96 | cefNewStringVisitor = purecef.NewStringVisitor | |
| @@ -131,6 +151,21 @@ type WebView struct { | |||
| 131 | 151 | adaptiveFrameRatePollID uint | |
| 132 | 152 | 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 | 169 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 135 | 170 | // widget is visible. Access is guarded by mu. | |
| 136 | 171 | beginFrameTick *gtk.TickCallback | |
| @@ -1201,6 +1236,8 @@ func (wv *WebView) Destroy() { | |||
| 1201 | 1236 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 1202 | 1237 | return | |
| 1203 | 1238 | } | |
| 1239 | + | wv.shutdownAccessibilityCapture() | |
| 1240 | + | wv.resetPageScrollQueue() | |
| 1204 | 1241 | wv.syntheticPopupMu.Lock() | |
| 1205 | 1242 | wv.syntheticPopups = nil | |
| 1206 | 1243 | wv.syntheticPopupMu.Unlock() | |
| @@ -1423,23 +1460,28 @@ func (wv *WebView) setEditableFocus(editable bool) { | |||
| 1423 | 1460 | wv.mu.Lock() | |
| 1424 | 1461 | previous := wv.focusedEditable | |
| 1425 | 1462 | wv.focusedEditable = editable | |
| 1463 | + | cb := wv.callbacks | |
| 1464 | + | var timer stoppableTimer | |
| 1426 | 1465 | if editable { | |
| 1427 | 1466 | wv.selectionDebounceSeq++ | |
| 1428 | - | timer := wv.selectionDebounceTimer | |
| 1467 | + | timer = wv.selectionDebounceTimer | |
| 1429 | 1468 | 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 | 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 | 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 | 1487 | func (wv *WebView) cancelSelectionDebounce() { | |
| @@ -2368,6 +2410,231 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |||
| 2368 | 2410 | 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 | 2638 | // beginAudioStreamStart invalidates any prior stream before creating a new | |
| 2372 | 2639 | // one. A stream created for the returned generation may only be published if | |
| 2373 | 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 | 472 | Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)). | |
| 473 | 473 | Msg("cef: OnLoadStart") | |
| 474 | 474 | } | |
| 475 | + | h.wv.setEditableFocus(false) | |
| 475 | 476 | h.wv.updateURI(frame.GetURL()) | |
| 476 | 477 | h.wv.mu.RLock() | |
| 477 | 478 | cb := h.wv.callbacks | |
| @@ -954,6 +955,7 @@ func (h *handlerSet) finishAfterCreated( | |||
| 954 | 955 | } | |
| 955 | 956 | h.wv.scheduleStartAdaptiveFrameRatePolling() | |
| 956 | 957 | h.wv.scheduleStartBeginFrameLoop() | |
| 958 | + | h.wv.applyAccessibilityIfReady() | |
| 957 | 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 | 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 | 581 | func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) { | |
| 548 | 582 | called := false | |
| 549 | 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 | 38 | defaultOmniboxAutoOpenOnNewPane = false | |
| 39 | 39 | ||
| 40 | 40 | // Workspace defaults | |
| 41 | - | defaultPaneActivationShortcut = "ctrl+p" | |
| 42 | - | defaultPaneTimeoutMilliseconds = 3000 | |
| 43 | - | defaultTabActivationShortcut = "ctrl+t" | |
| 44 | - | defaultTabTimeoutMilliseconds = 3000 | |
| 45 | - | defaultResizeActivationShortcut = "ctrl+n" | |
| 46 | - | defaultResizeTimeoutMilliseconds = 3000 | |
| 47 | - | defaultResizeStepPercent = 5.0 | |
| 48 | - | defaultResizeMinPanePercent = 10.0 | |
| 49 | - | defaultTabBarPosition = "bottom" | |
| 50 | - | defaultPopupPlacement = "right" | |
| 51 | - | defaultFloatingPaneWidthPct = 0.82 | |
| 52 | - | defaultFloatingPaneHeightPct = 0.72 | |
| 41 | + | defaultPaneActivationShortcut = "ctrl+p" | |
| 42 | + | defaultPaneTimeoutMilliseconds = 3000 | |
| 43 | + | defaultTabActivationShortcut = "ctrl+t" | |
| 44 | + | defaultTabTimeoutMilliseconds = 3000 | |
| 45 | + | defaultResizeActivationShortcut = "ctrl+n" | |
| 46 | + | defaultResizeTimeoutMilliseconds = 3000 | |
| 47 | + | defaultPageActivationShortcut = "ctrl+y" | |
| 48 | + | defaultPageTimeoutMilliseconds = 0 | |
| 49 | + | defaultPageSequenceTimeoutMilliseconds = 500 | |
| 50 | + | defaultResizeStepPercent = 5.0 | |
| 51 | + | defaultResizeMinPanePercent = 10.0 | |
| 52 | + | defaultTabBarPosition = "bottom" | |
| 53 | + | defaultPopupPlacement = "right" | |
| 54 | + | defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed | |
| 55 | + | defaultExternalLinkPlacement = ExternalLinkPlacementRight | |
| 56 | + | defaultFloatingPaneWidthPct = 0.82 | |
| 57 | + | defaultFloatingPaneHeightPct = 0.72 | |
| 53 | 58 | ||
| 54 | 59 | // Session defaults | |
| 55 | 60 | defaultSessionActivationShortcut = "ctrl+o" | |
| @@ -300,6 +305,33 @@ func DefaultConfig() *Config { | |||
| 300 | 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 | 335 | ResizeMode: ResizeModeConfig{ | |
| 304 | 336 | ActivationShortcut: defaultResizeActivationShortcut, | |
| 305 | 337 | TimeoutMilliseconds: defaultResizeTimeoutMilliseconds, | |
| @@ -324,7 +356,7 @@ func DefaultConfig() *Config { | |||
| 324 | 356 | Actions: map[string]ActionBinding{ | |
| 325 | 357 | "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, | |
| 326 | 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 | 360 | "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"}, | |
| 329 | 361 | "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"}, | |
| 330 | 362 | "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, | |
| @@ -341,6 +373,10 @@ func DefaultConfig() *Config { | |||
| 341 | 373 | HeightPct: defaultFloatingPaneHeightPct, | |
| 342 | 374 | Profiles: map[string]FloatingPaneProfile{}, | |
| 343 | 375 | }, | |
| 376 | + | ExternalLinks: ExternalLinksConfig{ | |
| 377 | + | Behavior: defaultExternalLinkBehavior, | |
| 378 | + | Placement: defaultExternalLinkPlacement, | |
| 379 | + | }, | |
| 344 | 380 | TabBarPosition: defaultTabBarPosition, | |
| 345 | 381 | HideTabBarWhenSingleTab: true, | |
| 346 | 382 | BrowsingContexts: browsingContextDefaults, | |
M internal/infrastructure/config/defaults_test.go +3 -1
| @@ -13,6 +13,8 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 13 | 13 | assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles) | |
| 14 | 14 | assert.False(t, cfg.Logging.CaptureGTKLogs) | |
| 15 | 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 | 19 | // Engine defaults (replaces old Performance/Privacy sections) | |
| 18 | 20 | assert.Equal(t, EngineTypeCEF, cfg.Engine.Type) | |
| @@ -41,7 +43,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 41 | 43 | ||
| 42 | 44 | // System view shortcuts are first-class global actions. | |
| 43 | 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 | 47 | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-current-page-favorite", []string{"ctrl+d"}) | |
| 46 | 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 | 32 | g.buildGlobalGroup(cfg, defaults), | |
| 33 | 33 | g.buildPaneModeGroup(cfg, defaults), | |
| 34 | 34 | g.buildTabModeGroup(cfg, defaults), | |
| 35 | + | g.buildVimModeGroup(cfg, defaults), | |
| 35 | 36 | g.buildResizeModeGroup(cfg, defaults), | |
| 36 | 37 | g.buildSessionModeGroup(cfg, defaults), | |
| 37 | 38 | } | |
| @@ -48,6 +49,7 @@ func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.Ke | |||
| 48 | 49 | g.buildGlobalGroup(defaults, defaults), | |
| 49 | 50 | g.buildPaneModeGroup(defaults, defaults), | |
| 50 | 51 | g.buildTabModeGroup(defaults, defaults), | |
| 52 | + | g.buildVimModeGroup(defaults, defaults), | |
| 51 | 53 | g.buildResizeModeGroup(defaults, defaults), | |
| 52 | 54 | g.buildSessionModeGroup(defaults, defaults), | |
| 53 | 55 | } | |
| @@ -108,6 +110,7 @@ func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error { | |||
| 108 | 110 | ||
| 109 | 111 | cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions | |
| 110 | 112 | cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions | |
| 113 | + | cfg.Workspace.VimMode.Actions = defaults.Workspace.VimMode.Actions | |
| 111 | 114 | cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions | |
| 112 | 115 | cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts | |
| 113 | 116 | 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 | 160 | // buildResizeModeGroup builds the resize mode group. | |
| 148 | 161 | func (g *KeybindingsGateway) buildResizeModeGroup(cfg, defaults *Config) port.KeybindingGroup { | |
| 149 | 162 | return port.KeybindingGroup{ | |
| @@ -208,6 +221,11 @@ func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingR | |||
| 208 | 221 | existing.Keys = req.Keys | |
| 209 | 222 | 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 | 229 | case "resize": | |
| 212 | 230 | if existing, ok := cfg.Workspace.ResizeMode.Actions[req.Action]; ok { | |
| 213 | 231 | existing.Keys = req.Keys | |
| @@ -236,6 +254,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string) | |||
| 236 | 254 | if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok { | |
| 237 | 255 | return binding.Keys | |
| 238 | 256 | } | |
| 257 | + | case "vim": | |
| 258 | + | if binding, ok := defaults.Workspace.VimMode.Actions[action]; ok { | |
| 259 | + | return binding.Keys | |
| 260 | + | } | |
| 239 | 261 | case "resize": | |
| 240 | 262 | if binding, ok := defaults.Workspace.ResizeMode.Actions[action]; ok { | |
| 241 | 263 | return binding.Keys | |
| @@ -269,6 +291,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction | |||
| 269 | 291 | addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions) | |
| 270 | 292 | addBindings("pane", cfg.Workspace.PaneMode.Actions) | |
| 271 | 293 | addBindings("tab", cfg.Workspace.TabMode.Actions) | |
| 294 | + | addBindings("vim", cfg.Workspace.VimMode.Actions) | |
| 272 | 295 | addBindings("resize", cfg.Workspace.ResizeMode.Actions) | |
| 273 | 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 | 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 | 178 | // Apply transformed config back to viper | |
| 183 | 179 | for key, value := range rawConfig { | |
| 184 | 180 | m.viper.Set(key, value) | |
| @@ -378,6 +374,7 @@ func ensureDatabasePath(config *Config) error { | |||
| 378 | 374 | } | |
| 379 | 375 | ||
| 380 | 376 | func normalizeConfig(config *Config) { | |
| 377 | + | normalizeExternalLinks(config) | |
| 381 | 378 | normalizeAppearance(config) | |
| 382 | 379 | normalizeMedia(config) | |
| 383 | 380 | normalizeEngineConfig(config) | |
| @@ -390,6 +387,20 @@ func normalizeBrowsingContexts(config *Config) { | |||
| 390 | 387 | 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 | 404 | func normalizeEngineConfig(config *Config) { | |
| 394 | 405 | // Normalize GSK renderer (engine.webkit) | |
| 395 | 406 | switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) { | |
| @@ -695,6 +706,11 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 695 | 706 | m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut) | |
| 696 | 707 | m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds) | |
| 697 | 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 | 714 | m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut) | |
| 699 | 715 | m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds) | |
| 700 | 716 | m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent) | |
| @@ -704,6 +720,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 704 | 720 | m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct) | |
| 705 | 721 | m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct) | |
| 706 | 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 | 725 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | |
| 708 | 726 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | |
| 709 | 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 | 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 | 40 | home := t.TempDir() | |
| 41 | 41 | t.Setenv("HOME", home) | |
| 42 | 42 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| @@ -64,10 +64,65 @@ desc = "Custom stack pane" | |||
| 64 | 64 | cfg := mgr.Get() | |
| 65 | 65 | require.NotNil(t, cfg) | |
| 66 | 66 | ||
| 67 | - | eject, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"] | |
| 68 | - | require.True(t, ok) | |
| 69 | - | assert.Equal(t, []string{"w"}, eject.Keys) | |
| 67 | + | _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"] | |
| 68 | + | assert.False(t, ok) | |
| 70 | 69 | ||
| 71 | 70 | stack := cfg.Workspace.PaneMode.Actions["stack-pane"] | |
| 72 | 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 | 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 | 53 | func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) { | |
| 43 | 54 | cfg := DefaultConfig() | |
| 44 | 55 | cfg.Engine.CookiePolicy = CookiePolicy("INVALID") | |
M internal/infrastructure/config/migrate.go +228 -13
| @@ -25,11 +25,6 @@ type Migrator struct { | |||
| 25 | 25 | 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 | 28 | // NewMigrator creates a new Migrator instance. | |
| 34 | 29 | func NewMigrator() *Migrator { | |
| 35 | 30 | v := viper.New() | |
| @@ -58,17 +53,22 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) { | |||
| 58 | 53 | return nil, nil | |
| 59 | 54 | } | |
| 60 | 55 | ||
| 61 | - | // Get user-defined keys from the TOML file | |
| 62 | - | userKeys, err := m.getUserConfigKeys(configFile) | |
| 56 | + | userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile) | |
| 63 | 57 | if err != nil { | |
| 64 | 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 | 66 | // Get all default keys | |
| 68 | 67 | defaultKeys := m.getAllDefaultKeys() | |
| 69 | 68 | ||
| 70 | 69 | // Find missing keys (in defaults but not in user config) | |
| 71 | 70 | missingKeys := m.findMissingKeys(defaultKeys, userKeys) | |
| 71 | + | missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues)) | |
| 72 | 72 | ||
| 73 | 73 | if len(missingKeys) == 0 { | |
| 74 | 74 | return nil, nil | |
| @@ -119,13 +119,17 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |||
| 119 | 119 | 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 | 127 | var changes []port.KeyChange | |
| 124 | - | changes = append(changes, legacyBrowsingContextRenames...) | |
| 128 | + | changes = append(changes, legacyRenames...) | |
| 125 | 129 | ||
| 126 | - | handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyBrowsingContextRenames)) | |
| 127 | - | handledLegacyMissingKeys := make(map[string]bool, len(legacyBrowsingContextRenames)) | |
| 128 | - | for _, change := range legacyBrowsingContextRenames { | |
| 130 | + | handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyRenames)) | |
| 131 | + | handledLegacyMissingKeys := make(map[string]bool, len(legacyRenames)) | |
| 132 | + | for _, change := range legacyRenames { | |
| 129 | 133 | handledLegacyDeprecatedKeys[change.OldKey] = true | |
| 130 | 134 | handledLegacyMissingKeys[change.NewKey] = true | |
| 131 | 135 | } | |
| @@ -299,6 +303,7 @@ func (m *Migrator) defaultActionMaps() []defaultActionMap { | |||
| 299 | 303 | {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions}, | |
| 300 | 304 | {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions}, | |
| 301 | 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 | 307 | {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions}, | |
| 303 | 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 | 421 | transformer.TransformLegacyActions(rawConfig) | |
| 417 | 422 | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 418 | 423 | transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig) | |
| 424 | + | m.migrateLegacyVimMode(rawConfig) | |
| 419 | 425 | ||
| 420 | 426 | // Build sets of keys to remove and renames to apply | |
| 421 | 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 config | |
| 452 | + | // Remove deprecated and renamed keys from the raw config. | |
| 447 | 453 | for key := range keysToRemove { | |
| 448 | 454 | m.deleteNestedKey(rawConfig, key) | |
| 449 | 455 | } | |
| 456 | + | m.upgradeLegacyFavoritesSidebarDefault(rawConfig) | |
| 450 | 457 | m.mergeMissingDefaultActions(rawConfig) | |
| 451 | 458 | ||
| 452 | 459 | // Create a new Viper instance with defaults for added keys | |
| @@ -722,6 +729,31 @@ func (m *Migrator) getRawUserConfigKeysWithValues(configFile string) (map[string | |||
| 722 | 729 | 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 | 757 | func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange { | |
| 726 | 758 | type renamePair struct { | |
| 727 | 759 | oldKey string | |
| @@ -758,6 +790,104 @@ func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any | |||
| 758 | 790 | 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 | 891 | // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values. | |
| 762 | 892 | func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) { | |
| 763 | 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 | 1112 | func appendUniqueKeys(base, extra []string) []string { | |
| 898 | 1113 | if len(extra) == 0 { | |
| 899 | 1114 | return base | |
M internal/infrastructure/config/migrate_test.go +258 -0
| @@ -816,6 +816,41 @@ func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) { | |||
| 816 | 816 | assert.Equal(t, "Custom move pane", move["desc"]) | |
| 817 | 817 | } | |
| 818 | 818 | ||
| 819 | + | func TestMigrator_MergeMissingDefaultActions_VimMode(t *testing.T) { | |
| 820 | + | m := NewMigrator() | |
| 821 | + | ||
| 822 | + | rawConfig := map[string]any{ | |
| 823 | + | "workspace": map[string]any{ | |
| 824 | + | "vim_mode": map[string]any{ | |
| 825 | + | "actions": map[string]any{ | |
| 826 | + | "heading-next": map[string]any{ | |
| 827 | + | "keys": []string{"]]"}, | |
| 828 | + | "desc": "Custom next heading", | |
| 829 | + | }, | |
| 830 | + | }, | |
| 831 | + | }, | |
| 832 | + | }, | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | m.mergeMissingDefaultActions(rawConfig) | |
| 836 | + | ||
| 837 | + | actionsAny := m.getNestedValue(rawConfig, "workspace.vim_mode.actions") | |
| 838 | + | actions, ok := actionsAny.(map[string]any) | |
| 839 | + | require.True(t, ok) | |
| 840 | + | ||
| 841 | + | focusAny, hasFocus := actions["focus-input"] | |
| 842 | + | require.True(t, hasFocus) | |
| 843 | + | focus, ok := focusAny.(ActionBinding) | |
| 844 | + | require.True(t, ok) | |
| 845 | + | assert.Equal(t, []string{"gi"}, focus.Keys) | |
| 846 | + | ||
| 847 | + | headingAny, hasHeading := actions["heading-next"] | |
| 848 | + | require.True(t, hasHeading) | |
| 849 | + | heading, ok := headingAny.(map[string]any) | |
| 850 | + | require.True(t, ok) | |
| 851 | + | assert.Equal(t, "Custom next heading", heading["desc"]) | |
| 852 | + | } | |
| 853 | + | ||
| 819 | 854 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | |
| 820 | 855 | m := NewMigrator() | |
| 821 | 856 | ||
| @@ -1175,6 +1210,118 @@ func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) { | |||
| 1175 | 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 | 1325 | func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) { | |
| 1179 | 1326 | home := t.TempDir() | |
| 1180 | 1327 | t.Setenv("HOME", home) | |
| @@ -1282,3 +1429,114 @@ section: | |||
| 1282 | 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 | 24 | // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style). | |
| 25 | 25 | type ResizeModeConfig = entity.ResizeModeConfig | |
| 26 | 26 | ||
| 27 | + | // VimModeConfig defines modal behavior for Vim-style scrolling. | |
| 28 | + | type VimModeConfig = entity.VimModeConfig | |
| 29 | + | ||
| 27 | 30 | // SessionModeConfig defines modal behavior for session management. | |
| 28 | 31 | type SessionModeConfig = entity.SessionModeConfig | |
| 29 | 32 | ||
| @@ -42,6 +45,29 @@ type FloatingPaneConfig = entity.FloatingPaneConfig | |||
| 42 | 45 | // WorkspaceStylingConfig defines visual styling for workspace panes. | |
| 43 | 46 | 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 | 71 | // PopupBehavior defines how popup windows should be opened. | |
| 46 | 72 | 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 | 472 | Description: "Key bindings for tab mode actions", | |
| 473 | 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 | 513 | // Resize mode | |
| 476 | 514 | { | |
| 477 | 515 | Key: "workspace.resize_mode.activation_shortcut", | |
| @@ -541,6 +579,23 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||
| 541 | 579 | Description: "Named floating pane URL profiles with keys, url, and optional desc", | |
| 542 | 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 | 599 | // Browsing contexts | |
| 545 | 600 | { | |
| 546 | 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 | 146 | "search_shortcuts": "search_shortcuts.<key>", | |
| 147 | 147 | "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>", | |
| 148 | 148 | "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>", | |
| 149 | + | "workspace.vim_mode.actions": "workspace.vim_mode.actions.<action>", | |
| 149 | 150 | "workspace.resize_mode.actions": "workspace.resize_mode.actions.<action>", | |
| 150 | 151 | "workspace.floating_pane.profiles": "workspace.floating_pane.profiles.<name>", | |
| 151 | 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 | 12 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 13 | 13 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | |
| 14 | + | "github.com/bnema/dumber/internal/domain/vimkeys" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | const cefLogSeverityDisabled = 99 | |
| @@ -24,10 +25,12 @@ func validateConfig(config *Config) error { | |||
| 24 | 25 | validationErrors = append(validationErrors, validateAppearance(config)...) | |
| 25 | 26 | validationErrors = append(validationErrors, validateSearchEngine(config)...) | |
| 26 | 27 | validationErrors = append(validationErrors, validatePopups(config)...) | |
| 28 | + | validationErrors = append(validationErrors, validateExternalLinks(config)...) | |
| 27 | 29 | validationErrors = append(validationErrors, validateWorkspaceStyling(config)...) | |
| 28 | 30 | validationErrors = append(validationErrors, validatePaneMode(config)...) | |
| 29 | 31 | validationErrors = append(validationErrors, validateTabBar(config)...) | |
| 30 | 32 | validationErrors = append(validationErrors, validateTabMode(config)...) | |
| 33 | + | validationErrors = append(validationErrors, validateVimMode(config)...) | |
| 31 | 34 | validationErrors = append(validationErrors, validateFloatingPane(config)...) | |
| 32 | 35 | validationErrors = append(validationErrors, validateLogging(config)...) | |
| 33 | 36 | validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...) | |
| @@ -187,6 +190,28 @@ func validatePopups(config *Config) []string { | |||
| 187 | 190 | 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 | 215 | func validateWorkspaceStyling(config *Config) []string { | |
| 191 | 216 | var validationErrors []string | |
| 192 | 217 | if config.Workspace.Styling.BorderWidth < 0 { | |
| @@ -202,32 +227,7 @@ func validateWorkspaceStyling(config *Config) []string { | |||
| 202 | 227 | } | |
| 203 | 228 | ||
| 204 | 229 | func validatePaneMode(config *Config) []string { | |
| 205 | - | var validationErrors []string | |
| 206 | - | if config.Workspace.PaneMode.TimeoutMilliseconds < 0 { | |
| 207 | - | validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative") | |
| 208 | - | } | |
| 209 | - | if len(config.Workspace.PaneMode.Actions) == 0 { | |
| 210 | - | validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty") | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | seenKeys := make(map[string]string) | |
| 214 | - | for action, binding := range config.Workspace.PaneMode.Actions { | |
| 215 | - | if len(binding.Keys) == 0 { | |
| 216 | - | validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action)) | |
| 217 | - | } | |
| 218 | - | for _, key := range binding.Keys { | |
| 219 | - | if existingAction, exists := seenKeys[key]; exists { | |
| 220 | - | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 221 | - | "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", | |
| 222 | - | key, | |
| 223 | - | existingAction, | |
| 224 | - | action, | |
| 225 | - | )) | |
| 226 | - | } | |
| 227 | - | seenKeys[key] = action | |
| 228 | - | } | |
| 229 | - | } | |
| 230 | - | return validationErrors | |
| 230 | + | return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions) | |
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | 233 | func validateTabBar(config *Config) []string { | |
| @@ -240,34 +240,138 @@ func validateTabBar(config *Config) []string { | |||
| 240 | 240 | } | |
| 241 | 241 | ||
| 242 | 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 | 272 | var validationErrors []string | |
| 244 | - | if config.Workspace.TabMode.TimeoutMilliseconds < 0 { | |
| 245 | - | validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative") | |
| 273 | + | if timeoutMS < 0 { | |
| 274 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath)) | |
| 275 | + | } | |
| 276 | + | if len(actions) == 0 { | |
| 277 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) | |
| 246 | 278 | } | |
| 247 | - | if len(config.Workspace.TabMode.Actions) == 0 { | |
| 248 | - | validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty") | |
| 279 | + | ||
| 280 | + | if keyCanon == nil { | |
| 281 | + | keyCanon = func(key string) (string, error) { return key, nil } | |
| 249 | 282 | } | |
| 250 | 283 | ||
| 251 | - | tabSeenKeys := make(map[string]string) | |
| 252 | - | for action, binding := range config.Workspace.TabMode.Actions { | |
| 284 | + | actionNames := make([]string, 0, len(actions)) | |
| 285 | + | for action := range actions { | |
| 286 | + | actionNames = append(actionNames, action) | |
| 287 | + | } | |
| 288 | + | sort.Strings(actionNames) | |
| 289 | + | ||
| 290 | + | seenSequence := make(map[string]string) | |
| 291 | + | for _, action := range actionNames { | |
| 292 | + | binding := actions[action] | |
| 253 | 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 { | |
| 257 | - | if existingAction, exists := tabSeenKeys[key]; exists { | |
| 297 | + | keys := append([]string(nil), binding.Keys...) | |
| 298 | + | sort.Strings(keys) | |
| 299 | + | for _, key := range keys { | |
| 300 | + | canonical, err := keyCanon(key) | |
| 301 | + | if err != nil { | |
| 258 | 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 | 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 | 342 | existingAction, | |
| 262 | 343 | action, | |
| 263 | 344 | )) | |
| 264 | 345 | } | |
| 265 | - | tabSeenKeys[key] = action | |
| 346 | + | seenSequence[canonical] = action | |
| 266 | 347 | } | |
| 267 | 348 | } | |
| 268 | 349 | 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 | 375 | func validateFloatingPane(config *Config) []string { | |
| 272 | 376 | var validationErrors []string | |
| 273 | 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 | 329 | func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) { | |
| 298 | 330 | tmpDir := t.TempDir() | |
| 299 | 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 | 446 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | |
| 447 | 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 | 453 | const layerShellPreloadToken = "libgtk4" + "-layer-shell" | |
| 450 | 454 | ||
| 451 | 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 | 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 | 490 | if resolveExecutablePath == nil { | |
| 470 | 491 | resolveExecutablePath = getExecutablePath | |
| 471 | 492 | } | |
| @@ -479,7 +500,10 @@ func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, er | |||
| 479 | 500 | } | |
| 480 | 501 | ||
| 481 | 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 | 507 | if err := spawnDetachedProcess(cmd); err != nil { | |
| 484 | 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 | 238 | func TestSetAsDefaultBrowserAlsoUpdatesMimeHandlers(t *testing.T) { | |
| 224 | 239 | t.Setenv("HOME", t.TempDir()) | |
| 225 | 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 | 41 | type browserLaunchRequest struct { | |
| 42 | - | RequestID string `json:"request_id,omitempty"` | |
| 43 | - | URL string `json:"url"` | |
| 42 | + | RequestID string `json:"request_id,omitempty"` | |
| 43 | + | URL string `json:"url"` | |
| 44 | + | Action browserLaunchAction `json:"action,omitempty"` | |
| 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 | 54 | type browserLaunchResponse struct { | |
| 47 | 55 | RequestID string `json:"request_id,omitempty"` | |
| 48 | 56 | Accepted bool `json:"accepted,omitempty"` | |
| @@ -68,7 +76,15 @@ func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay | |||
| 68 | 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 | 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 | 88 | socketPath, err := r.socketPath() | |
| 73 | 89 | if err != nil { | |
| 74 | 90 | return false, err | |
| @@ -93,7 +109,7 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str | |||
| 93 | 109 | if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil { | |
| 94 | 110 | 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 | 113 | return false, err | |
| 98 | 114 | } | |
| 99 | 115 | ||
| @@ -353,20 +369,34 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n | |||
| 353 | 369 | log.Debug(). | |
| 354 | 370 | Str("request_id", requestID). | |
| 355 | 371 | Str("url_host", safeURLHost(request.URL)). | |
| 356 | - | Msg("browser launch relay calling browser window opener") | |
| 357 | - | if err := opener.OpenFreshWindow(ctx, request.URL); err != nil { | |
| 372 | + | Msg("browser launch relay calling browser URL opener") | |
| 373 | + | var err error | |
| 374 | + | switch request.Action { | |
| 375 | + | case "", browserLaunchActionOpenExternalURL: | |
| 376 | + | err = opener.OpenExternalURL(ctx, request.URL) | |
| 377 | + | case browserLaunchActionOpenFreshWindow: | |
| 378 | + | err = opener.OpenFreshWindow(ctx, request.URL) | |
| 379 | + | default: | |
| 380 | + | log.Warn(). | |
| 381 | + | Str("request_id", requestID). | |
| 382 | + | Str("url_host", safeURLHost(request.URL)). | |
| 383 | + | Str("action", string(request.Action)). | |
| 384 | + | Msg("browser launch relay rejected unknown action") | |
| 385 | + | return | |
| 386 | + | } | |
| 387 | + | if err != nil { | |
| 358 | 388 | log.Warn().Err(err). | |
| 359 | 389 | Str("request_id", requestID). | |
| 360 | 390 | Str("url_host", safeURLHost(request.URL)). | |
| 361 | 391 | Dur("elapsed", time.Since(started)). | |
| 362 | - | Msg("browser launch relay opener failed") | |
| 392 | + | Msg("browser launch relay browser URL opener failed") | |
| 363 | 393 | return | |
| 364 | 394 | } | |
| 365 | 395 | log.Debug(). | |
| 366 | 396 | Str("request_id", requestID). | |
| 367 | 397 | Str("url_host", safeURLHost(request.URL)). | |
| 368 | 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 | 18 | 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 | 24 | func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error { | |
| 21 | 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 | 41 | func waitForSocket(t *testing.T, path string) { | |
| 25 | 42 | t.Helper() | |
| 26 | 43 | ||
| @@ -112,10 +129,10 @@ func overrideBrowserLaunchRequestID(t *testing.T, id string) func() { | |||
| 112 | 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 | 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 | 137 | require.NoError(t, err) | |
| 121 | 138 | assert.False(t, delivered) | |
| @@ -138,7 +155,7 @@ func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) { | |||
| 138 | 155 | ||
| 139 | 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 | 159 | require.NoError(t, err) | |
| 143 | 160 | require.True(t, delivered) | |
| 144 | 161 | require.Equal(t, "https://example.com/same-namespace", <-received) | |
| @@ -151,7 +168,7 @@ func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) | |||
| 151 | 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 | 172 | ipc := testIPC(shortTempDir(t)) | |
| 156 | 173 | relay := NewBrowserLaunchRelay(ipc) | |
| 157 | 174 | restore := overrideBrowserLaunchRequestID(t, "request-test-1") | |
| @@ -180,7 +197,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch | |||
| 180 | 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 | 202 | require.NoError(t, err) | |
| 186 | 203 | require.True(t, delivered) | |
| @@ -189,7 +206,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch | |||
| 189 | 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 | 210 | ipc := testIPC(shortTempDir(t)) | |
| 194 | 211 | relay := NewBrowserLaunchRelay(ipc) | |
| 195 | 212 | restore := overrideBrowserLaunchRequestID(t, "request-test-2") | |
| @@ -216,30 +233,36 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequ | |||
| 216 | 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 | 238 | require.Error(t, err) | |
| 222 | 239 | require.True(t, delivered) | |
| 223 | 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 | 244 | ipc := testIPC(shortTempDir(t)) | |
| 228 | 245 | relay := NewBrowserLaunchRelay(ipc) | |
| 229 | 246 | ||
| 230 | 247 | received := make(chan string, 1) | |
| 231 | 248 | ctx := t.Context() | |
| 232 | 249 | ||
| 233 | - | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 234 | - | received <- url | |
| 235 | - | return nil | |
| 236 | - | })) | |
| 250 | + | closer, err := relay.Listen(ctx, browserWindowOpener{ | |
| 251 | + | openExternalURL: func(_ context.Context, url string) error { | |
| 252 | + | received <- url | |
| 253 | + | return nil | |
| 254 | + | }, | |
| 255 | + | openFreshWindow: func(context.Context, string) error { | |
| 256 | + | t.Fatal("external URL relay request must preserve configured placement") | |
| 257 | + | return nil | |
| 258 | + | }, | |
| 259 | + | }) | |
| 237 | 260 | require.NoError(t, err) | |
| 238 | 261 | defer closer.Close() | |
| 239 | 262 | ||
| 240 | 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 | 267 | require.NoError(t, err) | |
| 245 | 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 | 310 | func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) { | |
| 256 | 311 | relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{}) | |
| 257 | 312 | ||
| @@ -279,7 +334,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||
| 279 | 334 | _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 280 | 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 | 338 | require.NoError(t, err) | |
| 284 | 339 | require.True(t, delivered) | |
| 285 | 340 | ||
| @@ -310,7 +365,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||
| 310 | 365 | ||
| 311 | 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 | 369 | require.NoError(t, err) | |
| 315 | 370 | require.True(t, delivered) | |
| 316 | 371 | ||
| @@ -345,7 +400,7 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||
| 345 | 400 | require.NoError(t, err) | |
| 346 | 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 | 405 | require.NoError(t, err) | |
| 351 | 406 | assert.True(t, delivered) | |
| @@ -387,12 +442,12 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | |||
| 387 | 442 | waitForSocketGone(t, ipc.BrowserLaunchSocket) | |
| 388 | 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 | 446 | require.NoError(t, err) | |
| 392 | 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 | 451 | ipc := testIPC(shortTempDir(t)) | |
| 397 | 452 | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 398 | 453 | ||
| @@ -419,13 +474,13 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||
| 419 | 474 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |
| 420 | 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 | 478 | require.Error(t, err) | |
| 424 | 479 | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 425 | 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 | 484 | ipc := testIPC(shortTempDir(t)) | |
| 430 | 485 | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 431 | 486 | ||
| @@ -459,7 +514,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe | |||
| 459 | 514 | result := make(chan deliverResult, 1) | |
| 460 | 515 | go func() { | |
| 461 | 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 | 518 | result <- deliverResult{delivered: delivered, err: err} | |
| 464 | 519 | }() | |
| 465 | 520 | <-started | |
| @@ -470,7 +525,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe | |||
| 470 | 525 | require.Error(t, got.err) | |
| 471 | 526 | require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed) | |
| 472 | 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 | 630 | result := make(chan deliverResult, 1) | |
| 576 | 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 | 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 | 13 | // received the request, but the caller could not confirm that in time. | |
| 14 | 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 | 17 | type BrowserLauncher struct { | |
| 18 | 18 | relay port.BrowserLaunchRelay | |
| 19 | 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. | |
| 33 | - | func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) error { | |
| 32 | + | // LaunchFreshWindowURL forwards the URL to a running instance when possible, | |
| 33 | + | // always requesting a new browser window. This keeps standalone omnibox | |
| 34 | + | // navigation independent from external-link workspace placement settings. | |
| 35 | + | func (l *BrowserLauncher) LaunchFreshWindowURL(ctx context.Context, url string) error { | |
| 34 | 36 | if l == nil { | |
| 35 | 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 | 54 | return fmt.Errorf("launch dumber browse: %w", err) | |
| 53 | 55 | } | |
| 54 | 56 | return nil | |
M internal/infrastructure/desktop/browser_launcher_test.go +11 -10
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 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 | 15 | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 16 | 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 | 26 | 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 | 31 | require.NoError(t, err) | |
| 32 | 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 | 36 | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 37 | 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 | 47 | 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 | 52 | require.NoError(t, err) | |
| 53 | 53 | require.NotNil(t, gotCmd) | |
| 54 | 54 | assert.Equal(t, "/usr/bin/dumber", gotCmd.Path) | |
| 55 | 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 | 60 | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 60 | 61 | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, ErrBrowserLaunchRelayUnconfirmed) | |
| 61 | 62 | ||
| @@ -69,13 +70,13 @@ func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmb | |||
| 69 | 70 | 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 | 75 | require.Error(t, err) | |
| 75 | 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 | 80 | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 80 | 81 | wantErr := errors.New("relay exploded") | |
| 81 | 82 | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr) | |
| @@ -90,17 +91,17 @@ func TestBrowserLauncher_LaunchURL_PropagatesRelayError(t *testing.T) { | |||
| 90 | 91 | 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 | 96 | require.Error(t, err) | |
| 96 | 97 | require.ErrorIs(t, err, wantErr) | |
| 97 | 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 | 102 | 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 | 106 | require.Error(t, err) | |
| 106 | 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 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "sort" | |
| 7 | + | "strconv" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| 9 | 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 JS | |
| 183 | + | // accentDetectionScriptKeys is built at init from entity.AccentMap so the JS | |
| 183 | 184 | // filter stays in sync with the Go-side accent table. | |
| 184 | - | var accentDetectionScript string | |
| 185 | + | var accentDetectionScriptKeys string | |
| 185 | 186 | ||
| 186 | 187 | func buildExplicitCopyScript() string { | |
| 187 | 188 | return explicitCopyScript | |
| 188 | 189 | } | |
| 189 | 190 | ||
| 190 | - | func init() { | |
| 191 | - | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | |
| 192 | - | keys := make([]string, 0, len(entity.AccentMap)) | |
| 193 | - | for k := range entity.AccentMap { | |
| 194 | - | keys = append(keys, fmt.Sprintf("'%c'", k)) | |
| 195 | - | } | |
| 196 | - | sort.Strings(keys) | |
| 197 | - | ||
| 198 | - | accentDetectionScript = fmt.Sprintf(`(function() { | |
| 191 | + | func buildAccentDetectionScript(token string) string { | |
| 192 | + | return fmt.Sprintf(`(function() { | |
| 199 | 193 | 'use strict'; | |
| 200 | 194 | const accentKeys = new Set([%s]); | |
| 195 | + | const editableFocusToken = %q; | |
| 201 | 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 | 210 | function isEditableTarget(target) { | |
| 204 | 211 | const el = target instanceof Element ? target : null; | |
| 205 | 212 | if (!el) return false; | |
| 206 | - | if (el.closest('[contenteditable=""], [contenteditable="true"]')) return true; | |
| 207 | - | if (el.closest('textarea')) return true; | |
| 213 | + | if (el.isContentEditable) return true; | |
| 214 | + | const editableAncestor = el.closest('[contenteditable]'); | |
| 215 | + | if (editableAncestor && editableAncestor.isContentEditable) return true; | |
| 216 | + | const textarea = el.closest('textarea'); | |
| 217 | + | if (textarea) return !textarea.matches(':disabled') && !textarea.readOnly; | |
| 208 | 218 | const input = el.closest('input'); | |
| 209 | 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 | 225 | document.addEventListener('keydown', function(e) { | |
| @@ -234,12 +246,50 @@ func init() { | |||
| 234 | 246 | } | |
| 235 | 247 | }, true); | |
| 236 | 248 | ||
| 237 | - | document.addEventListener('focusin', function(e) { | |
| 238 | - | if (isEditableTarget(e.target)) { | |
| 239 | - | window.__dumber_lastEditableEl = e.target; | |
| 240 | - | } | |
| 241 | - | }, true); | |
| 242 | - | })();`, strings.Join(keys, ",")) | |
| 249 | + | function canReportNegativeFocus(target) { | |
| 250 | + | if (window.top === window) return true; | |
| 251 | + | return lastEditableFocusState === true && window.__dumber_lastEditableEl === target; | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | document.addEventListener('focusin', function(e) { | |
| 255 | + | if (e && e.isTrusted === false) return; | |
| 256 | + | if (isEditableTarget(e.target)) { | |
| 257 | + | window.__dumber_lastEditableEl = e.target; | |
| 258 | + | postEditableFocus(true); | |
| 259 | + | return; | |
| 260 | + | } | |
| 261 | + | if (canReportNegativeFocus(window.__dumber_lastEditableEl)) { | |
| 262 | + | postEditableFocus(false); | |
| 263 | + | } | |
| 264 | + | }, true); | |
| 265 | + | ||
| 266 | + | document.addEventListener('focusout', function(e) { | |
| 267 | + | if (e && e.isTrusted === false) return; | |
| 268 | + | if (!isEditableTarget(e.target)) return; | |
| 269 | + | setTimeout(function() { | |
| 270 | + | if (!isEditableTarget(document.activeElement) && canReportNegativeFocus(e.target)) { | |
| 271 | + | postEditableFocus(false); | |
| 272 | + | } | |
| 273 | + | }, 0); | |
| 274 | + | }, true); | |
| 275 | + | ||
| 276 | + | if (isEditableTarget(document.activeElement)) { | |
| 277 | + | window.__dumber_lastEditableEl = document.activeElement; | |
| 278 | + | postEditableFocus(true); | |
| 279 | + | } else if (window.top === window) { | |
| 280 | + | postEditableFocus(false); | |
| 281 | + | } | |
| 282 | + | })();`, accentDetectionScriptKeys, token) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func init() { | |
| 286 | + | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | |
| 287 | + | keys := make([]string, 0, len(entity.AccentMap)) | |
| 288 | + | for k := range entity.AccentMap { | |
| 289 | + | keys = append(keys, fmt.Sprintf("'%c'", k)) | |
| 290 | + | } | |
| 291 | + | sort.Strings(keys) | |
| 292 | + | accentDetectionScriptKeys = strings.Join(keys, ",") | |
| 243 | 293 | } | |
| 244 | 294 | ||
| 245 | 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 | 500 | // 8. Inject accent key detection for all pages and all frames (unconditional). | |
| 451 | 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 | 510 | addScript( | |
| 453 | 511 | webkit.NewUserScript( | |
| 454 | - | accentDetectionScript, | |
| 512 | + | accentScript, | |
| 455 | 513 | accentDetectionInjectionMode, | |
| 456 | 514 | webkit.UserScriptInjectAtDocumentEndValue, | |
| 457 | 515 | 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 | 21 | func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) { | |
| 22 | + | script := buildAccentDetectionScript("") | |
| 22 | 23 | assert.Contains(t, | |
| 23 | - | accentDetectionScript, | |
| 24 | + | script, | |
| 24 | 25 | "window.__dumber_lastEditableEl = e.target", | |
| 25 | 26 | "accent detection script must track the last focused editable element", | |
| 26 | 27 | ) | |
| 27 | 28 | assert.Contains(t, | |
| 28 | - | accentDetectionScript, | |
| 29 | + | script, | |
| 29 | 30 | "document.addEventListener('focusin'", | |
| 30 | 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 | 62 | func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | |
M internal/infrastructure/webkit/messaging.go +68 -26
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 12 | "github.com/bnema/puregotk/v4/javascriptcore" | |
| 13 | 13 | "github.com/bnema/puregotk/v4/webkit" | |
| 14 | + | "github.com/rs/zerolog" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 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 | 189 | log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry") | |
| 189 | 190 | ||
| 190 | - | if valuePtr == 0 { | |
| 191 | - | log.Warn().Msg("received script message with nil value pointer") | |
| 192 | - | return | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr) | |
| 196 | - | if jscValue == nil { | |
| 197 | - | log.Warn().Msg("failed to wrap script message JSC value") | |
| 198 | - | return | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | rawJSON := jscValue.ToJson(0) | |
| 202 | - | log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result") | |
| 203 | - | if rawJSON == "" { | |
| 204 | - | // This typically happens when the JS side sends non-serializable values | |
| 205 | - | // like Svelte 5 Proxy objects or functions. The frontend should use | |
| 206 | - | // JSON.parse(JSON.stringify(obj)) to convert Proxies to plain objects. | |
| 207 | - | log.Error(). | |
| 208 | - | Msg("script message JSON serialization failed (ToJson returned empty) - " + | |
| 209 | - | "this usually means JS sent non-serializable values like Proxy objects") | |
| 210 | - | return | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | var msg Message | |
| 214 | - | if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil { | |
| 215 | - | log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message") | |
| 191 | + | msg, ok := decodeScriptMessage(valuePtr, log) | |
| 192 | + | if !ok { | |
| 216 | 193 | return | |
| 217 | 194 | } | |
| 218 | 195 | ||
| @@ -221,6 +198,9 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||
| 221 | 198 | log.Warn().Msg("rejecting script message from unknown sender") | |
| 222 | 199 | return | |
| 223 | 200 | } | |
| 201 | + | if r.handleAllowlistedBridgeMessage(senderWV, msg) { | |
| 202 | + | return | |
| 203 | + | } | |
| 224 | 204 | if !isTrustedBridgeWebView(senderWV) { | |
| 225 | 205 | log.Warn(). | |
| 226 | 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 | 324 | func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView { | |
| 283 | 325 | ptr := senderUCM.GoPointer() | |
| 284 | 326 | if ptr == 0 { | |
M internal/infrastructure/webkit/messaging_test.go +53 -0
| @@ -2,9 +2,11 @@ package webkit | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 10 | "github.com/stretchr/testify/require" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -35,6 +37,57 @@ func TestMessageRouterBaseContextConcurrentUpdate(t *testing.T) { | |||
| 35 | 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 | 91 | func TestIsTrustedBridgeURI(t *testing.T) { | |
| 39 | 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 | 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 | 192 | // Apply filters if available (may not have been applied during prewarm) | |
| 183 | 193 | if p.filterApplier != nil { | |
| 184 | 194 | p.filterApplier.ApplyTo(ctx, wv.ucm) | |
| @@ -240,6 +250,13 @@ func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuP | |||
| 240 | 250 | 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 | 260 | func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool { | |
| 244 | 261 | 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 | 16 | t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe") | |
| 17 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/rand" | |
| 6 | + | "crypto/subtle" | |
| 7 | + | "encoding/hex" | |
| 5 | 8 | "fmt" | |
| 6 | 9 | "net/url" | |
| 7 | 10 | "os" | |
| @@ -16,6 +19,7 @@ import ( | |||
| 16 | 19 | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | 20 | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 18 | 21 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 22 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 19 | 23 | "github.com/bnema/dumber/internal/logging" | |
| 20 | 24 | "github.com/bnema/puregotk/v4/gdk" | |
| 21 | 25 | "github.com/bnema/puregotk/v4/gio" | |
| @@ -34,6 +38,26 @@ var _ port.Printer = (*WebView)(nil) | |||
| 34 | 38 | var _ port.PopupLifecycleCapable = (*WebView)(nil) | |
| 35 | 39 | var _ port.PopupFeatureResolver = (*WebView)(nil) | |
| 36 | 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 | 62 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | |
| 39 | 63 | // Infrastructure layer uses the type defined in the application port. | |
| @@ -163,6 +187,7 @@ type WebView struct { | |||
| 163 | 187 | OnEnterFullscreen func() bool // Return true to prevent fullscreen | |
| 164 | 188 | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | |
| 165 | 189 | OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops | |
| 190 | + | OnEditableFocusChanged func(editable bool) // Called when page editable focus changes | |
| 166 | 191 | OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving) | |
| 167 | 192 | OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) | |
| 168 | 193 | browsingContextDecision dto.HostDecision | |
| @@ -179,8 +204,9 @@ type WebView struct { | |||
| 179 | 204 | logger zerolog.Logger | |
| 180 | 205 | mu sync.RWMutex | |
| 181 | 206 | ||
| 182 | - | frontendAttached atomic.Bool | |
| 183 | - | navigationActive atomic.Bool | |
| 207 | + | frontendAttached atomic.Bool | |
| 208 | + | navigationActive atomic.Bool | |
| 209 | + | editableFocusBridgeToken string | |
| 184 | 210 | ||
| 185 | 211 | // asyncCallbacks keeps references to async JS callbacks to prevent GC | |
| 186 | 212 | asyncCallbacks []any | |
| @@ -346,11 +372,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan | |||
| 346 | 372 | } | |
| 347 | 373 | ||
| 348 | 374 | wv := &WebView{ | |
| 349 | - | inner: inner, | |
| 350 | - | ucm: inner.GetUserContentManager(), | |
| 351 | - | logger: log.With().Str("component", "webview").Logger(), | |
| 352 | - | signalIDs: make([]uintptr, 0, 4), | |
| 353 | - | runJSErrorStats: make(map[string]runJSErrorStat), | |
| 375 | + | inner: inner, | |
| 376 | + | ucm: inner.GetUserContentManager(), | |
| 377 | + | logger: log.With().Str("component", "webview").Logger(), | |
| 378 | + | signalIDs: make([]uintptr, 0, 4), | |
| 379 | + | runJSErrorStats: make(map[string]runJSErrorStat), | |
| 380 | + | editableFocusBridgeToken: newEditableFocusBridgeToken(), | |
| 354 | 381 | } | |
| 355 | 382 | ||
| 356 | 383 | // Register in global registry | |
| @@ -396,12 +423,13 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti | |||
| 396 | 423 | Msg("related webview created, checking pointers") | |
| 397 | 424 | ||
| 398 | 425 | wv := &WebView{ | |
| 399 | - | inner: inner, | |
| 400 | - | isRelated: true, // Shares web process with parent - must not terminate process on destroy | |
| 401 | - | ucm: inner.GetUserContentManager(), | |
| 402 | - | logger: log.With().Str("component", "webview-popup").Logger(), | |
| 403 | - | signalIDs: make([]uintptr, 0, 6), | |
| 404 | - | runJSErrorStats: make(map[string]runJSErrorStat), | |
| 426 | + | inner: inner, | |
| 427 | + | isRelated: true, // Shares web process with parent - must not terminate process on destroy | |
| 428 | + | ucm: inner.GetUserContentManager(), | |
| 429 | + | logger: log.With().Str("component", "webview-popup").Logger(), | |
| 430 | + | signalIDs: make([]uintptr, 0, 6), | |
| 431 | + | runJSErrorStats: make(map[string]runJSErrorStat), | |
| 432 | + | editableFocusBridgeToken: newEditableFocusBridgeToken(), | |
| 405 | 433 | } | |
| 406 | 434 | ||
| 407 | 435 | wv.id = globalRegistry.register(wv) | |
| @@ -448,37 +476,49 @@ func (wv *WebView) connectLoadChangedSignal() { | |||
| 448 | 476 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | |
| 449 | 477 | uri := inner.GetUri() | |
| 450 | 478 | title := inner.GetTitle() | |
| 451 | - | ||
| 452 | - | wv.mu.Lock() | |
| 453 | - | wv.uri = uri | |
| 454 | - | wv.title = title | |
| 455 | - | // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal | |
| 456 | - | // which fires for both traditional navigation and SPA history.pushState() | |
| 457 | - | wv.progress = inner.GetEstimatedLoadProgress() | |
| 458 | - | ||
| 459 | - | switch event { | |
| 460 | - | case webkit.LoadStartedValue: | |
| 461 | - | wv.navigationActive.Store(true) | |
| 462 | - | wv.isLoading = true | |
| 463 | - | wv.logger.Debug().Str("uri", uri).Msg("load started") | |
| 464 | - | case webkit.LoadRedirectedValue: | |
| 465 | - | wv.logger.Debug().Str("uri", uri).Msg("load redirected") | |
| 466 | - | case webkit.LoadCommittedValue: | |
| 467 | - | wv.logger.Debug().Str("uri", uri).Msg("load committed") | |
| 468 | - | case webkit.LoadFinishedValue: | |
| 469 | - | wv.isLoading = false | |
| 470 | - | wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished") | |
| 471 | - | } | |
| 472 | - | wv.mu.Unlock() | |
| 473 | - | ||
| 474 | - | if wv.OnLoadChanged != nil { | |
| 475 | - | wv.OnLoadChanged(LoadEvent(event)) | |
| 476 | - | } | |
| 479 | + | progress := inner.GetEstimatedLoadProgress() | |
| 480 | + | wv.handleLoadChanged(LoadEvent(event), uri, title, progress) | |
| 477 | 481 | } | |
| 478 | 482 | sigID := wv.inner.ConnectLoadChanged(&loadChangedCb) | |
| 479 | 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 | 522 | func (wv *WebView) connectLoadFailedSignal() { | |
| 483 | 523 | loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool { | |
| 484 | 524 | wv.logger.Warn(). | |
| @@ -1762,6 +1802,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||
| 1762 | 1802 | wv.OnEnterFullscreen = nil | |
| 1763 | 1803 | wv.OnLeaveFullscreen = nil | |
| 1764 | 1804 | wv.OnAudioStateChanged = nil | |
| 1805 | + | wv.OnEditableFocusChanged = nil | |
| 1765 | 1806 | return | |
| 1766 | 1807 | } | |
| 1767 | 1808 | ||
| @@ -1807,6 +1848,16 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||
| 1807 | 1848 | wv.OnEnterFullscreen = callbacks.OnEnterFullscreen | |
| 1808 | 1849 | wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen | |
| 1809 | 1850 | 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 | 1863 | // ShowDevTools opens the WebKit inspector/developer tools. | |
| @@ -1999,6 +2050,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) { | |||
| 1999 | 2050 | wv.OnEnterFullscreen = nil | |
| 2000 | 2051 | wv.OnLeaveFullscreen = nil | |
| 2001 | 2052 | wv.OnAudioStateChanged = nil | |
| 2053 | + | wv.OnEditableFocusChanged = nil | |
| 2002 | 2054 | wv.OnLinkHover = nil | |
| 2003 | 2055 | wv.OnWebProcessTerminated = nil | |
| 2004 | 2056 | wv.OnPermissionRequest = nil | |
| @@ -2047,6 +2099,7 @@ func (wv *WebView) ResetForPoolReuse() { | |||
| 2047 | 2099 | return | |
| 2048 | 2100 | } | |
| 2049 | 2101 | wv.generation.Add(1) | |
| 2102 | + | wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken()) | |
| 2050 | 2103 | ||
| 2051 | 2104 | // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView. | |
| 2052 | 2105 | // They will be reconnected when the WebView is re-acquired from the pool. | |
| @@ -2064,6 +2117,7 @@ func (wv *WebView) ResetForPoolReuse() { | |||
| 2064 | 2117 | wv.OnEnterFullscreen = nil | |
| 2065 | 2118 | wv.OnLeaveFullscreen = nil | |
| 2066 | 2119 | wv.OnAudioStateChanged = nil | |
| 2120 | + | wv.OnEditableFocusChanged = nil | |
| 2067 | 2121 | wv.OnLinkHover = nil | |
| 2068 | 2122 | wv.OnWebProcessTerminated = nil | |
| 2069 | 2123 | wv.OnPermissionRequest = nil | |
| @@ -2376,3 +2430,61 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector | |||
| 2376 | 2430 | log.Debug().Msg("frontend assets attached to webview") | |
| 2377 | 2431 | 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 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 5 | 7 | "testing" | |
| 6 | 8 | "time" | |
| 7 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 9 | 13 | ) | |
| 10 | 14 | ||
| 11 | 15 | func TestClassifyRunJSEvaluateError(t *testing.T) { | |
| @@ -87,3 +91,104 @@ func TestShouldLogRunJSError_FatalAlwaysLogs(t *testing.T) { | |||
| 87 | 91 | assert.True(t, shouldLog) | |
| 88 | 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 | 52 | floatingPaneIDPrefix = "floating-pane:" | |
| 53 | 53 | floatingSessionIDDefault = "default" | |
| 54 | 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 | 63 | func gtkApplicationFlags() gio.ApplicationFlags { | |
| @@ -117,6 +123,10 @@ type App struct { | |||
| 117 | 123 | // Focus tracking stays app-global. | |
| 118 | 124 | focusMgr *focus.Manager | |
| 119 | 125 | ||
| 126 | + | vimModePolicyUC *usecase.VimModePolicyUseCase | |
| 127 | + | vimNavigationUC *usecase.VimNavigationUseCase | |
| 128 | + | pageEditableFocusByPane map[entity.PaneID]bool | |
| 129 | + | ||
| 120 | 130 | resizeModeBorderTarget layout.Widget | |
| 121 | 131 | ||
| 122 | 132 | // Omnibox configuration (omnibox is created per workspace view) | |
| @@ -150,6 +160,12 @@ type App struct { | |||
| 150 | 160 | // Accent picker for dead keys support | |
| 151 | 161 | 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 | 169 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | |
| 154 | 170 | deferredInitOnce sync.Once | |
| 155 | 171 | deferredInitFn func() | |
| @@ -192,15 +208,18 @@ func New(deps *Dependencies) (*App, error) { | |||
| 192 | 208 | ctx, cancel := context.WithCancelCause(deps.Ctx) | |
| 193 | 209 | ||
| 194 | 210 | app := &App{ | |
| 195 | - | deps: deps, | |
| 196 | - | runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig), | |
| 197 | - | tabs: entity.NewTabList(), | |
| 198 | - | tabsUC: deps.TabsUC, | |
| 199 | - | panesUC: deps.PanesUC, | |
| 200 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 201 | - | windowForTab: make(map[entity.TabID]*browserWindow), | |
| 202 | - | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 203 | - | browserWindows: make(map[string]*browserWindow), | |
| 211 | + | deps: deps, | |
| 212 | + | runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig), | |
| 213 | + | tabs: entity.NewTabList(), | |
| 214 | + | tabsUC: deps.TabsUC, | |
| 215 | + | panesUC: deps.PanesUC, | |
| 216 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 217 | + | windowForTab: make(map[entity.TabID]*browserWindow), | |
| 218 | + | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 219 | + | browserWindows: make(map[string]*browserWindow), | |
| 220 | + | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | |
| 221 | + | vimNavigationUC: usecase.NewVimNavigationUseCase(), | |
| 222 | + | pageEditableFocusByPane: make(map[entity.PaneID]bool), | |
| 204 | 223 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | |
| 205 | 224 | if fn != nil { | |
| 206 | 225 | fn() | |
| @@ -210,6 +229,9 @@ func New(deps *Dependencies) (*App, error) { | |||
| 210 | 229 | engine: deps.Engine, | |
| 211 | 230 | cancel: cancel, | |
| 212 | 231 | } | |
| 232 | + | if deps.Theme != nil { | |
| 233 | + | deps.Theme.SetTransitionDuration(app.runtimeConfigSnapshot().UI.Workspace.Styling.TransitionDuration) | |
| 234 | + | } | |
| 213 | 235 | var autoCopyConfig port.AutoCopyConfig | |
| 214 | 236 | var clipboardOrchestrator port.ClipboardTextOrchestrator | |
| 215 | 237 | if deps.Clipboard != nil { | |
| @@ -814,6 +836,9 @@ func (a *App) wireSessionManagerShortcut() { | |||
| 814 | 836 | return nil | |
| 815 | 837 | } | |
| 816 | 838 | bw.sessionManager.Toggle(ctx) | |
| 839 | + | if bw.sessionManager.IsVisible() { | |
| 840 | + | a.handleVimModeFocusTrigger(ctx, bw, usecase.VimModePolicyTriggerOverlayFocus) | |
| 841 | + | } | |
| 817 | 842 | return nil | |
| 818 | 843 | }) | |
| 819 | 844 | } | |
| @@ -1065,13 +1090,24 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1065 | 1090 | } | |
| 1066 | 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 | 1097 | bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool { | |
| 1069 | 1098 | a.activateBrowserWindow(bw) | |
| 1070 | 1099 | return a.handleGlobalEscape(escapeCtx) | |
| 1071 | 1100 | }) | |
| 1072 | 1101 | bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | |
| 1073 | 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 | 1112 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 1077 | 1113 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | |
| @@ -1099,6 +1135,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1099 | 1135 | ||
| 1100 | 1136 | return input.RouteHandleShortcuts | |
| 1101 | 1137 | }) | |
| 1138 | + | bw.keyboardHandler.SetVimModeActivationPassthrough(func() bool { | |
| 1139 | + | return a.shouldBypassVimModeActivation(bw) | |
| 1140 | + | }) | |
| 1102 | 1141 | bw.keyboardHandler.SetAccentHandler(a) | |
| 1103 | 1142 | bw.keyboardHandler.AttachTo(bw.mainWindow.Window()) | |
| 1104 | 1143 | ||
| @@ -1339,6 +1378,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 1339 | 1378 | a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry)) | |
| 1340 | 1379 | } | |
| 1341 | 1380 | } | |
| 1381 | + | a.handleVimModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.VimModePolicyTriggerOmniboxFocus) | |
| 1342 | 1382 | }, | |
| 1343 | 1383 | OnFocusOut: func() { | |
| 1344 | 1384 | // When omnibox loses focus, set WebView as the focused input | |
| @@ -1377,6 +1417,7 @@ func (a *App) initFindBarConfig(ctx context.Context) { | |||
| 1377 | 1417 | a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry)) | |
| 1378 | 1418 | } | |
| 1379 | 1419 | } | |
| 1420 | + | a.handleVimModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.VimModePolicyTriggerFindBarFocus) | |
| 1380 | 1421 | }, | |
| 1381 | 1422 | OnFocusOut: func() { | |
| 1382 | 1423 | // When find bar loses focus, set WebView as the focused input | |
| @@ -1400,6 +1441,9 @@ func (a *App) ToggleSessionManager(ctx context.Context) { | |||
| 1400 | 1441 | return | |
| 1401 | 1442 | } | |
| 1402 | 1443 | bw.sessionManager.Toggle(ctx) | |
| 1444 | + | if bw.sessionManager.IsVisible() { | |
| 1445 | + | a.handleVimModeFocusTrigger(ctx, bw, usecase.VimModePolicyTriggerOverlayFocus) | |
| 1446 | + | } | |
| 1403 | 1447 | } | |
| 1404 | 1448 | ||
| 1405 | 1449 | func (a *App) attachTabPickerToActivePane() { | |
| @@ -1498,6 +1542,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |||
| 1498 | 1542 | ||
| 1499 | 1543 | a.attachTabPickerToActivePane() | |
| 1500 | 1544 | bw.tabPicker.Show(ctx, items) | |
| 1545 | + | a.handleVimModeFocusTrigger(ctx, bw, usecase.VimModePolicyTriggerOverlayFocus) | |
| 1501 | 1546 | return nil | |
| 1502 | 1547 | } | |
| 1503 | 1548 | ||
| @@ -2123,6 +2168,111 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p | |||
| 2123 | 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 | 2276 | // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes | |
| 2127 | 2277 | // navigation through navigate for the supplied browser window. The closure captures | |
| 2128 | 2278 | // 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 | 3222 | // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed. | |
| 3073 | 3223 | if bw := a.browserWindowForTabTarget(target); bw != nil { | |
| 3074 | 3224 | a.activateBrowserWindow(bw) | |
| 3225 | + | a.handleVimModeTabSwitch(ctx, bw) | |
| 3075 | 3226 | } | |
| 3076 | 3227 | a.switchWorkspaceView(ctx, tab.ID) | |
| 3077 | 3228 | }) | |
| @@ -3209,8 +3360,13 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3209 | 3360 | a.deps.HistoryRecorderUC, | |
| 3210 | 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 | 3367 | a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) { | |
| 3213 | 3368 | a.navCoord.ClearPaneHistory(paneID) | |
| 3369 | + | a.clearPageEditableFocusState(paneID) | |
| 3214 | 3370 | }) | |
| 3215 | 3371 | ||
| 3216 | 3372 | // Wire title updates to history persistence | |
| @@ -3220,6 +3376,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3220 | 3376 | ||
| 3221 | 3377 | // Wire history recording on LoadCommitted (URI is guaranteed correct at this point) | |
| 3222 | 3378 | a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) { | |
| 3379 | + | a.clearPageEditableFocusState(paneID) | |
| 3223 | 3380 | a.navCoord.RecordHistory(ctx, paneID, url) | |
| 3224 | 3381 | }) | |
| 3225 | 3382 | ||
| @@ -3235,6 +3392,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3235 | 3392 | // Mark dirty so snapshot captures the new URI | |
| 3236 | 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 | 3399 | // Hide loading skeleton once the WebView paints | |
| 3240 | 3400 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | |
| @@ -3253,6 +3413,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3253 | 3413 | a.deps.OnFirstWebViewShown(ctx) | |
| 3254 | 3414 | }) | |
| 3255 | 3415 | } | |
| 3416 | + | a.preloadAccessibilityForShownPane(paneID) | |
| 3256 | 3417 | ||
| 3257 | 3418 | // This only updates focus when the shown pane belongs to the last-focused | |
| 3258 | 3419 | // 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 | 3509 | func (a *App) wireKeyboardActions() { | |
| 3349 | 3510 | a.kbDispatcher.SetOnQuit(a.Quit) | |
| 3511 | + | a.kbDispatcher.SetOnVimModePulse(func(ctx context.Context, fast bool) { | |
| 3512 | + | a.triggerVimModePulse(ctx, fast) | |
| 3513 | + | }) | |
| 3350 | 3514 | a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error { | |
| 3351 | 3515 | a.ToggleFindBar(ctx) | |
| 3352 | 3516 | return nil | |
| @@ -3678,8 +3842,120 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |||
| 3678 | 3842 | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | |
| 3679 | 3843 | } | |
| 3680 | 3844 | ||
| 3681 | - | // handleModeChange is called when the input mode changes. | |
| 3682 | - | func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |
| 3845 | + | func (a *App) vimModePolicy() *usecase.VimModePolicyUseCase { | |
| 3846 | + | if a == nil { | |
| 3847 | + | return usecase.NewVimModePolicyUseCase() | |
| 3848 | + | } | |
| 3849 | + | if a.vimModePolicyUC == nil { | |
| 3850 | + | a.vimModePolicyUC = usecase.NewVimModePolicyUseCase() | |
| 3851 | + | } | |
| 3852 | + | return a.vimModePolicyUC | |
| 3853 | + | } | |
| 3854 | + | ||
| 3855 | + | func (a *App) ensurePageEditableFocusMap() { | |
| 3856 | + | if a != nil && a.pageEditableFocusByPane == nil { | |
| 3857 | + | a.pageEditableFocusByPane = make(map[entity.PaneID]bool) | |
| 3858 | + | } | |
| 3859 | + | } | |
| 3860 | + | ||
| 3861 | + | func (a *App) pageEditableFocused(paneID entity.PaneID) bool { | |
| 3862 | + | if a == nil || paneID == "" || a.pageEditableFocusByPane == nil { | |
| 3863 | + | return false | |
| 3864 | + | } | |
| 3865 | + | return a.pageEditableFocusByPane[paneID] | |
| 3866 | + | } | |
| 3867 | + | ||
| 3868 | + | func (a *App) setPageEditableFocused(paneID entity.PaneID, editable bool) { | |
| 3869 | + | if a == nil || paneID == "" { | |
| 3870 | + | return | |
| 3871 | + | } | |
| 3872 | + | a.ensurePageEditableFocusMap() | |
| 3873 | + | if editable { | |
| 3874 | + | a.pageEditableFocusByPane[paneID] = true | |
| 3875 | + | return | |
| 3876 | + | } | |
| 3877 | + | delete(a.pageEditableFocusByPane, paneID) | |
| 3878 | + | } | |
| 3879 | + | ||
| 3880 | + | func (a *App) clearPageEditableFocusState(paneID entity.PaneID) { | |
| 3881 | + | a.setPageEditableFocused(paneID, false) | |
| 3882 | + | } | |
| 3883 | + | ||
| 3884 | + | func (a *App) vimModeActiveForBrowserWindow(bw *browserWindow) bool { | |
| 3885 | + | return bw != nil && bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim | |
| 3886 | + | } | |
| 3887 | + | ||
| 3888 | + | func (a *App) applyVimModePolicyTransition(_ context.Context, bw *browserWindow, transition usecase.VimModePolicyTransition) { | |
| 3889 | + | if transition != usecase.VimModePolicyTransitionExit || bw == nil || bw.keyboardHandler == nil { | |
| 3890 | + | return | |
| 3891 | + | } | |
| 3892 | + | if bw.keyboardHandler.Mode() != input.ModeVim { | |
| 3893 | + | return | |
| 3894 | + | } | |
| 3895 | + | bw.keyboardHandler.ExitMode() | |
| 3896 | + | } | |
| 3897 | + | ||
| 3898 | + | func (a *App) handleVimModeFocusTrigger(ctx context.Context, bw *browserWindow, trigger usecase.VimModePolicyTrigger) { | |
| 3899 | + | if bw == nil { | |
| 3900 | + | return | |
| 3901 | + | } | |
| 3902 | + | transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{ | |
| 3903 | + | Trigger: trigger, | |
| 3904 | + | VimModeActive: a.vimModeActiveForBrowserWindow(bw), | |
| 3905 | + | }) | |
| 3906 | + | a.applyVimModePolicyTransition(ctx, bw, transition) | |
| 3907 | + | } | |
| 3908 | + | ||
| 3909 | + | func (a *App) handleVimModeTabSwitch(_ context.Context, bw *browserWindow) { | |
| 3910 | + | if bw == nil || bw.tabs == nil || !a.vimModeActiveForBrowserWindow(bw) { | |
| 3911 | + | return | |
| 3912 | + | } | |
| 3913 | + | transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{ | |
| 3914 | + | Trigger: usecase.VimModePolicyTriggerContextChanged, | |
| 3915 | + | VimModeActive: true, | |
| 3916 | + | PreserveOnContextChange: false, | |
| 3917 | + | }) | |
| 3918 | + | if transition != usecase.VimModePolicyTransitionExit { | |
| 3919 | + | return | |
| 3920 | + | } | |
| 3921 | + | if prevTabID := bw.tabs.PreviousActiveTabID; prevTabID != "" { | |
| 3922 | + | if prevView := a.workspaceViews[prevTabID]; prevView != nil { | |
| 3923 | + | if pv := prevView.GetPaneView(bw.vimModePaneID); pv != nil { | |
| 3924 | + | pv.SetVimMode(false) | |
| 3925 | + | } | |
| 3926 | + | } | |
| 3927 | + | } | |
| 3928 | + | bw.vimModePaneID = "" | |
| 3929 | + | bw.keyboardHandler.ExitMode() | |
| 3930 | + | } | |
| 3931 | + | ||
| 3932 | + | func (a *App) handlePageEditableFocusChanged(ctx context.Context, paneID entity.PaneID, editable bool) { | |
| 3933 | + | if paneID == "" { | |
| 3934 | + | return | |
| 3935 | + | } | |
| 3936 | + | a.setPageEditableFocused(paneID, editable) | |
| 3937 | + | ||
| 3938 | + | bw := a.browserWindowForAnyPane(paneID) | |
| 3939 | + | if bw == nil { | |
| 3940 | + | return | |
| 3941 | + | } | |
| 3942 | + | ws := a.activeWorkspaceForBrowserWindow(bw) | |
| 3943 | + | activeContext := ws != nil && ws.ActivePaneID == paneID && a.lastFocusedBrowserWindow() == bw | |
| 3944 | + | transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{ | |
| 3945 | + | Trigger: usecase.VimModePolicyTriggerPageEditableFocusChanged, | |
| 3946 | + | VimModeActive: a.vimModeActiveForBrowserWindow(bw), | |
| 3947 | + | PageEditableFocused: editable, | |
| 3948 | + | EventInActiveContext: activeContext, | |
| 3949 | + | }) | |
| 3950 | + | a.applyVimModePolicyTransition(ctx, bw, transition) | |
| 3951 | + | } | |
| 3952 | + | ||
| 3953 | + | func (*App) shouldBypassVimModeActivation(_ *browserWindow) bool { | |
| 3954 | + | return false | |
| 3955 | + | } | |
| 3956 | + | ||
| 3957 | + | // handleModeChange is called when the input mode changes for a specific browser window. | |
| 3958 | + | func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to input.Mode) { | |
| 3683 | 3959 | log := logging.FromContext(ctx) | |
| 3684 | 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 | 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 | 3981 | // Update global border overlay visibility based on mode. | |
| 3695 | 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 | 3985 | bw.borderMgr.OnModeChange(ctx, from, to) | |
| 3698 | 3986 | } | |
| 3699 | 3987 | ||
| 3700 | - | // Show/hide mode indicator toaster based on config. | |
| 3701 | - | a.updateModeIndicatorToaster(ctx, to) | |
| 3988 | + | // Show/hide this window's mode indicator toaster based on mode and config. | |
| 3989 | + | a.updateModeIndicatorToaster(ctx, bw, to) | |
| 3702 | 3990 | } | |
| 3703 | 3991 | ||
| 3704 | - | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | |
| 3705 | - | func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | |
| 3992 | + | // transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent | |
| 3993 | + | // from the current owning pane to another pane in the same browser window. | |
| 3994 | + | // The transfer only happens while that window is in Vim Mode. | |
| 3995 | + | func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWindow, newPaneID entity.PaneID) { | |
| 3996 | + | if bw == nil { | |
| 3997 | + | return | |
| 3998 | + | } | |
| 3999 | + | ||
| 4000 | + | // Only transfer if vim mode is currently active and the owner is changing. | |
| 4001 | + | if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID { | |
| 4002 | + | return | |
| 4003 | + | } | |
| 4004 | + | ||
| 4005 | + | // Check if this window is actually in vim mode. | |
| 4006 | + | if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim { | |
| 4007 | + | // Vim mode not active on this window; just clear stale ownership. | |
| 4008 | + | bw.vimModePaneID = "" | |
| 4009 | + | return | |
| 4010 | + | } | |
| 4011 | + | ||
| 4012 | + | wsView := a.activeWorkspaceViewForBrowserWindow(bw) | |
| 4013 | + | if wsView == nil { | |
| 4014 | + | return | |
| 4015 | + | } | |
| 4016 | + | ||
| 4017 | + | transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{ | |
| 4018 | + | Trigger: usecase.VimModePolicyTriggerContextChanged, | |
| 4019 | + | VimModeActive: true, | |
| 4020 | + | PreserveOnContextChange: !a.pageEditableFocused(newPaneID), | |
| 4021 | + | }) | |
| 4022 | + | if transition == usecase.VimModePolicyTransitionExit { | |
| 4023 | + | a.applyVimModePolicyTransition(ctx, bw, transition) | |
| 4024 | + | return | |
| 4025 | + | } | |
| 4026 | + | ||
| 4027 | + | oldPaneID := bw.vimModePaneID | |
| 4028 | + | ||
| 4029 | + | // Deactivate the old owning pane. | |
| 4030 | + | if oldPV := wsView.GetPaneView(oldPaneID); oldPV != nil { | |
| 4031 | + | oldPV.SetVimMode(false) | |
| 4032 | + | } | |
| 4033 | + | ||
| 4034 | + | // Activate the new pane. | |
| 4035 | + | if newPV := wsView.GetPaneView(newPaneID); newPV != nil { | |
| 4036 | + | newPV.SetVimMode(true) | |
| 4037 | + | } | |
| 4038 | + | bw.vimModePaneID = newPaneID | |
| 4039 | + | ||
| 4040 | + | logging.FromContext(ctx).Debug(). | |
| 4041 | + | Str("window_id", bw.id). | |
| 4042 | + | Str("old_pane_id", string(oldPaneID)). | |
| 4043 | + | Str("new_pane_id", string(newPaneID)). | |
| 4044 | + | Msg("vim mode ownership transferred") | |
| 4045 | + | } | |
| 4046 | + | ||
| 4047 | + | // handleVimModeOwnership manages the pane-local Vim Mode accent and pulse | |
| 4048 | + | // owner for a specific browser window when input modes change. | |
| 4049 | + | func (a *App) handleVimModeOwnership(ctx context.Context, bw *browserWindow, to, from input.Mode) { | |
| 4050 | + | if bw == nil { | |
| 4051 | + | return | |
| 4052 | + | } | |
| 4053 | + | ||
| 4054 | + | ws := a.activeWorkspaceForBrowserWindow(bw) | |
| 4055 | + | wsView := a.activeWorkspaceViewForBrowserWindow(bw) | |
| 4056 | + | if ws == nil || wsView == nil { | |
| 4057 | + | // If there's no workspace yet (startup), just track the intent. | |
| 4058 | + | if to != input.ModeVim && from == input.ModeVim { | |
| 4059 | + | bw.vimModePaneID = "" | |
| 4060 | + | } | |
| 4061 | + | return | |
| 4062 | + | } | |
| 4063 | + | ||
| 4064 | + | if to == input.ModeVim && from != input.ModeVim { | |
| 4065 | + | // Entering vim mode: activate on the current active pane of this window. | |
| 4066 | + | paneID := ws.ActivePaneID | |
| 4067 | + | if pv := wsView.GetPaneView(paneID); pv != nil { | |
| 4068 | + | pv.SetVimMode(true) | |
| 4069 | + | bw.vimModePaneID = paneID | |
| 4070 | + | logging.FromContext(ctx).Debug(). | |
| 4071 | + | Str("window_id", bw.id). | |
| 4072 | + | Str("pane_id", string(paneID)). | |
| 4073 | + | Msg("vim mode activated on pane") | |
| 4074 | + | } | |
| 4075 | + | } else if from == input.ModeVim && to != input.ModeVim { | |
| 4076 | + | // Leaving Vim Mode: deactivate the pane that owns the visual accent. | |
| 4077 | + | a.clearVimModeOwnership(ctx, bw) | |
| 4078 | + | } | |
| 4079 | + | } | |
| 4080 | + | ||
| 4081 | + | // triggerVimModePulse triggers a pane-local vim mode pulse on the | |
| 4082 | + | // last-focused browser window's owning pane. This is called by the | |
| 4083 | + | // keyboard dispatcher after a page scroll action. The bw used is the | |
| 4084 | + | // window that was active when the action was dispatched (via | |
| 4085 | + | // dispatchBrowserWindowAction which calls activateBrowserWindow first). | |
| 4086 | + | // If no pane is currently in vim mode, the pulse is a no-op. | |
| 4087 | + | // | |
| 4088 | + | // Pulses are debounced at vimModePulseInterval to prevent excessive GTK CSS | |
| 4089 | + | // class churn during held-key repeats. Smooth scroll cadence is owned by the | |
| 4090 | + | // Vim Mode repeater and backend scroll path; pulse feedback is deliberately | |
| 4091 | + | // slower so indicator/overlay animation work does not become part of the | |
| 4092 | + | // scrolling critical path. | |
| 4093 | + | func (a *App) triggerVimModePulse(_ context.Context, fast bool) { | |
| 4094 | + | // Debounce: skip if called again too soon during a continuous hold. | |
| 4095 | + | // We still serialize the timestamp update so repeated dispatcher calls do | |
| 4096 | + | // not restart CSS animations at scroll cadence. | |
| 4097 | + | a.vimModePulseMu.Lock() | |
| 4098 | + | since := time.Since(a.vimModePulseLastTime) | |
| 4099 | + | if since < vimModePulseInterval { | |
| 4100 | + | a.vimModePulseMu.Unlock() | |
| 4101 | + | return | |
| 4102 | + | } | |
| 4103 | + | a.vimModePulseLastTime = time.Now() | |
| 4104 | + | a.vimModePulseMu.Unlock() | |
| 4105 | + | ||
| 3706 | 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 | 4153 | if bw == nil || bw.modeToaster == nil { | |
| 3708 | 4154 | return | |
| 3709 | 4155 | } | |
| 3710 | 4156 | ||
| 3711 | - | // Check if mode indicator toaster is enabled in config. | |
| 3712 | - | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled { | |
| 4157 | + | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal { | |
| 3713 | 4158 | bw.modeToaster.Hide() | |
| 3714 | 4159 | 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 | 4195 | bw.modeToaster.Hide() | |
| 4196 | + | log.Debug().Str("window_id", bw.id).Msg("vim mode pending toaster hidden (disabled)") | |
| 3719 | 4197 | return | |
| 3720 | 4198 | } | |
| 3721 | 4199 | ||
| 3722 | - | // Show persistent toaster at bottom-left with mode display name. | |
| 3723 | - | // Mode class is applied atomically with Show() to avoid visual flicker. | |
| 3724 | - | modeClass := getModeToastClass(mode) | |
| 3725 | - | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 3726 | - | component.WithDuration(0), // Persistent until mode exits. | |
| 4200 | + | text := input.ModeVim.DisplayName() | |
| 4201 | + | if pending != "" { | |
| 4202 | + | text = text + " · " + pending | |
| 4203 | + | } | |
| 4204 | + | bw.modeToaster.Show(ctx, text, component.ToastInfo, | |
| 4205 | + | component.WithDuration(0), | |
| 3727 | 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 | 4216 | // getModeToastClass returns the CSS class for the given mode's toast styling. | |
| @@ -3734,6 +4218,8 @@ func getModeToastClass(mode input.Mode) string { | |||
| 3734 | 4218 | switch mode { | |
| 3735 | 4219 | case input.ModePane: | |
| 3736 | 4220 | return "toast-pane-mode" | |
| 4221 | + | case input.ModeVim: | |
| 4222 | + | return "toast-vim-mode" | |
| 3737 | 4223 | case input.ModeTab: | |
| 3738 | 4224 | return "toast-tab-mode" | |
| 3739 | 4225 | case input.ModeSession: | |
| @@ -3843,6 +4329,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 3843 | 4329 | }) | |
| 3844 | 4330 | wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) { | |
| 3845 | 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 | 5454 | if bw.globalShortcutHandler != nil { | |
| 4966 | 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 | 5527 | if resolveThemeUC == nil { | |
| 5034 | 5528 | resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource) | |
| 5035 | 5529 | } | |
| 5530 | + | a.deps.Theme.SetTransitionDuration(workspaceStyling.TransitionDuration) | |
| 5036 | 5531 | resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig( | |
| 5037 | 5532 | &appearanceCfg, | |
| 5038 | 5533 | runtimeCfg.DefaultUIScale, | |
M internal/ui/app_browser_launch_test.go +57 -14
| @@ -35,7 +35,9 @@ import ( | |||
| 35 | 35 | "github.com/bnema/dumber/internal/ui/window" | |
| 36 | 36 | "github.com/bnema/puregotk/v4/gdk" | |
| 37 | 37 | "github.com/bnema/puregotk/v4/gio" | |
| 38 | + | "github.com/bnema/puregotk/v4/glib" | |
| 38 | 39 | "github.com/bnema/puregotk/v4/gtk" | |
| 40 | + | "github.com/stretchr/testify/assert" | |
| 39 | 41 | "github.com/stretchr/testify/mock" | |
| 40 | 42 | "github.com/stretchr/testify/require" | |
| 41 | 43 | ) | |
| @@ -45,6 +47,10 @@ type testBrowserLaunchRelay struct { | |||
| 45 | 47 | closer *testCloser | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 50 | + | func (r *testBrowserLaunchRelay) DeliverOpenExternalURL(context.Context, string) (bool, error) { | |
| 51 | + | return false, nil | |
| 52 | + | } | |
| 53 | + | ||
| 48 | 54 | func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) { | |
| 49 | 55 | return false, nil | |
| 50 | 56 | } | |
| @@ -957,6 +963,10 @@ func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool { | |||
| 957 | 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 | 970 | func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) { | |
| 961 | 971 | t.Helper() | |
| 962 | 972 | ||
| @@ -979,7 +989,9 @@ func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBox | |||
| 979 | 989 | label.EXPECT().SetCanTarget(false).Once() | |
| 980 | 990 | label.EXPECT().SetCanFocus(false).Once() | |
| 981 | 991 | ||
| 982 | - | return component.NewToaster(factory), box, label | |
| 992 | + | toaster := component.NewToaster(factory) | |
| 993 | + | t.Cleanup(toaster.CancelAutoDismiss) | |
| 994 | + | return toaster, box, label | |
| 983 | 995 | } | |
| 984 | 996 | ||
| 985 | 997 | func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |
| @@ -987,6 +999,9 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |||
| 987 | 999 | toaster, box, label := newTestShellToaster(t) | |
| 988 | 1000 | box.EXPECT().SetVisible(true).Once() | |
| 989 | 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 | 1006 | bw := &browserWindow{id: "window-1", appToaster: toaster} | |
| 992 | 1007 | app := &App{ | |
| @@ -995,6 +1010,30 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |||
| 995 | 1010 | } | |
| 996 | 1011 | ||
| 997 | 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 | 1039 | func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |
| @@ -1002,6 +1041,9 @@ func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing. | |||
| 1002 | 1041 | toaster, box, label := newTestShellToaster(t) | |
| 1003 | 1042 | box.EXPECT().SetVisible(true).Once() | |
| 1004 | 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 | 1048 | migrator := portmocks.NewMockConfigMigrator(t) | |
| 1007 | 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 | 1064 | app.checkConfigMigration(ctx) | |
| 1065 | + | toaster.Hide() | |
| 1023 | 1066 | } | |
| 1024 | 1067 | ||
| 1025 | 1068 | func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) { | |
| @@ -1061,7 +1104,7 @@ func TestApp_GetWindowSnapshotStateReturnsUnavailableWhenMainThreadDispatchTimes | |||
| 1061 | 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 | 1108 | var factoryCalls int | |
| 1066 | 1109 | app := &App{ | |
| 1067 | 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 | 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 | 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 | 1127 | if factoryCalls != 0 { | |
| 1085 | 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 | 1133 | existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))) | |
| 1091 | 1134 | existingTabs := entity.NewTabList() | |
| 1092 | 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 { | |
| 1108 | - | t.Fatalf("OpenFreshWindow returned error: %v", err) | |
| 1150 | + | if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil { | |
| 1151 | + | t.Fatalf("OpenExternalURL returned error: %v", err) | |
| 1109 | 1152 | } | |
| 1110 | 1153 | if got := windowForTabCount(t, app); got != 2 { | |
| 1111 | 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 | 1400 | created := &browserWindow{id: "window-1", tabs: entity.NewTabList()} | |
| 1358 | 1401 | originalWindow := &window.MainWindow{} | |
| 1359 | 1402 | tabBar := &component.TabBar{} | |
| @@ -1384,8 +1427,8 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) { | |||
| 1384 | 1427 | MainWindow: &window.MainWindow{}, | |
| 1385 | 1428 | }) | |
| 1386 | 1429 | ||
| 1387 | - | if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil { | |
| 1388 | - | t.Fatalf("OpenFreshWindow = nil error, want failure") | |
| 1430 | + | if err := app.OpenExternalURL(context.Background(), "https://example.com/fail"); err == nil { | |
| 1431 | + | t.Fatalf("OpenExternalURL = nil error, want failure") | |
| 1389 | 1432 | } | |
| 1390 | 1433 | if got := len(app.browserWindows); got != 1 { | |
| 1391 | 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 | 1451 | existingTabID := entity.TabID("existing-tab") | |
| 1409 | 1452 | createdTabID := entity.TabID("id-1") | |
| 1410 | 1453 | oldWindow := &window.MainWindow{} | |
| @@ -1441,8 +1484,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) { | |||
| 1441 | 1484 | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | |
| 1442 | 1485 | }) | |
| 1443 | 1486 | ||
| 1444 | - | if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil { | |
| 1445 | - | t.Fatalf("OpenFreshWindow returned error: %v", err) | |
| 1487 | + | if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil { | |
| 1488 | + | t.Fatalf("OpenExternalURL returned error: %v", err) | |
| 1446 | 1489 | } | |
| 1447 | 1490 | ||
| 1448 | 1491 | 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 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 12 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 14 | "github.com/bnema/dumber/internal/shared/syncdispatch" | |
| 14 | 15 | "github.com/bnema/dumber/internal/ui/component" | |
| @@ -52,6 +53,13 @@ type browserWindow struct { | |||
| 52 | 53 | historySidebarReloader historySidebarReloader | |
| 53 | 54 | sidebarVisible bool | |
| 54 | 55 | 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 | 65 | func (bw *browserWindow) detachInputForDestroy() { | |
| @@ -108,6 +116,9 @@ func (bw *browserWindow) clearShellState() { | |||
| 108 | 116 | bw.favoritesSidebar = nil | |
| 109 | 117 | bw.historySidebarReloader = nil | |
| 110 | 118 | bw.activeSidebarKind = nativeSidebarNone | |
| 119 | + | bw.vimModePaneID = "" | |
| 120 | + | bw.vimNavigationHighlightedWebViews = nil | |
| 121 | + | bw.vimNavigationHighlightedWebViewIDs = nil | |
| 111 | 122 | } | |
| 112 | 123 | ||
| 113 | 124 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |
| @@ -544,11 +555,9 @@ func (a *App) deterministicBrowserWindowFallback() *browserWindow { | |||
| 544 | 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 | 559 | log := logging.FromContext(ctx) | |
| 549 | - | log.Debug(). | |
| 550 | - | Str("url_host", logging.SafeURLHost(url)). | |
| 551 | - | Msg("ui: open fresh window dispatch requested") | |
| 560 | + | log.Debug().Str("url_host", logging.SafeURLHost(url)).Str("dispatch_label", label).Msg("ui: window URL dispatch requested") | |
| 552 | 561 | ||
| 553 | 562 | dispatch := a.dispatchOnMainThread | |
| 554 | 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 error | |
| 564 | - | var windowCountBefore int | |
| 565 | - | var windowCountAfter int | |
| 566 | - | var hasTabCoord bool | |
| 567 | - | var hasTabsUC bool | |
| 568 | - | result := dispatch("ui.open_fresh_window", func() { | |
| 572 | + | var workErr error | |
| 573 | + | var windowCountBefore, windowCountAfter int | |
| 574 | + | var hasTabCoord, hasTabsUC bool | |
| 575 | + | result := dispatch(label, func() { | |
| 569 | 576 | windowCountBefore = len(a.browserWindows) | |
| 570 | 577 | hasTabCoord = a.tabCoord != nil | |
| 571 | 578 | hasTabsUC = a.tabsUC != nil | |
| 572 | 579 | log.Debug(). | |
| 573 | 580 | Str("url_host", logging.SafeURLHost(url)). | |
| 581 | + | Str("dispatch_label", label). | |
| 574 | 582 | Int("window_count_before", windowCountBefore). | |
| 575 | 583 | Bool("has_tab_coord", hasTabCoord). | |
| 576 | 584 | Bool("has_tabs_uc", hasTabsUC). | |
| 577 | - | Msg("ui: open fresh window main-thread work started") | |
| 578 | - | openErr = a.openFreshWindow(ctx, url) | |
| 585 | + | Msg("ui: window URL main-thread work started") | |
| 586 | + | workErr = work(ctx) | |
| 579 | 587 | windowCountAfter = len(a.browserWindows) | |
| 580 | 588 | }) | |
| 581 | 589 | if !result.Completed() { | |
| 582 | 590 | log.Warn(). | |
| 583 | 591 | Str("url_host", logging.SafeURLHost(url)). | |
| 592 | + | Str("dispatch_label", label). | |
| 584 | 593 | Dur("elapsed", result.Elapsed). | |
| 585 | 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 | 596 | return fmt.Errorf("main thread dispatch did not complete: %s", result.Status) | |
| 588 | 597 | } | |
| 589 | - | if openErr != nil { | |
| 590 | - | log.Warn().Err(openErr). | |
| 598 | + | if workErr != nil { | |
| 599 | + | log.Warn().Err(workErr). | |
| 591 | 600 | Str("url_host", logging.SafeURLHost(url)). | |
| 601 | + | Str("dispatch_label", label). | |
| 592 | 602 | Dur("elapsed", result.Elapsed). | |
| 593 | 603 | Str("dispatch_status", string(result.Status)). | |
| 594 | 604 | Int("window_count_after", windowCountAfter). | |
| 595 | - | Msg("ui: open fresh window failed") | |
| 596 | - | return openErr | |
| 605 | + | Msg("ui: window URL operation failed") | |
| 606 | + | return workErr | |
| 597 | 607 | } | |
| 598 | - | ||
| 599 | 608 | log.Debug(). | |
| 600 | 609 | Str("url_host", logging.SafeURLHost(url)). | |
| 610 | + | Str("dispatch_label", label). | |
| 601 | 611 | Dur("elapsed", result.Elapsed). | |
| 602 | 612 | Str("dispatch_status", string(result.Status)). | |
| 603 | 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 | 801 | 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 | 119 | if a == nil || a.deps == nil || a.deps.FavoritesUC == nil { | |
| 120 | 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 | 124 | if wv == nil { | |
| 124 | 125 | return fmt.Errorf("favorites unavailable: no active webview") | |
| 125 | 126 | } | |
| @@ -127,10 +128,16 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error { | |||
| 127 | 128 | if uri == "" { | |
| 128 | 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 | 132 | if err != nil { | |
| 132 | 133 | 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 | 141 | a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle") | |
| 135 | 142 | 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 | 451 | app := &App{browserWindows: make(map[string]*browserWindow)} | |
| 452 | 452 | ||
| 453 | 453 | dispatched := false | |
| 454 | 454 | createdURL := "" | |
| 455 | 455 | app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult { | |
| 456 | 456 | dispatched = true | |
| 457 | - | if label != "ui.open_fresh_window" { | |
| 458 | - | t.Fatalf("dispatch label = %q, want ui.open_fresh_window", label) | |
| 457 | + | if label != "ui.open_external_url" { | |
| 458 | + | t.Fatalf("dispatch label = %q, want ui.open_external_url", label) | |
| 459 | 459 | } | |
| 460 | 460 | fn() | |
| 461 | 461 | return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted} | |
| @@ -465,11 +465,11 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) { | |||
| 465 | 465 | return &browserWindow{id: "window-1", initialURL: url}, nil | |
| 466 | 466 | } | |
| 467 | 467 | ||
| 468 | - | if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil { | |
| 469 | - | t.Fatalf("OpenFreshWindow returned error: %v", err) | |
| 468 | + | if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil { | |
| 469 | + | t.Fatalf("OpenExternalURL returned error: %v", err) | |
| 470 | 470 | } | |
| 471 | 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 | 474 | if createdURL != "https://example.com" { | |
| 475 | 475 | t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com") | |
| @@ -515,7 +515,7 @@ func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T) | |||
| 515 | 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 | 519 | app := &App{ | |
| 520 | 520 | tabs: entity.NewTabList(), | |
| 521 | 521 | tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil), | |
| @@ -535,8 +535,8 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t * | |||
| 535 | 535 | return true | |
| 536 | 536 | } | |
| 537 | 537 | ||
| 538 | - | if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil { | |
| 539 | - | t.Fatalf("OpenFreshWindow returned error: %v", err) | |
| 538 | + | if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil { | |
| 539 | + | t.Fatalf("OpenExternalURL returned error: %v", err) | |
| 540 | 540 | } | |
| 541 | 541 | ||
| 542 | 542 | windowTabs := app.browserWindows["window-1"].tabs | |
| @@ -553,7 +553,7 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t * | |||
| 553 | 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 | 557 | app := &App{browserWindows: make(map[string]*browserWindow)} | |
| 558 | 558 | app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult { | |
| 559 | 559 | fn() | |
| @@ -564,7 +564,7 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) { | |||
| 564 | 564 | 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 | 569 | if !errors.Is(err, wantErr) { | |
| 570 | 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 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | - | const favoritesSidebarOuterSpacing = 6 | |
| 17 | - | ||
| 18 | 16 | // FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar. | |
| 19 | 17 | type FavoritesSidebarConfig struct { | |
| 20 | 18 | FavoritesUC port.FavoritesSidebarFavorites | |
| @@ -26,22 +24,27 @@ type FavoritesSidebarConfig struct { | |||
| 26 | 24 | ||
| 27 | 25 | // FavoritesSidebar is a read-only GTK sidebar component for browsing favorites. | |
| 28 | 26 | type FavoritesSidebar struct { | |
| 29 | - | outerBox *gtk.Box | |
| 30 | - | searchEntry *gtk.SearchEntry | |
| 31 | - | tagBox *gtk.Box | |
| 32 | - | scrolledWin *gtk.ScrolledWindow | |
| 33 | - | listBox *gtk.ListBox | |
| 34 | - | formBox *gtk.Box | |
| 27 | + | outerBox *gtk.Box | |
| 28 | + | searchEntry *gtk.SearchEntry | |
| 29 | + | tagBox *gtk.Box | |
| 30 | + | tagControls []*gtk.Button | |
| 31 | + | tagPromptBox *gtk.Box | |
| 32 | + | tagNameEntry *gtk.Entry | |
| 33 | + | tagPromptSaveBtn *gtk.Button | |
| 34 | + | scrolledWin *gtk.ScrolledWindow | |
| 35 | + | listBox *gtk.ListBox | |
| 36 | + | formBox *gtk.Box | |
| 35 | 37 | ||
| 36 | 38 | formURLEntry *gtk.SearchEntry | |
| 37 | 39 | formTitleEntry *gtk.SearchEntry | |
| 38 | - | formTagsEntry *gtk.SearchEntry | |
| 40 | + | formTagSearch *gtk.SearchEntry | |
| 41 | + | formTagMatches *gtk.Box | |
| 39 | 42 | formShortcutEntry *gtk.SearchEntry | |
| 40 | 43 | formSaveButton *gtk.Button | |
| 41 | 44 | formURL string | |
| 42 | 45 | formTitle string | |
| 43 | - | formTags string | |
| 44 | 46 | formShortcut string | |
| 47 | + | formTagIDs map[entity.TagID]struct{} | |
| 45 | 48 | ||
| 46 | 49 | favoritesUC port.FavoritesSidebarFavorites | |
| 47 | 50 | onNavigate func(ctx context.Context, url string) error | |
| @@ -60,16 +63,20 @@ type FavoritesSidebar struct { | |||
| 60 | 63 | destroyed bool | |
| 61 | 64 | visible bool | |
| 62 | 65 | focusZone favoritesSidebarFocusZone | |
| 66 | + | textInputWidget *gtk.Widget | |
| 63 | 67 | mode favoritesSidebarMode | |
| 64 | 68 | editingID entity.FavoriteID | |
| 65 | 69 | confirmDelete bool | |
| 66 | 70 | confirmDeleteID entity.FavoriteID | |
| 67 | 71 | ||
| 68 | - | retainedCallbacks []any | |
| 69 | - | tagCallbacks []any | |
| 70 | - | ctx context.Context | |
| 71 | - | cancel context.CancelFunc | |
| 72 | - | mu sync.RWMutex | |
| 72 | + | retainedCallbacks []any | |
| 73 | + | tagCallbacks []any | |
| 74 | + | tagPromptCallbacks []any | |
| 75 | + | formCallbacks []any | |
| 76 | + | formTagMatchCallbacks []any | |
| 77 | + | ctx context.Context | |
| 78 | + | cancel context.CancelFunc | |
| 79 | + | mu sync.RWMutex | |
| 73 | 80 | ||
| 74 | 81 | idleScheduler func(glib.SourceFunc) | |
| 75 | 82 | } | |
| @@ -157,33 +164,12 @@ func (fs *FavoritesSidebar) Destroy() { | |||
| 157 | 164 | } | |
| 158 | 165 | ||
| 159 | 166 | func (fs *FavoritesSidebar) createWidgets() error { | |
| 160 | - | fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, favoritesSidebarOuterSpacing) | |
| 161 | - | if fs.outerBox == nil { | |
| 162 | - | return fmt.Errorf("favorites sidebar: outer box creation failed") | |
| 167 | + | if err := fs.createOuterAndSearch(); err != nil { | |
| 168 | + | return err | |
| 163 | 169 | } | |
| 164 | - | fs.outerBox.AddCssClass("favorites-sidebar-outer") | |
| 165 | - | fs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | |
| 166 | - | fs.outerBox.SetHexpand(false) | |
| 167 | - | fs.outerBox.SetVexpand(true) | |
| 168 | - | fs.outerBox.SetVisible(false) | |
| 169 | - | ||
| 170 | - | fs.searchEntry = gtk.NewSearchEntry() | |
| 171 | - | if fs.searchEntry == nil { | |
| 172 | - | return fmt.Errorf("favorites sidebar: search entry creation failed") | |
| 170 | + | if err := fs.createTagControls(); err != nil { | |
| 171 | + | return err | |
| 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 | 174 | fs.scrolledWin = gtk.NewScrolledWindow() | |
| 189 | 175 | if fs.scrolledWin == nil { | |
| @@ -192,12 +178,15 @@ func (fs *FavoritesSidebar) createWidgets() error { | |||
| 192 | 178 | fs.scrolledWin.SetVexpand(true) | |
| 193 | 179 | fs.scrolledWin.SetHexpand(true) | |
| 194 | 180 | fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 181 | + | fs.scrolledWin.AddCssClass("sidebar-list") | |
| 195 | 182 | ||
| 196 | 183 | fs.listBox = gtk.NewListBox() | |
| 197 | 184 | if fs.listBox == nil { | |
| 198 | 185 | return fmt.Errorf("favorites sidebar: list box creation failed") | |
| 199 | 186 | } | |
| 187 | + | fs.listBox.AddCssClass("sidebar-list") | |
| 200 | 188 | fs.listBox.AddCssClass("favorites-sidebar-list") | |
| 189 | + | fs.trackListFocus(&fs.listBox.Widget) | |
| 201 | 190 | fs.listBox.SetActivateOnSingleClick(true) | |
| 202 | 191 | fs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 203 | 192 | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| @@ -220,6 +209,125 @@ func (fs *FavoritesSidebar) createWidgets() error { | |||
| 220 | 209 | 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 | 331 | func (fs *FavoritesSidebar) focusSearch() { | |
| 224 | 332 | if fs == nil { | |
| 225 | 333 | 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 | 18 | favoritesSidebarModeNone favoritesSidebarMode = iota | |
| 19 | 19 | favoritesSidebarModeAdd | |
| 20 | 20 | favoritesSidebarModeEdit | |
| 21 | - | favoritesSidebarModeTag | |
| 21 | + | favoritesSidebarModeCreateTag | |
| 22 | + | favoritesSidebarModeBindTag | |
| 22 | 23 | favoritesSidebarModeShortcut | |
| 23 | 24 | ) | |
| 24 | 25 | ||
| @@ -42,7 +43,7 @@ func (fs *FavoritesSidebar) beginAddForm() { | |||
| 42 | 43 | fs.editingID = 0 | |
| 43 | 44 | fs.confirmDelete = false | |
| 44 | 45 | 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 | 47 | fs.mu.Unlock() | |
| 47 | 48 | fs.renderForm(nil) | |
| 48 | 49 | fs.rebuildList() | |
| @@ -63,7 +64,7 @@ func (fs *FavoritesSidebar) beginEditForm() { | |||
| 63 | 64 | fs.editingID = fav.ID | |
| 64 | 65 | fs.confirmDelete = false | |
| 65 | 66 | 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 | 68 | fs.mu.Unlock() | |
| 68 | 69 | fs.renderForm(fav) | |
| 69 | 70 | fs.rebuildList() | |
| @@ -74,20 +75,23 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||
| 74 | 75 | return | |
| 75 | 76 | } | |
| 76 | 77 | clearBoxChildren(fs.formBox) | |
| 78 | + | fs.formCallbacks = nil | |
| 79 | + | fs.formTagMatchCallbacks = nil | |
| 77 | 80 | fs.formURLEntry = gtk.NewSearchEntry() | |
| 78 | 81 | fs.formTitleEntry = gtk.NewSearchEntry() | |
| 79 | - | fs.formTagsEntry = nil | |
| 80 | - | if fav == nil { | |
| 81 | - | fs.formTagsEntry = gtk.NewSearchEntry() | |
| 82 | - | } | |
| 82 | + | fs.formTagSearch = gtk.NewSearchEntry() | |
| 83 | + | fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3) | |
| 83 | 84 | fs.formShortcutEntry = gtk.NewSearchEntry() | |
| 85 | + | fs.mu.Lock() | |
| 86 | + | fs.formTagIDs = tagIDSet(favoriteTags(fav)) | |
| 87 | + | fs.mu.Unlock() | |
| 84 | 88 | entries := []struct { | |
| 85 | 89 | entry *gtk.SearchEntry | |
| 86 | 90 | label string | |
| 87 | 91 | }{ | |
| 88 | 92 | {entry: fs.formURLEntry, label: "URL"}, | |
| 89 | 93 | {entry: fs.formTitleEntry, label: "Title"}, | |
| 90 | - | {entry: fs.formTagsEntry, label: "Tag IDs"}, | |
| 94 | + | {entry: fs.formTagSearch, label: "Add tags..."}, | |
| 91 | 95 | {entry: fs.formShortcutEntry, label: "Shortcut"}, | |
| 92 | 96 | } | |
| 93 | 97 | for _, item := range entries { | |
| @@ -96,21 +100,26 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||
| 96 | 100 | } | |
| 97 | 101 | placeholder := item.label | |
| 98 | 102 | item.entry.SetPlaceholderText(&placeholder) | |
| 103 | + | fs.trackTextInputFocus(&item.entry.Widget, &fs.formCallbacks) | |
| 99 | 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 | 111 | fs.formSaveButton = gtk.NewButtonWithLabel("Save") | |
| 102 | 112 | if fs.formSaveButton != nil { | |
| 103 | 113 | cb := func(_ gtk.Button) { | |
| 104 | 114 | fs.submitForm() | |
| 105 | 115 | } | |
| 106 | - | fs.retainedCallbacks = append(fs.retainedCallbacks, cb) | |
| 116 | + | fs.formCallbacks = append(fs.formCallbacks, cb) | |
| 107 | 117 | fs.formSaveButton.ConnectClicked(&cb) | |
| 108 | 118 | fs.formBox.Append(&fs.formSaveButton.Widget) | |
| 109 | 119 | } | |
| 110 | 120 | if fav != nil { | |
| 111 | 121 | fs.formURL = fav.URL | |
| 112 | 122 | fs.formTitle = fav.Title | |
| 113 | - | fs.formTags = joinFavoriteTagIDs(fav.Tags) | |
| 114 | 123 | fs.formShortcut = "" | |
| 115 | 124 | if fav.ShortcutKey != nil { | |
| 116 | 125 | fs.formShortcut = strconv.Itoa(*fav.ShortcutKey) | |
| @@ -122,15 +131,13 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||
| 122 | 131 | if fs.formTitleEntry != nil { | |
| 123 | 132 | fs.formTitleEntry.SetText(fs.formTitle) | |
| 124 | 133 | } | |
| 125 | - | if fs.formTagsEntry != nil { | |
| 126 | - | fs.formTagsEntry.SetText(fs.formTags) | |
| 127 | - | } | |
| 128 | 134 | if fs.formShortcutEntry != nil { | |
| 129 | 135 | fs.formShortcutEntry.SetText(fs.formShortcut) | |
| 130 | 136 | } | |
| 131 | 137 | } else { | |
| 132 | - | fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", "" | |
| 138 | + | fs.formURL, fs.formTitle, fs.formShortcut = "", "", "" | |
| 133 | 139 | } | |
| 140 | + | fs.renderFormTagMatches("") | |
| 134 | 141 | fs.formBox.SetVisible(true) | |
| 135 | 142 | fs.focusForm() | |
| 136 | 143 | } | |
| @@ -176,6 +183,11 @@ func (fs *FavoritesSidebar) cancelManagement() bool { | |||
| 176 | 183 | if fs.formBox != nil { | |
| 177 | 184 | fs.formBox.SetVisible(false) | |
| 178 | 185 | clearBoxChildren(fs.formBox) | |
| 186 | + | fs.formCallbacks = nil | |
| 187 | + | fs.formTagMatchCallbacks = nil | |
| 188 | + | } | |
| 189 | + | if fs.tagPromptBox != nil { | |
| 190 | + | fs.hideTagPrompt() | |
| 179 | 191 | } | |
| 180 | 192 | fs.rebuildList() | |
| 181 | 193 | return active | |
| @@ -194,13 +206,9 @@ func (fs *FavoritesSidebar) submitForm() bool { | |||
| 194 | 206 | if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) { | |
| 195 | 207 | return false | |
| 196 | 208 | } | |
| 197 | - | url, title, tagsText, shortcutText := fs.formValues() | |
| 209 | + | url, title, shortcutText := fs.formValues() | |
| 210 | + | tags := fs.formTagIDsSnapshot() | |
| 198 | 211 | if mode == favoritesSidebarModeAdd { | |
| 199 | - | tags, err := parseTagIDs(tagsText) | |
| 200 | - | if err != nil { | |
| 201 | - | fs.setNotice(err.Error()) | |
| 202 | - | return true | |
| 203 | - | } | |
| 204 | 212 | key, err := parseShortcut(shortcutText) | |
| 205 | 213 | if err != nil { | |
| 206 | 214 | fs.setNotice(err.Error()) | |
| @@ -231,15 +239,15 @@ func (fs *FavoritesSidebar) submitForm() bool { | |||
| 231 | 239 | fs.setNotice(err.Error()) | |
| 232 | 240 | return true | |
| 233 | 241 | } | |
| 242 | + | if !fs.updateFavoriteTags(id, tags) { | |
| 243 | + | return true | |
| 244 | + | } | |
| 234 | 245 | } | |
| 235 | 246 | fs.cancelManagement() | |
| 236 | 247 | fs.startLoad() | |
| 237 | 248 | 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 | 251 | func (fs *FavoritesSidebar) enterShortcutMode() { | |
| 244 | 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 | 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 | 267 | func (fs *FavoritesSidebar) setShortcutKey(key *int) bool { | |
| 297 | 268 | fav := fs.selectedFavorite() | |
| 298 | 269 | if fav == nil { | |
| @@ -402,41 +373,18 @@ func (fs *FavoritesSidebar) setNotice(notice string) { | |||
| 402 | 373 | fs.rebuildList() | |
| 403 | 374 | } | |
| 404 | 375 | ||
| 405 | - | func (fs *FavoritesSidebar) formValues() (string, string, string, string) { | |
| 406 | - | url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut | |
| 376 | + | func (fs *FavoritesSidebar) formValues() (string, string, string) { | |
| 377 | + | url, title, shortcut := fs.formURL, fs.formTitle, fs.formShortcut | |
| 407 | 378 | if fs.formURLEntry != nil { | |
| 408 | 379 | url = fs.formURLEntry.GetText() | |
| 409 | 380 | } | |
| 410 | 381 | if fs.formTitleEntry != nil { | |
| 411 | 382 | title = fs.formTitleEntry.GetText() | |
| 412 | 383 | } | |
| 413 | - | if fs.formTagsEntry != nil { | |
| 414 | - | tagsText = fs.formTagsEntry.GetText() | |
| 415 | - | } | |
| 416 | 384 | if fs.formShortcutEntry != nil { | |
| 417 | 385 | shortcut = fs.formShortcutEntry.GetText() | |
| 418 | 386 | } | |
| 419 | - | return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut) | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | func parseTagIDs(text string) ([]entity.TagID, error) { | |
| 423 | - | if text == "" { | |
| 424 | - | return nil, nil | |
| 425 | - | } | |
| 426 | - | parts := strings.Split(text, ",") | |
| 427 | - | ids := make([]entity.TagID, 0, len(parts)) | |
| 428 | - | for _, part := range parts { | |
| 429 | - | trimmed := strings.TrimSpace(part) | |
| 430 | - | if trimmed == "" { | |
| 431 | - | return nil, fmt.Errorf("invalid tag ID %q", part) | |
| 432 | - | } | |
| 433 | - | v, err := strconv.Atoi(trimmed) | |
| 434 | - | if err != nil || v <= 0 { | |
| 435 | - | return nil, fmt.Errorf("invalid tag ID %q", trimmed) | |
| 436 | - | } | |
| 437 | - | ids = append(ids, entity.TagID(v)) | |
| 438 | - | } | |
| 439 | - | return ids, nil | |
| 387 | + | return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(shortcut) | |
| 440 | 388 | } | |
| 441 | 389 | ||
| 442 | 390 | func parseShortcut(text string) (*int, error) { | |
| @@ -450,11 +398,3 @@ func parseShortcut(text string) (*int, error) { | |||
| 450 | 398 | } | |
| 451 | 399 | 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 | 17 | if keyController == nil { | |
| 18 | 18 | 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 | 23 | keyController.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 21 | 24 | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { | |
| 22 | 25 | fs.mu.RLock() | |
| @@ -29,6 +32,18 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 29 | 32 | fs.mu.RUnlock() | |
| 30 | 33 | ||
| 31 | 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 | 47 | switch keyval { | |
| 33 | 48 | case uint(gdk.KEY_Escape): | |
| 34 | 49 | if fs.cancelManagement() { | |
| @@ -43,6 +58,12 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 43 | 58 | } | |
| 44 | 59 | return true | |
| 45 | 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 | 67 | return fs.handleReturnKey(state) | |
| 47 | 68 | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | |
| 48 | 69 | fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0) | |
| @@ -89,6 +110,11 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 89 | 110 | } | |
| 90 | 111 | fs.selectAdjacentRow(1) | |
| 91 | 112 | 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 | 118 | case uint(gdk.KEY_slash): | |
| 93 | 119 | if textEditing { | |
| 94 | 120 | return false | |
| @@ -279,21 +305,97 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) { | |||
| 279 | 305 | ||
| 280 | 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 | 317 | if fs == nil { | |
| 284 | 318 | 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 | 326 | return true | |
| 288 | - | } | |
| 289 | - | for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} { | |
| 290 | - | if entry != nil && entry.HasFocus() { | |
| 291 | - | return true | |
| 327 | + | case keyval == uint(gdk.KEY_Down) && zone == favoritesSidebarFocusSearch: | |
| 328 | + | fs.focusListAndSelectFirst() | |
| 329 | + | return true | |
| 330 | + | case keyval == uint(gdk.KEY_Up) && zone == favoritesSidebarFocusList && fs.selectedRowIsFirst(): | |
| 331 | + | fs.setFocusZone(favoritesSidebarFocusSearch) | |
| 332 | + | if fs.searchEntry != nil { | |
| 333 | + | fs.searchEntry.GrabFocus() | |
| 292 | 334 | } | |
| 335 | + | return true | |
| 293 | 336 | } | |
| 294 | 337 | 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 | 399 | func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool { | |
| 298 | 400 | shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue | |
| 299 | 401 | if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 { | |
| @@ -303,9 +405,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi | |||
| 303 | 405 | mode := fs.mode | |
| 304 | 406 | confirm := fs.confirmDelete | |
| 305 | 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 | 408 | if mode == favoritesSidebarModeShortcut { | |
| 310 | 409 | if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | |
| 311 | 410 | v := int(keyval - uint(gdk.KEY_0)) | |
| @@ -328,9 +427,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi | |||
| 328 | 427 | case uint(gdk.KEY_e): | |
| 329 | 428 | fs.beginEditForm() | |
| 330 | 429 | return true | |
| 331 | - | case uint(gdk.KEY_t): | |
| 332 | - | fs.enterTagMode() | |
| 333 | - | return true | |
| 334 | 430 | case uint(gdk.KEY_s): | |
| 335 | 431 | fs.enterShortcutMode() | |
| 336 | 432 | return true | |
| @@ -366,13 +462,21 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) { | |||
| 366 | 462 | if fs == nil { | |
| 367 | 463 | return | |
| 368 | 464 | } | |
| 369 | - | zones := fs.availableFocusZones() | |
| 370 | - | if len(zones) == 0 { | |
| 465 | + | if fs.cycleTagControlFocus(reverse) { | |
| 371 | 466 | return | |
| 372 | 467 | } | |
| 373 | 468 | fs.mu.RLock() | |
| 374 | 469 | current := fs.focusZone | |
| 470 | + | tagCount := len(fs.tagControls) | |
| 375 | 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 | 480 | idx := -1 | |
| 377 | 481 | for i, zone := range zones { | |
| 378 | 482 | if zone == current { | |
| @@ -394,6 +498,37 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) { | |||
| 394 | 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 | 532 | func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone { | |
| 398 | 533 | zones := []favoritesSidebarFocusZone{} | |
| 399 | 534 | if fs.searchEntry != nil { | |
| @@ -428,9 +563,7 @@ func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) { | |||
| 428 | 563 | case favoritesSidebarFocusSearch: | |
| 429 | 564 | fs.focusSearch() | |
| 430 | 565 | case favoritesSidebarFocusTags: | |
| 431 | - | if fs.tagBox != nil { | |
| 432 | - | fs.tagBox.GrabFocus() | |
| 433 | - | } | |
| 566 | + | fs.focusTagControl(0) | |
| 434 | 567 | case favoritesSidebarFocusList, favoritesSidebarFocusConfirm: | |
| 435 | 568 | if fs.listBox != nil { | |
| 436 | 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 | 74 | func (fs *FavoritesSidebar) renderTags() { | |
| 75 | - | if fs == nil || fs.tagBox == nil { | |
| 75 | + | tagBox, tags, selectedTagIDs, ok := fs.tagRenderState() | |
| 76 | + | if !ok { | |
| 76 | 77 | 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 | 103 | fs.mu.RLock() | |
| 104 | + | defer fs.mu.RUnlock() | |
| 79 | 105 | if fs.destroyed { | |
| 80 | - | fs.mu.RUnlock() | |
| 81 | - | return | |
| 106 | + | return nil, nil, nil, false | |
| 82 | 107 | } | |
| 83 | - | tags := append([]*entity.Tag(nil), fs.allTags...) | |
| 84 | - | selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs)) | |
| 108 | + | selected := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs)) | |
| 85 | 109 | for id := range fs.selectedTagIDs { | |
| 86 | - | selectedTagIDs[id] = struct{}{} | |
| 110 | + | selected[id] = struct{}{} | |
| 87 | 111 | } | |
| 88 | - | tagBox := fs.tagBox | |
| 89 | - | fs.mu.RUnlock() | |
| 90 | - | ||
| 91 | - | clearBoxChildren(tagBox) | |
| 92 | - | callbacks := make([]any, 0, len(tags)+1) | |
| 112 | + | return fs.tagBox, append([]*entity.Tag(nil), fs.allTags...), selected, true | |
| 113 | + | } | |
| 93 | 114 | ||
| 94 | - | allButton := gtk.NewButtonWithLabel("All") | |
| 95 | - | if allButton != nil { | |
| 96 | - | if len(selectedTagIDs) == 0 { | |
| 97 | - | allButton.AddCssClass("suggested-action") | |
| 98 | - | } | |
| 99 | - | cb := func(_ gtk.Button) { | |
| 100 | - | fs.mu.Lock() | |
| 101 | - | if fs.destroyed { | |
| 102 | - | fs.mu.Unlock() | |
| 103 | - | return | |
| 104 | - | } | |
| 105 | - | fs.selectedTagIDs = make(map[entity.TagID]struct{}) | |
| 106 | - | fs.rebuildDisplayRowsLocked() | |
| 107 | - | fs.mu.Unlock() | |
| 108 | - | fs.renderTags() | |
| 109 | - | fs.rebuildList() | |
| 110 | - | } | |
| 111 | - | callbacks = append(callbacks, cb) | |
| 112 | - | allButton.ConnectClicked(&cb) | |
| 113 | - | tagBox.Append(&allButton.Widget) | |
| 115 | + | func (fs *FavoritesSidebar) appendTagFilter( | |
| 116 | + | box *gtk.Box, | |
| 117 | + | label string, | |
| 118 | + | selected bool, | |
| 119 | + | action func(), | |
| 120 | + | callbacks *[]any, | |
| 121 | + | controls *[]*gtk.Button, | |
| 122 | + | ) { | |
| 123 | + | button := gtk.NewButtonWithLabel(label) | |
| 124 | + | if button == nil { | |
| 125 | + | return | |
| 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 { | |
| 117 | - | if tag == nil { | |
| 118 | - | continue | |
| 119 | - | } | |
| 120 | - | t := tag | |
| 121 | - | button := gtk.NewButtonWithLabel(t.Name) | |
| 122 | - | if button == nil { | |
| 123 | - | continue | |
| 124 | - | } | |
| 125 | - | if _, ok := selectedTagIDs[t.ID]; ok { | |
| 126 | - | button.AddCssClass("suggested-action") | |
| 127 | - | } | |
| 128 | - | cb := func(_ gtk.Button) { | |
| 129 | - | fs.toggleTag(t.ID) | |
| 130 | - | } | |
| 131 | - | callbacks = append(callbacks, cb) | |
| 132 | - | button.ConnectClicked(&cb) | |
| 133 | - | tagBox.Append(&button.Widget) | |
| 138 | + | func (fs *FavoritesSidebar) clearTagFilters() { | |
| 139 | + | if fs == nil { | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | fs.mu.Lock() | |
| 143 | + | if fs.destroyed { | |
| 144 | + | fs.mu.Unlock() | |
| 145 | + | return | |
| 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 | 155 | fs.mu.Lock() | |
| 137 | 156 | if !fs.destroyed { | |
| 138 | 157 | fs.tagCallbacks = callbacks | |
| 158 | + | fs.tagControls = controls | |
| 139 | 159 | } | |
| 140 | 160 | fs.mu.Unlock() | |
| 141 | 161 | } | |
| @@ -212,6 +232,7 @@ func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) { | |||
| 212 | 232 | if label == nil { | |
| 213 | 233 | return | |
| 214 | 234 | } | |
| 235 | + | label.AddCssClass("sidebar-empty") | |
| 215 | 236 | label.AddCssClass("favorites-sidebar-empty") | |
| 216 | 237 | label.SetXalign(0.0) | |
| 217 | 238 | row := gtk.NewListBoxRow() | |
| @@ -243,6 +264,7 @@ func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow f | |||
| 243 | 264 | if row == nil { | |
| 244 | 265 | return | |
| 245 | 266 | } | |
| 267 | + | row.AddCssClass("sidebar-row") | |
| 246 | 268 | row.AddCssClass("favorites-sidebar-row") | |
| 247 | 269 | row.SetSelectable(displayRow.Selectable) | |
| 248 | 270 | row.SetActivatable(displayRow.Selectable) | |
| @@ -257,6 +279,7 @@ func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) { | |||
| 257 | 279 | if title == nil { | |
| 258 | 280 | return | |
| 259 | 281 | } | |
| 282 | + | title.AddCssClass("sidebar-row-title") | |
| 260 | 283 | title.AddCssClass("favorites-sidebar-row-title") | |
| 261 | 284 | title.SetText(safeSidebarString(fav.Title, fav.URL)) | |
| 262 | 285 | title.SetXalign(0.0) | |
| @@ -273,6 +296,7 @@ func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) { | |||
| 273 | 296 | sub.SetHexpand(true) | |
| 274 | 297 | url := gtk.NewLabel(nil) | |
| 275 | 298 | if url != nil { | |
| 299 | + | url.AddCssClass("sidebar-row-subtitle") | |
| 276 | 300 | url.AddCssClass("favorites-sidebar-row-subtitle") | |
| 277 | 301 | url.SetText(readableURL(fav.URL)) | |
| 278 | 302 | url.SetXalign(0.0) | |
| @@ -299,6 +323,7 @@ func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) { | |||
| 299 | 323 | if tags == nil { | |
| 300 | 324 | return | |
| 301 | 325 | } | |
| 326 | + | tags.AddCssClass("favorites-sidebar-row-tags") | |
| 302 | 327 | for _, tag := range favoriteTags { | |
| 303 | 328 | name := tag.Name | |
| 304 | 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 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 12 | "github.com/bnema/puregotk/v4/gdk" | |
| 13 | 13 | "github.com/bnema/puregotk/v4/glib" | |
| 14 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 14 | 15 | "github.com/stretchr/testify/assert" | |
| 15 | 16 | "github.com/stretchr/testify/require" | |
| 16 | 17 | ) | |
| @@ -34,8 +35,15 @@ type fakeFavoritesSidebarUC struct { | |||
| 34 | 35 | favID entity.FavoriteID | |
| 35 | 36 | tagID entity.TagID | |
| 36 | 37 | } | |
| 38 | + | createdTags []struct { | |
| 39 | + | name string | |
| 40 | + | color string | |
| 41 | + | } | |
| 37 | 42 | err error | |
| 38 | 43 | errOnSetShortcut error | |
| 44 | + | errOnTag map[entity.TagID]error | |
| 45 | + | errOnUntag map[entity.TagID]error | |
| 46 | + | failUntagOnCall int | |
| 39 | 47 | getAllCalled chan struct{} | |
| 40 | 48 | } | |
| 41 | 49 | ||
| @@ -51,6 +59,16 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er | |||
| 51 | 59 | func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { | |
| 52 | 60 | 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 | 72 | func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 55 | 73 | f.addInputs = append(f.addInputs, input) | |
| 56 | 74 | if f.err != nil { | |
| @@ -84,6 +102,9 @@ func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.Fav | |||
| 84 | 102 | favID entity.FavoriteID | |
| 85 | 103 | tagID entity.TagID | |
| 86 | 104 | }{favID: favID, tagID: tagID}) | |
| 105 | + | if err := f.errOnTag[tagID]; err != nil { | |
| 106 | + | return err | |
| 107 | + | } | |
| 87 | 108 | return f.err | |
| 88 | 109 | } | |
| 89 | 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 | 112 | favID entity.FavoriteID | |
| 92 | 113 | tagID entity.TagID | |
| 93 | 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 | 121 | 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 | 155 | func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar { | |
| 98 | 156 | fs := &FavoritesSidebar{ | |
| @@ -340,20 +398,19 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) { | |||
| 340 | 398 | fs.favoritesUC = uc | |
| 341 | 399 | fs.formURL = "https://add.test" | |
| 342 | 400 | fs.formTitle = "Added" | |
| 343 | - | fs.formTags = "1, 2" | |
| 401 | + | fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}} | |
| 344 | 402 | fs.formShortcut = "3" | |
| 345 | 403 | fs.mode = favoritesSidebarModeAdd | |
| 346 | 404 | ||
| 347 | 405 | assert.True(t, fs.submitForm()) | |
| 348 | 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 | 408 | require.Len(t, uc.shortcuts, 1) | |
| 351 | 409 | assert.Equal(t, 3, *uc.shortcuts[0].key) | |
| 352 | 410 | ||
| 353 | 411 | fs.mode = favoritesSidebarModeEdit | |
| 354 | 412 | fs.editingID = 7 | |
| 355 | 413 | fs.formTitle = "Edited" | |
| 356 | - | fs.formTags = "999" | |
| 357 | 414 | fs.formShortcut = "4" | |
| 358 | 415 | assert.True(t, fs.submitForm()) | |
| 359 | 416 | require.Len(t, uc.updateInputs, 1) | |
| @@ -372,23 +429,15 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) { | |||
| 372 | 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 | 433 | uc := &fakeFavoritesSidebarUC{} | |
| 377 | 434 | fs := newFavoritesSidebarHarness(nil, nil) | |
| 378 | 435 | fs.favoritesUC = uc | |
| 379 | 436 | fs.formURL = "https://add.test" | |
| 380 | 437 | fs.formTitle = "Added" | |
| 381 | - | fs.formTags = "1, nope" | |
| 438 | + | fs.formShortcut = "10" | |
| 382 | 439 | 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 | 441 | assert.True(t, fs.submitForm()) | |
| 393 | 442 | assert.Empty(t, uc.addInputs) | |
| 394 | 443 | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |
| @@ -396,6 +445,88 @@ func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) { | |||
| 396 | 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 | 530 | func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) { | |
| 400 | 531 | fav := &entity.Favorite{ID: 5, URL: "https://go.dev"} | |
| 401 | 532 | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) | |
| @@ -436,7 +567,6 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) { | |||
| 436 | 567 | fs.mode = favoritesSidebarModeEdit | |
| 437 | 568 | fs.editingID = 7 | |
| 438 | 569 | fs.formTitle = "Edited" | |
| 439 | - | fs.formTags = "999" | |
| 440 | 570 | ||
| 441 | 571 | assert.True(t, fs.submitForm()) | |
| 442 | 572 | require.Len(t, uc.updateInputs, 1) | |
| @@ -445,6 +575,90 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) { | |||
| 445 | 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 := >k.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 | 662 | func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) { | |
| 449 | 663 | fs := newFavoritesSidebarHarness(nil, nil) | |
| 450 | 664 | fs.searchEntry = nil | |
| @@ -505,23 +719,14 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) { | |||
| 505 | 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 | 723 | dev := entity.Tag{ID: 10, Name: "dev"} | |
| 510 | - | news := entity.Tag{ID: 11, Name: "news"} | |
| 511 | 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 | 726 | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |
| 514 | 727 | fs.allTags = uc.tags | |
| 515 | 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 | 730 | fs.mode = favoritesSidebarModeShortcut | |
| 526 | 731 | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0)) | |
| 527 | 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 | 103 | if label == nil { | |
| 104 | 104 | return | |
| 105 | 105 | } | |
| 106 | + | label.AddCssClass("sidebar-empty") | |
| 106 | 107 | label.AddCssClass("history-sidebar-empty") | |
| 107 | 108 | ||
| 108 | 109 | if query != "" { | |
| @@ -163,6 +164,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His | |||
| 163 | 164 | if titleLabel == nil { | |
| 164 | 165 | return | |
| 165 | 166 | } | |
| 167 | + | titleLabel.AddCssClass("sidebar-row-title") | |
| 166 | 168 | titleLabel.AddCssClass("history-sidebar-row-title") | |
| 167 | 169 | titleLabel.SetText(safeSidebarString(entry.Title, entry.URL)) | |
| 168 | 170 | titleLabel.SetXalign(0.0) | |
| @@ -180,6 +182,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His | |||
| 180 | 182 | if urlLabel == nil { | |
| 181 | 183 | return | |
| 182 | 184 | } | |
| 185 | + | urlLabel.AddCssClass("sidebar-row-subtitle") | |
| 183 | 186 | urlLabel.AddCssClass("history-sidebar-row-subtitle") | |
| 184 | 187 | urlLabel.SetText(readableURL(entry.URL)) | |
| 185 | 188 | urlLabel.SetXalign(0.0) | |
| @@ -205,6 +208,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His | |||
| 205 | 208 | if row == nil { | |
| 206 | 209 | return | |
| 207 | 210 | } | |
| 211 | + | row.AddCssClass("sidebar-row") | |
| 208 | 212 | row.AddCssClass("history-sidebar-row") | |
| 209 | 213 | row.SetSelectable(true) | |
| 210 | 214 | row.SetActivatable(true) | |
M internal/ui/component/history_sidebar_widgets.go +5 -0
| @@ -28,6 +28,7 @@ func (hs *HistorySidebar) initOuterBox() error { | |||
| 28 | 28 | if hs.outerBox == nil { | |
| 29 | 29 | return fmt.Errorf("history sidebar: outer box creation failed") | |
| 30 | 30 | } | |
| 31 | + | hs.outerBox.AddCssClass("sidebar-outer") | |
| 31 | 32 | hs.outerBox.AddCssClass("history-sidebar-outer") | |
| 32 | 33 | hs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | |
| 33 | 34 | hs.outerBox.SetHexpand(false) | |
| @@ -41,6 +42,7 @@ func (hs *HistorySidebar) initSearchBox() error { | |||
| 41 | 42 | if hs.searchBox == nil { | |
| 42 | 43 | return fmt.Errorf("history sidebar: search box creation failed") | |
| 43 | 44 | } | |
| 45 | + | hs.searchBox.AddCssClass("sidebar-search-box") | |
| 44 | 46 | hs.searchBox.AddCssClass("history-sidebar-search-box") | |
| 45 | 47 | hs.searchBox.SetHexpand(true) | |
| 46 | 48 | ||
| @@ -48,6 +50,7 @@ func (hs *HistorySidebar) initSearchBox() error { | |||
| 48 | 50 | if hs.searchEntry == nil { | |
| 49 | 51 | return fmt.Errorf("history sidebar: search entry creation failed") | |
| 50 | 52 | } | |
| 53 | + | hs.searchEntry.AddCssClass("sidebar-search") | |
| 51 | 54 | hs.searchEntry.AddCssClass("history-sidebar-search") | |
| 52 | 55 | hs.searchEntry.SetHexpand(true) | |
| 53 | 56 | placeholder := "Search history..." | |
| @@ -66,12 +69,14 @@ func (hs *HistorySidebar) initListArea() error { | |||
| 66 | 69 | hs.scrolledWin.SetVexpand(true) | |
| 67 | 70 | hs.scrolledWin.SetHexpand(true) | |
| 68 | 71 | hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 72 | + | hs.scrolledWin.AddCssClass("sidebar-list") | |
| 69 | 73 | hs.scrolledWin.AddCssClass("history-sidebar-groups") | |
| 70 | 74 | ||
| 71 | 75 | hs.listBox = gtk.NewListBox() | |
| 72 | 76 | if hs.listBox == nil { | |
| 73 | 77 | return fmt.Errorf("history sidebar: list box creation failed") | |
| 74 | 78 | } | |
| 79 | + | hs.listBox.AddCssClass("sidebar-list") | |
| 75 | 80 | hs.listBox.AddCssClass("history-sidebar-groups") | |
| 76 | 81 | hs.listBox.SetActivateOnSingleClick(true) | |
| 77 | 82 | hs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
M internal/ui/component/omnibox_callback_pressure_stress_test.go +5 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/purego" | |
| 11 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 11 | 12 | "github.com/bnema/puregotk/v4/glib" | |
| 12 | 13 | "github.com/bnema/puregotk/v4/gobject" | |
| 13 | 14 | "github.com/bnema/puregotk/v4/gtk" | |
| @@ -32,7 +33,10 @@ func runGTKCallbackLifecycleStress(t *testing.T) { | |||
| 32 | 33 | if !gtk.InitCheck() { | |
| 33 | 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 | 40 | mainContext := glib.MainContextDefault() | |
| 37 | 41 | require.NotNil(t, mainContext) | |
| 38 | 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 | 73 | hasGhost bool | |
| 74 | 74 | 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 | 77 | {name: "falls back to entry when no ghost", entryText: "dumb", realInput: "dumb", hasGhost: false, want: "dumb"}, | |
| 78 | 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 | 136 | {URL: "https://github.com/bnema/dumber/issues"}, | |
| 137 | 137 | } | |
| 138 | 138 | favorites := []Favorite{ | |
| 139 | - | {URL: "https://dumber.bnema.dev"}, | |
| 140 | - | {URL: "https://docs.dumber.bnema.dev"}, | |
| 139 | + | {URL: "https://bnema.dev/dumber"}, | |
| 140 | + | {URL: "https://bnema.dev/dumber/docs"}, | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | 143 | tests := []struct { | |
| @@ -148,7 +148,7 @@ func TestResolveTargetURLForSelection(t *testing.T) { | |||
| 148 | 148 | wantURL string | |
| 149 | 149 | }{ | |
| 150 | 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 | 152 | {name: "invalid index", mode: ViewModeHistory, index: 99, limit: 10, wantURL: ""}, | |
| 153 | 153 | {name: "history index beyond visible limit is hidden", mode: ViewModeHistory, index: 2, limit: 2, wantURL: ""}, | |
| 154 | 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 | 170 | {URL: "https://github.com/bnema/dumber/pulls"}, | |
| 171 | 171 | } | |
| 172 | 172 | favorites := []Favorite{ | |
| 173 | - | {URL: "https://dumber.bnema.dev"}, | |
| 174 | - | {URL: "https://docs.dumber.bnema.dev"}, | |
| 173 | + | {URL: "https://bnema.dev/dumber"}, | |
| 174 | + | {URL: "https://bnema.dev/dumber/docs"}, | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | 177 | tests := []struct { | |
| @@ -184,7 +184,7 @@ func TestSelectedTargetURL(t *testing.T) { | |||
| 184 | 184 | }{ | |
| 185 | 185 | {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, limit: 10, wantURL: "", wantBool: false}, | |
| 186 | 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 | 188 | {name: "hidden history selection is ignored", mode: ViewModeHistory, index: 1, limit: 1, wantURL: "", wantBool: true}, | |
| 189 | 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 | 14 | const ( | |
| 15 | 15 | // CSS class applied to active pane's border overlay | |
| 16 | 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 | 40 | // PaneView is a container for a single WebView with active state indication. | |
| @@ -31,6 +52,10 @@ type PaneView struct { | |||
| 31 | 52 | paneID entity.PaneID | |
| 32 | 53 | isActive bool | |
| 33 | 54 | ||
| 55 | + | // Vim Mode pane-local visual state. | |
| 56 | + | vimMode bool | |
| 57 | + | pulseCycle bool | |
| 58 | + | ||
| 34 | 59 | onFocusIn func(paneID entity.PaneID) | |
| 35 | 60 | onFocusOut func(paneID entity.PaneID) | |
| 36 | 61 | onHover func(paneID entity.PaneID) | |
| @@ -92,6 +117,7 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit | |||
| 92 | 117 | loading: loading, | |
| 93 | 118 | paneID: paneID, | |
| 94 | 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 | 609 | // Cleanup removes the WebView widget from the overlay and clears references. | |
| 524 | 610 | // This must be called before destroying the WebView to ensure proper GTK cleanup. | |
| 525 | 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 | 681 | mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once() | |
| 682 | 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 | 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 | 291 | // hide performs the actual hide operation (must be called with lock held). | |
| 280 | 292 | func (t *Toaster) hide() { | |
| 281 | 293 | if !t.visible { | |
M internal/ui/component/workspace_view.go +10 -0
| @@ -73,6 +73,16 @@ type WorkspaceView struct { | |||
| 73 | 73 | 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 | 86 | // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface | |
| 77 | 87 | // so TreeRenderer can create PaneViews. | |
| 78 | 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 | 188 | // Add popup create handler if popup handling is configured | |
| 183 | 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 | 109 | // Callback when WebView gains focus (for accent picker text input targeting) | |
| 110 | 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 | 115 | // Callback for first load_started event (triggers deferred initialization) | |
| 113 | 116 | onFirstLoadStarted func() | |
| 114 | 117 | loadStartedOnce sync.Once | |
| @@ -243,6 +246,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port. | |||
| 243 | 246 | 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 | 254 | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | |
| 247 | 255 | // This is used to trigger deferred initialization after the initial load_uri() | |
| 248 | 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 | 23 | contextProvider func() context.Context | |
| 24 | 24 | navigateUC *usecase.NavigateUseCase | |
| 25 | 25 | historyRecorder *usecase.HistoryRecorderUseCase | |
| 26 | + | pageScrollUC *usecase.PageScrollUseCase | |
| 26 | 27 | contentCoord *content.Coordinator | |
| 27 | 28 | omniboxProvider OmniboxProvider | |
| 28 | 29 | } | |
| @@ -275,6 +276,56 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) { | |||
| 275 | 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 | 329 | // NotifyZoomChanged updates the omnibox zoom indicator. | |
| 279 | 330 | func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) { | |
| 280 | 331 | if c.omniboxProvider != nil { | |
M internal/ui/coordinator/navigation_webview_targeted_test.go +74 -0
| @@ -6,9 +6,15 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 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 | 18 | type mockDevToolsWebView struct { | |
| 13 | 19 | *mocks.MockWebView | |
| 14 | 20 | *mocks.MockDevToolsOpener | |
| @@ -76,6 +82,15 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi | |||
| 76 | 82 | t.Run("PrintWebView calls PrintPage once", func(t *testing.T) { | |
| 77 | 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 | 96 | func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| @@ -268,3 +283,62 @@ func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |||
| 268 | 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 | 164 | // Split splits the active pane in the given direction. | |
| 165 | 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 | 169 | // SplitWithURL splits the active pane in the given direction and loads initialURL. | |
| 170 | 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 | 185 | log := logging.FromContext(ctx) | |
| 176 | 186 | ||
| 177 | - | splitCtx, ok := c.prepareSplit(ctx, direction) | |
| 178 | - | if !ok { | |
| 179 | - | return nil | |
| 187 | + | splitCtx, err := c.prepareSplit(ctx, direction) | |
| 188 | + | if err != nil { | |
| 189 | + | return err | |
| 180 | 190 | } | |
| 181 | 191 | ||
| 182 | 192 | output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{ | |
| @@ -198,11 +208,20 @@ func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, directio | |||
| 198 | 208 | ||
| 199 | 209 | // Update the workspace view | |
| 200 | 210 | if splitCtx.wsView != nil { | |
| 201 | - | c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID) | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | if splitCtx.wsView != nil { | |
| 205 | - | splitCtx.wsView.NotifyNewPaneCreated(ctx) | |
| 211 | + | if err := c.applySplitToView( | |
| 212 | + | ctx, splitCtx.wsView, splitCtx.ws, output, direction, | |
| 213 | + | splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID, | |
| 214 | + | ); err != nil { | |
| 215 | + | if rollbackErr := c.ClosePaneByID(ctx, output.NewPaneNode.Pane.ID); rollbackErr != nil { | |
| 216 | + | log.Warn().Err(rollbackErr).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to roll back split pane") | |
| 217 | + | } | |
| 218 | + | splitCtx.ws.ActivePaneID = oldActivePaneID | |
| 219 | + | c.notifyStateChanged() | |
| 220 | + | return err | |
| 221 | + | } | |
| 222 | + | if notifyNewPane { | |
| 223 | + | splitCtx.wsView.NotifyNewPaneCreated(ctx) | |
| 224 | + | } | |
| 206 | 225 | } | |
| 207 | 226 | ||
| 208 | 227 | // Notify state change for session snapshots | |
| @@ -332,23 +351,27 @@ func setActiveStackIndexForChild(parent, child *entity.PaneNode) { | |||
| 332 | 351 | func (c *WorkspaceCoordinator) prepareSplit( | |
| 333 | 352 | ctx context.Context, | |
| 334 | 353 | direction usecase.SplitDirection, | |
| 335 | - | ) (*splitContext, bool) { | |
| 354 | + | ) (*splitContext, error) { | |
| 336 | 355 | log := logging.FromContext(ctx) | |
| 337 | 356 | if c.panesUC == nil { | |
| 338 | 357 | log.Warn().Msg("panes use case not available") | |
| 339 | - | return nil, false | |
| 358 | + | return nil, fmt.Errorf("panes use case not available") | |
| 359 | + | } | |
| 360 | + | if c.getActiveWS == nil { | |
| 361 | + | log.Warn().Msg("active workspace provider not configured") | |
| 362 | + | return nil, fmt.Errorf("active workspace provider not configured") | |
| 340 | 363 | } | |
| 341 | 364 | ||
| 342 | 365 | ws, wsView := c.getActiveWS() | |
| 343 | 366 | if ws == nil { | |
| 344 | 367 | log.Warn().Msg("no active workspace") | |
| 345 | - | return nil, false | |
| 368 | + | return nil, fmt.Errorf("no active workspace") | |
| 346 | 369 | } | |
| 347 | 370 | ||
| 348 | 371 | activePane := ws.ActivePane() | |
| 349 | - | if activePane == nil { | |
| 372 | + | if activePane == nil || activePane.Pane == nil { | |
| 350 | 373 | log.Warn().Msg("no active pane to split") | |
| 351 | - | return nil, false | |
| 374 | + | return nil, fmt.Errorf("no active pane to split") | |
| 352 | 375 | } | |
| 353 | 376 | ||
| 354 | 377 | // Check if active pane is inside a stack | |
| @@ -367,7 +390,7 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||
| 367 | 390 | activePane: activePane, | |
| 368 | 391 | existingWidget: existingWidget, | |
| 369 | 392 | isStackSplit: isStackSplit, | |
| 370 | - | }, true | |
| 393 | + | }, nil | |
| 371 | 394 | } | |
| 372 | 395 | ||
| 373 | 396 | func (c *WorkspaceCoordinator) resolveSplitWidget( | |
| @@ -407,7 +430,7 @@ func (c *WorkspaceCoordinator) applySplitToView( | |||
| 407 | 430 | existingWidget layout.Widget, | |
| 408 | 431 | isStackSplit bool, | |
| 409 | 432 | oldActivePaneID entity.PaneID, | |
| 410 | - | ) { | |
| 433 | + | ) error { | |
| 411 | 434 | log := logging.FromContext(ctx) | |
| 412 | 435 | needsAttach := false | |
| 413 | 436 | ||
| @@ -423,12 +446,14 @@ func (c *WorkspaceCoordinator) applySplitToView( | |||
| 423 | 446 | log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild") | |
| 424 | 447 | if err := wsView.Rebuild(ctx); err != nil { | |
| 425 | 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 | 451 | needsAttach = true | |
| 428 | 452 | } | |
| 429 | 453 | } else { | |
| 430 | 454 | if err := wsView.Rebuild(ctx); err != nil { | |
| 431 | 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 | 458 | needsAttach = true | |
| 434 | 459 | } | |
| @@ -439,8 +464,10 @@ func (c *WorkspaceCoordinator) applySplitToView( | |||
| 439 | 464 | } | |
| 440 | 465 | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| 441 | 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 | 469 | wsView.FocusPane(ws.ActivePaneID) | |
| 470 | + | return nil | |
| 444 | 471 | } | |
| 445 | 472 | ||
| 446 | 473 | // doIncrementalStackSplit performs an incremental split around an existing stacked pane. | |
| @@ -582,6 +609,13 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||
| 582 | 609 | wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID) | |
| 583 | 610 | if err != nil { | |
| 584 | 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 | 619 | return err | |
| 586 | 620 | } | |
| 587 | 621 | ||
| @@ -722,6 +756,11 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 722 | 756 | wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID) | |
| 723 | 757 | if err != nil { | |
| 724 | 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 | 764 | return err | |
| 726 | 765 | } | |
| 727 | 766 | ||
| @@ -1453,71 +1492,32 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView | |||
| 1453 | 1492 | // StackPane adds a new pane stacked on top of the active pane. | |
| 1454 | 1493 | // Uses CreateStack use case for new stacks, AddToStack for existing stacks. | |
| 1455 | 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) | |
| 1459 | - | if !ok { | |
| 1460 | - | return nil | |
| 1461 | - | } | |
| 1498 | + | // StackPaneWithURL adds a new pane containing initialURL to the active stack. | |
| 1499 | + | func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL string) error { | |
| 1500 | + | return c.stackPaneWithURL(ctx, initialURL, true) | |
| 1501 | + | } | |
| 1462 | 1502 | ||
| 1463 | - | // Determine if we need to create a new stack or add to existing. | |
| 1464 | - | var stackNode *entity.PaneNode | |
| 1465 | - | var newPane *entity.Pane | |
| 1466 | - | var newPaneID entity.PaneID | |
| 1467 | - | var needsFirstPaneTitleUpdate bool | |
| 1503 | + | // StackPaneWithURLWithoutOmnibox adds a stacked pane without opening the omnibox. | |
| 1504 | + | func (c *WorkspaceCoordinator) StackPaneWithURLWithoutOmnibox(ctx context.Context, initialURL string) error { | |
| 1505 | + | return c.stackPaneWithURL(ctx, initialURL, false) | |
| 1506 | + | } | |
| 1468 | 1507 | ||
| 1469 | - | if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked { | |
| 1470 | - | // Already in a stack - use AddToStack use case | |
| 1471 | - | stackNode = stackCtx.activeNode.Parent | |
| 1472 | - | output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL) | |
| 1473 | - | if err != nil { | |
| 1474 | - | log.Error().Err(err).Msg("failed to add pane to stack via use case") | |
| 1475 | - | return err | |
| 1476 | - | } | |
| 1477 | - | newPane = output.NewPaneNode.Pane | |
| 1478 | - | newPaneID = newPane.ID | |
| 1479 | - | newPane.Title = defaultPaneTitle | |
| 1480 | - | log.Debug(). | |
| 1481 | - | Int("stack_size", len(stackNode.Children)). | |
| 1482 | - | Int("insert_index", output.StackIndex). | |
| 1483 | - | Msg("added to existing stack via use case") | |
| 1484 | - | } else if stackCtx.activeNode.IsStacked { | |
| 1485 | - | // Active node is already a stack container - add to it | |
| 1486 | - | stackNode = stackCtx.activeNode | |
| 1487 | - | output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL) | |
| 1488 | - | if err != nil { | |
| 1489 | - | log.Error().Err(err).Msg("failed to add pane to stack via use case") | |
| 1490 | - | return err | |
| 1491 | - | } | |
| 1492 | - | newPane = output.NewPaneNode.Pane | |
| 1493 | - | newPaneID = newPane.ID | |
| 1494 | - | newPane.Title = defaultPaneTitle | |
| 1495 | - | log.Debug(). | |
| 1496 | - | Int("stack_size", len(stackNode.Children)). | |
| 1497 | - | Int("insert_index", output.StackIndex). | |
| 1498 | - | Msg("added to stack container via use case") | |
| 1499 | - | } else { | |
| 1500 | - | // Need to create a new stack - use CreateStack use case. | |
| 1501 | - | output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode, c.newPaneURL) | |
| 1502 | - | if err != nil { | |
| 1503 | - | log.Error().Err(err).Msg("failed to create stack via use case") | |
| 1504 | - | return err | |
| 1505 | - | } | |
| 1506 | - | stackNode = output.StackNode | |
| 1507 | - | newPane = output.NewPane | |
| 1508 | - | newPaneID = newPane.ID | |
| 1509 | - | newPane.Title = defaultPaneTitle | |
| 1510 | - | needsFirstPaneTitleUpdate = true | |
| 1508 | + | func (c *WorkspaceCoordinator) stackPaneWithURL(ctx context.Context, initialURL string, notifyNewPane bool) error { | |
| 1509 | + | log := logging.FromContext(ctx) | |
| 1511 | 1510 | ||
| 1512 | - | // Update the original pane's title in the domain | |
| 1513 | - | if output.OriginalNode != nil && output.OriginalNode.Pane != nil { | |
| 1514 | - | output.OriginalNode.Pane.Title = stackCtx.originalTitle | |
| 1515 | - | } | |
| 1511 | + | stackCtx, err := c.prepareStackPane(ctx) | |
| 1512 | + | if err != nil { | |
| 1513 | + | return err | |
| 1514 | + | } | |
| 1516 | 1515 | ||
| 1517 | - | log.Debug(). | |
| 1518 | - | Int("stack_size", len(stackNode.Children)). | |
| 1519 | - | Msg("created new stack via use case") | |
| 1516 | + | stackNode, newPane, needsFirstPaneTitleUpdate, err := c.createOrAddStackPane(ctx, stackCtx, initialURL) | |
| 1517 | + | if err != nil { | |
| 1518 | + | return err | |
| 1520 | 1519 | } | |
| 1520 | + | newPaneID := newPane.ID | |
| 1521 | 1521 | ||
| 1522 | 1522 | // Create PaneView for the new pane | |
| 1523 | 1523 | newPaneView := component.NewPaneView(ctx, c.widgetFactory, newPaneID, nil) | |
| @@ -1525,9 +1525,11 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1525 | 1525 | stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView) | |
| 1526 | 1526 | ||
| 1527 | 1527 | // Add to the UI StackedView | |
| 1528 | - | if err := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle); err != nil { | |
| 1529 | - | log.Error().Err(err).Msg("failed to add pane to stack") | |
| 1530 | - | return err | |
| 1528 | + | addErr := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle) | |
| 1529 | + | if addErr != nil { | |
| 1530 | + | log.Error().Err(addErr).Msg("failed to add pane to stack") | |
| 1531 | + | c.rollbackStackPane(ctx, stackCtx, newPaneID) | |
| 1532 | + | return addErr | |
| 1531 | 1533 | } | |
| 1532 | 1534 | ||
| 1533 | 1535 | // 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 | 1540 | // Get WebView and attach | |
| 1539 | 1541 | wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID) | |
| 1540 | 1542 | if err != nil { | |
| 1541 | - | log.Warn().Err(err).Msg("failed to get webview for new pane") | |
| 1542 | - | } else { | |
| 1543 | - | widget := c.contentCoord.WrapWidget(ctx, wv) | |
| 1544 | - | if widget != nil { | |
| 1545 | - | if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil { | |
| 1546 | - | log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane") | |
| 1547 | - | } | |
| 1548 | - | } | |
| 1549 | - | // Load initial page for the new pane | |
| 1550 | - | if err := wv.LoadURI(ctx, newPane.URI); err != nil { | |
| 1551 | - | log.Warn().Err(err).Str("uri", newPane.URI).Msg("failed to load initial page") | |
| 1552 | - | } | |
| 1543 | + | c.rollbackStackPane(ctx, stackCtx, newPaneID) | |
| 1544 | + | return err | |
| 1545 | + | } | |
| 1546 | + | widget := c.contentCoord.WrapWidget(ctx, wv) | |
| 1547 | + | if widget == nil { | |
| 1548 | + | err := fmt.Errorf("wrap webview for stacked pane") | |
| 1549 | + | c.rollbackStackPane(ctx, stackCtx, newPaneID) | |
| 1550 | + | return err | |
| 1551 | + | } | |
| 1552 | + | if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil { | |
| 1553 | + | log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane") | |
| 1554 | + | c.rollbackStackPane(ctx, stackCtx, newPaneID) | |
| 1555 | + | return err | |
| 1556 | + | } | |
| 1557 | + | // Loading failures leave the attached pane available for a later navigation. | |
| 1558 | + | if err := wv.LoadURI(ctx, newPane.URI); err != nil { | |
| 1559 | + | log.Warn().Err(err).Str("uri_host", logging.SafeURLHost(newPane.URI)).Msg("failed to load initial page") | |
| 1553 | 1560 | } | |
| 1554 | 1561 | ||
| 1555 | 1562 | // Update workspace view | |
| 1556 | 1563 | if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil { | |
| 1557 | 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 | 1574 | // Set up title bar click and close callbacks | |
| 1563 | 1575 | tr := stackCtx.wsView.TreeRenderer() | |
| @@ -1586,30 +1598,91 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1586 | 1598 | 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 | 1646 | func (c *WorkspaceCoordinator) prepareStackPane( | |
| 1590 | 1647 | ctx context.Context, | |
| 1591 | - | ) (*stackPaneContext, bool) { | |
| 1648 | + | ) (*stackPaneContext, error) { | |
| 1592 | 1649 | log := logging.FromContext(ctx) | |
| 1593 | 1650 | if c.stackedPaneMgr == nil { | |
| 1594 | 1651 | log.Warn().Msg("stacked pane manager not available") | |
| 1595 | - | return nil, false | |
| 1652 | + | return nil, fmt.Errorf("stacked pane manager not available") | |
| 1653 | + | } | |
| 1654 | + | if c.panesUC == nil { | |
| 1655 | + | log.Warn().Msg("panes use case not available") | |
| 1656 | + | return nil, fmt.Errorf("panes use case not available") | |
| 1657 | + | } | |
| 1658 | + | if c.contentCoord == nil { | |
| 1659 | + | log.Warn().Msg("content coordinator not available") | |
| 1660 | + | return nil, fmt.Errorf("content coordinator not available") | |
| 1661 | + | } | |
| 1662 | + | if c.widgetFactory == nil { | |
| 1663 | + | log.Warn().Msg("widget factory not available") | |
| 1664 | + | return nil, fmt.Errorf("widget factory not available") | |
| 1665 | + | } | |
| 1666 | + | if c.getActiveWS == nil { | |
| 1667 | + | log.Warn().Msg("active workspace provider not configured") | |
| 1668 | + | return nil, fmt.Errorf("active workspace provider not configured") | |
| 1596 | 1669 | } | |
| 1597 | 1670 | ||
| 1598 | 1671 | ws, wsView := c.getActiveWS() | |
| 1599 | 1672 | if ws == nil { | |
| 1600 | 1673 | log.Warn().Msg("no active workspace") | |
| 1601 | - | return nil, false | |
| 1674 | + | return nil, fmt.Errorf("no active workspace") | |
| 1602 | 1675 | } | |
| 1603 | 1676 | ||
| 1604 | 1677 | activeNode := ws.ActivePane() | |
| 1605 | 1678 | if activeNode == nil || activeNode.Pane == nil { | |
| 1606 | 1679 | log.Warn().Msg("no active pane") | |
| 1607 | - | return nil, false | |
| 1680 | + | return nil, fmt.Errorf("no active pane") | |
| 1608 | 1681 | } | |
| 1609 | 1682 | ||
| 1610 | 1683 | if wsView == nil { | |
| 1611 | 1684 | log.Warn().Msg("no workspace view") | |
| 1612 | - | return nil, false | |
| 1685 | + | return nil, fmt.Errorf("no workspace view") | |
| 1613 | 1686 | } | |
| 1614 | 1687 | ||
| 1615 | 1688 | activePaneID := activeNode.Pane.ID | |
| @@ -1627,7 +1700,7 @@ func (c *WorkspaceCoordinator) prepareStackPane( | |||
| 1627 | 1700 | activeNode: activeNode, | |
| 1628 | 1701 | activePaneID: activePaneID, | |
| 1629 | 1702 | originalTitle: originalTitle, | |
| 1630 | - | }, true | |
| 1703 | + | }, nil | |
| 1631 | 1704 | } | |
| 1632 | 1705 | ||
| 1633 | 1706 | func (c *WorkspaceCoordinator) updateFirstStackTitle( | |
| @@ -1942,7 +2015,14 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input conte | |||
| 1942 | 2015 | ||
| 1943 | 2016 | // Update UI | |
| 1944 | 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 | 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 | 2138 | // Revert stack conversion if we created a new stack | |
| 2059 | 2139 | conversionInfo.revert() | |
| 2140 | + | c.notifyStateChanged() | |
| 2060 | 2141 | ||
| 2061 | 2142 | 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 | 52 | onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error | |
| 53 | 53 | onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error | |
| 54 | 54 | onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error | |
| 55 | + | onVimModePulse func(ctx context.Context, fast bool) | |
| 55 | 56 | onToggleHistorySidebar func(ctx context.Context) error | |
| 56 | 57 | onToggleFavoritesSidebar func(ctx context.Context) error | |
| 57 | 58 | onToggleCurrentFavorite func(ctx context.Context) error | |
| @@ -156,6 +157,22 @@ func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context | |||
| 156 | 157 | 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 | 176 | func (d *KeyboardDispatcher) initActionHandlers() { | |
| 160 | 177 | const ( | |
| 161 | 178 | firstTabIndex = 0 | |
| @@ -249,6 +266,13 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 249 | 266 | input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") }, | |
| 250 | 267 | input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") }, | |
| 251 | 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 | 276 | // UI | |
| 253 | 277 | input.ActionOpenOmnibox: d.navCoord.OpenOmnibox, | |
| 254 | 278 | input.ActionOpenFind: d.handleFindOpen, | |
| @@ -271,7 +295,7 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 271 | 295 | } | |
| 272 | 296 | return d.onToggleHistorySidebar(ctx) | |
| 273 | 297 | }, | |
| 274 | - | input.ActionToggleFavoritesSystemView: func(ctx context.Context) error { | |
| 298 | + | input.ActionToggleFavoritesSidebar: func(ctx context.Context) error { | |
| 275 | 299 | if d.onToggleFavoritesSidebar == nil { | |
| 276 | 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 | 392 | // Dispatch routes a keyboard action to the appropriate coordinator. | |
| 302 | 393 | func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error { | |
| 303 | 394 | log := logging.FromContext(ctx) | |
M internal/ui/dispatcher/keyboard_test.go +185 -3
| @@ -5,6 +5,9 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 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 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 12 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| 10 | 13 | "github.com/bnema/dumber/internal/ui/input" | |
| @@ -119,19 +122,19 @@ func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErro | |||
| 119 | 122 | ctx := context.Background() | |
| 120 | 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 | 126 | require.Error(t, missingErr) | |
| 124 | 127 | require.ErrorContains(t, missingErr, "favorites sidebar unavailable") | |
| 125 | 128 | ||
| 126 | 129 | wantErr := fmt.Errorf("favorites failed") | |
| 127 | 130 | d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr }) | |
| 128 | - | err := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView) | |
| 131 | + | err := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar) | |
| 129 | 132 | require.Error(t, err) | |
| 130 | 133 | require.ErrorIs(t, err, wantErr) | |
| 131 | 134 | ||
| 132 | 135 | var called bool | |
| 133 | 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 | 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 | 61 | bm.borderOverlay.RemoveCssClass(tabModeClass) | |
| 62 | 62 | case input.ModeSession: | |
| 63 | 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 | 69 | // Add new mode class and set visibility | |
| @@ -74,6 +77,11 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) | |||
| 74 | 77 | case input.ModeSession: | |
| 75 | 78 | bm.borderOverlay.AddCssClass(sessionModeClass) | |
| 76 | 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 | 85 | case input.ModeNormal: | |
| 78 | 86 | bm.borderOverlay.SetVisible(false) | |
| 79 | 87 | default: | |
M internal/ui/input/global_shortcuts.go +41 -17
| @@ -31,7 +31,6 @@ type GlobalShortcutHandler struct { | |||
| 31 | 31 | shortcutRefs map[string]globalShortcutRegistration | |
| 32 | 32 | lastDispatchAt map[Action]time.Time | |
| 33 | 33 | heldShortcuts map[globalShortcutHoldKey]struct{} | |
| 34 | - | shortcutAction *gtk.NamedAction | |
| 35 | 34 | globalAction *gio.SimpleAction | |
| 36 | 35 | globalActionCb func(gio.SimpleAction, uintptr) | |
| 37 | 36 | 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 | 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 | 120 | shortcuts := NewShortcutSet(ctx, workspace, session) | |
| 119 | 121 | for binding, action := range shortcuts.Global { | |
| 122 | + | if !shouldRegisterGTKGlobalShortcut(action) { | |
| 123 | + | continue | |
| 124 | + | } | |
| 120 | 125 | if _, exists := h.registered[binding]; exists { | |
| 121 | 126 | continue | |
| 122 | 127 | } | |
| @@ -139,7 +144,6 @@ func (h *GlobalShortcutHandler) configureGlobalControllers() { | |||
| 139 | 144 | // Set global scope - this is the key to making shortcuts work | |
| 140 | 145 | // even when WebView has focus. | |
| 141 | 146 | h.controller.SetScope(gtk.ShortcutScopeGlobalValue) | |
| 142 | - | h.shortcutAction = gtk.NewNamedAction(globalShortcutActionFullName) | |
| 143 | 147 | h.globalAction = gio.NewSimpleAction(globalShortcutActionName, glib.NewVariantType("s")) | |
| 144 | 148 | if h.globalAction != nil { | |
| 145 | 149 | h.globalActionCb = func(_ gio.SimpleAction, parameter uintptr) { | |
| @@ -212,23 +216,26 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 212 | 216 | 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 | 222 | logging.FromContext(h.ctx).Error(). | |
| 217 | 223 | Uint("keyval", keyval). | |
| 218 | - | Msg("global shortcut action is unavailable") | |
| 224 | + | Msg("failed to create keyval trigger") | |
| 219 | 225 | return false | |
| 220 | 226 | } | |
| 221 | 227 | ||
| 222 | - | // Create trigger for this key combination. | |
| 223 | - | trigger := gtk.NewKeyvalTrigger(keyval, modifiers) | |
| 224 | - | if trigger == nil { | |
| 228 | + | // GtkShortcut takes ownership of its action. Each shortcut needs its own | |
| 229 | + | // named action even though they all dispatch through the same window action. | |
| 230 | + | shortcutAction := gtk.NewNamedAction(globalShortcutActionFullName) | |
| 231 | + | if shortcutAction == nil { | |
| 225 | 232 | logging.FromContext(h.ctx).Error(). | |
| 226 | 233 | Uint("keyval", keyval). | |
| 227 | - | Msg("failed to create keyval trigger") | |
| 234 | + | Msg("failed to create global shortcut action") | |
| 228 | 235 | return false | |
| 229 | 236 | } | |
| 230 | 237 | ||
| 231 | - | shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &h.shortcutAction.ShortcutAction) | |
| 238 | + | shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction) | |
| 232 | 239 | if shortcut == nil { | |
| 233 | 240 | logging.FromContext(h.ctx).Error(). | |
| 234 | 241 | Uint("keyval", keyval). | |
| @@ -292,6 +299,14 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, | |||
| 292 | 299 | Msg("inactive window global shortcut callback ignored") | |
| 293 | 300 | 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 | 310 | eventInfo := h.inspectCurrentShortcutEvent() | |
| 296 | 311 | if !shouldDispatchGlobalShortcutEvent(eventInfo) { | |
| 297 | 312 | appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo). | |
| @@ -318,8 +333,7 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, | |||
| 318 | 333 | // Mode-enter/exit actions go through KeyboardHandler for modal state. | |
| 319 | 334 | if isModeAction(actionToDispatch) { | |
| 320 | 335 | if h.kbHandler != nil { | |
| 321 | - | h.kbHandler.DispatchAction(actionToDispatch) | |
| 322 | - | return true | |
| 336 | + | return h.kbHandler.DispatchAction(actionToDispatch) | |
| 323 | 337 | } | |
| 324 | 338 | log.Warn(). | |
| 325 | 339 | Str("action", string(actionToDispatch)). | |
| @@ -481,6 +495,14 @@ func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, act | |||
| 481 | 495 | 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 | 506 | func globalShortcutActionMap() map[string]Action { | |
| 485 | 507 | return map[string]Action{ | |
| 486 | 508 | "toggle_floating_pane": ActionToggleFloatingPane, | |
| @@ -495,8 +517,8 @@ func globalShortcutActionMap() map[string]Action { | |||
| 495 | 517 | "consume-or-expel-down": ActionConsumeOrExpelDown, | |
| 496 | 518 | "toggle_history_systemview": ActionToggleHistorySystemView, | |
| 497 | 519 | "toggle-history-systemview": ActionToggleHistorySystemView, | |
| 498 | - | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |
| 499 | - | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |
| 520 | + | "toggle_favorites_sidebar": ActionToggleFavoritesSidebar, | |
| 521 | + | "toggle-favorites-sidebar": ActionToggleFavoritesSidebar, | |
| 500 | 522 | "toggle_current_page_favorite": ActionToggleCurrentPageFavorite, | |
| 501 | 523 | "toggle-current-page-favorite": ActionToggleCurrentPageFavorite, | |
| 502 | 524 | "toggle_config_systemview": ActionToggleConfigSystemView, | |
| @@ -563,6 +585,9 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||
| 563 | 585 | ||
| 564 | 586 | shortcuts := NewShortcutSet(ctx, workspace, session) | |
| 565 | 587 | for binding, action := range shortcuts.Global { | |
| 588 | + | if !shouldRegisterGTKGlobalShortcut(action) { | |
| 589 | + | continue | |
| 590 | + | } | |
| 566 | 591 | if _, exists := h.registered[binding]; exists { | |
| 567 | 592 | continue | |
| 568 | 593 | } | |
| @@ -610,7 +635,6 @@ func (h *GlobalShortcutHandler) detach(removeFromWindow bool) { | |||
| 610 | 635 | } | |
| 611 | 636 | h.controller = nil | |
| 612 | 637 | h.releaseController = nil | |
| 613 | - | h.shortcutAction = nil | |
| 614 | 638 | h.globalAction = nil | |
| 615 | 639 | h.globalActionCb = nil | |
| 616 | 640 | h.globalActionHandlerID = 0 | |
| @@ -709,7 +733,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool { | |||
| 709 | 733 | ActionToggleFullscreen, | |
| 710 | 734 | ActionToggleFloatingPane, | |
| 711 | 735 | ActionToggleHistorySystemView, | |
| 712 | - | ActionToggleFavoritesSystemView, | |
| 736 | + | ActionToggleFavoritesSidebar, | |
| 713 | 737 | ActionToggleCurrentPageFavorite, | |
| 714 | 738 | ActionToggleConfigSystemView, | |
| 715 | 739 | ActionCopyURL, | |
| @@ -816,7 +840,7 @@ func formatEventType(eventType gdk.EventType) string { | |||
| 816 | 840 | ||
| 817 | 841 | func isModeAction(action Action) bool { | |
| 818 | 842 | switch action { | |
| 819 | - | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode: | |
| 843 | + | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionExitMode: | |
| 820 | 844 | return true | |
| 821 | 845 | default: | |
| 822 | 846 | return false | |
M internal/ui/input/global_shortcuts_test.go +85 -4
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package input | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -9,6 +10,27 @@ import ( | |||
| 9 | 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 | 34 | func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) { | |
| 13 | 35 | h := &GlobalShortcutHandler{ | |
| 14 | 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 | 120 | func TestGlobalShortcutIDIncludesBinding(t *testing.T) { | |
| 37 | 121 | first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}, 0) | |
| 38 | 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 | 237 | if h.releaseController != nil { | |
| 154 | 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 | 240 | if h.globalAction != nil { | |
| 160 | 241 | t.Fatal("globalAction should be cleared during destroy detach") | |
| 161 | 242 | } | |
| @@ -230,7 +311,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te | |||
| 230 | 311 | ActionFindPrev, | |
| 231 | 312 | ActionOpenDevTools, | |
| 232 | 313 | ActionToggleFloatingPane, | |
| 233 | - | ActionToggleFavoritesSystemView, | |
| 314 | + | ActionToggleFavoritesSidebar, | |
| 234 | 315 | ActionToggleCurrentPageFavorite, | |
| 235 | 316 | ActionToggleConfigSystemView, | |
| 236 | 317 | ActionCopyURL, | |
M internal/ui/input/keyboard.go +373 -22
| @@ -36,6 +36,28 @@ import ( | |||
| 36 | 36 | // Return an error if the action fails. | |
| 37 | 37 | 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 | 61 | // AccentHandler handles long-press accent detection. | |
| 40 | 62 | // Called for character keys that may have accent variants. | |
| 41 | 63 | type AccentHandler interface { | |
| @@ -58,8 +80,10 @@ type KeyboardHandler struct { | |||
| 58 | 80 | workspace *entity.WorkspaceConfig | |
| 59 | 81 | session *entity.SessionConfig | |
| 60 | 82 | ||
| 61 | - | // Action handler callback | |
| 62 | - | onAction ActionHandler | |
| 83 | + | // Action handler callbacks. | |
| 84 | + | onAction ActionHandler | |
| 85 | + | onVimScrollLifecycle VimScrollLifecycleHandler | |
| 86 | + | onPageFocusNavigation PageFocusNavigationHandler | |
| 63 | 87 | // Optional routing callback that determines how a key should be handled. | |
| 64 | 88 | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | |
| 65 | 89 | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | |
| @@ -68,6 +92,9 @@ type KeyboardHandler struct { | |||
| 68 | 92 | accentHandler AccentHandler | |
| 69 | 93 | // Optional escape hook for app-level overlays | |
| 70 | 94 | 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 | 99 | // GTK controller (nil until attached) | |
| 73 | 100 | controller *gtk.EventControllerKey | |
| @@ -82,6 +109,18 @@ type KeyboardHandler struct { | |||
| 82 | 109 | activePressedActions map[Action]uint | |
| 83 | 110 | ctx context.Context | |
| 84 | 111 | 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 | 126 | // NewKeyboardHandler creates a new keyboard handler. | |
| @@ -90,14 +129,26 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, | |||
| 90 | 129 | ||
| 91 | 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 | 137 | h := &KeyboardHandler{ | |
| 94 | - | shortcuts: NewShortcutSet(ctx, workspace, session), | |
| 138 | + | shortcuts: shortcuts, | |
| 95 | 139 | modal: NewModalState(ctx), | |
| 96 | 140 | workspace: workspace, | |
| 97 | 141 | session: session, | |
| 98 | 142 | activePressedActions: make(map[Action]uint), | |
| 99 | 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 | 153 | return h | |
| 103 | 154 | } | |
| @@ -109,11 +160,35 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) { | |||
| 109 | 160 | 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 | 187 | // ReloadShortcuts rebuilds the shortcut set from new config values. | |
| 113 | 188 | // This enables hot-reloading of keybindings without restarting. | |
| 114 | 189 | func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | |
| 190 | + | h.stopVimScrollRepeat() | |
| 115 | 191 | h.mu.Lock() | |
| 116 | - | defer h.mu.Unlock() | |
| 117 | 192 | ||
| 118 | 193 | log := logging.FromContext(ctx) | |
| 119 | 194 | log.Debug().Msg("reloading shortcuts from config") | |
| @@ -121,6 +196,14 @@ func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity | |||
| 121 | 196 | h.shortcuts = NewShortcutSet(ctx, workspace, session) | |
| 122 | 197 | h.workspace = workspace | |
| 123 | 198 | 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 | 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 | 217 | } else if from == ModeNormal { | |
| 135 | 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 | 225 | // Forward to app-level callback | |
| 138 | 226 | if fn != nil { | |
| 139 | 227 | fn(from, to) | |
| @@ -212,16 +300,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||
| 212 | 300 | // so app shortcuts still work. | |
| 213 | 301 | h.controller.SetPropagationPhase(gtk.PhaseBubbleValue) | |
| 214 | 302 | ||
| 215 | - | // Wire GTK main thread scheduler for modal timeouts. Timer goroutines | |
| 216 | - | // must dispatch ExitMode to the GTK thread because onModeChange may | |
| 217 | - | // call setControllerPhase (a GTK operation). | |
| 218 | - | h.modal.SetMainThreadScheduler(func(fn func()) { | |
| 219 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 220 | - | fn() | |
| 221 | - | return false | |
| 222 | - | }) | |
| 223 | - | glib.IdleAdd(&cb, 0) | |
| 224 | - | }) | |
| 303 | + | // Wire GTK main thread schedulers for modal and sequence ambiguity timeouts. | |
| 304 | + | // Timer goroutines must dispatch back to the GTK thread because callbacks | |
| 305 | + | // may touch GTK widgets / EventController phase. | |
| 306 | + | h.installGTKMainThreadSchedulers() | |
| 225 | 307 | ||
| 226 | 308 | // Connect key pressed handler (retain callback to prevent GC). | |
| 227 | 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 | 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 | 346 | // Detach removes the keyboard handler from a live GTK window and releases its | |
| 247 | 347 | // retained GTK signal callbacks. | |
| 248 | 348 | func (h *KeyboardHandler) Detach() { | |
| @@ -257,8 +357,11 @@ func (h *KeyboardHandler) DetachForDestroy() { | |||
| 257 | 357 | } | |
| 258 | 358 | ||
| 259 | 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 | 364 | h.mu.Lock() | |
| 261 | - | defer h.mu.Unlock() | |
| 262 | 365 | if h.controller != nil && h.keyPressedHandlerID != 0 { | |
| 263 | 366 | h.controller.DisconnectSignal(h.keyPressedHandlerID) | |
| 264 | 367 | } | |
| @@ -275,6 +378,7 @@ func (h *KeyboardHandler) detach(removeController bool) { | |||
| 275 | 378 | h.keyPressedHandlerID = 0 | |
| 276 | 379 | h.keyReleasedHandlerID = 0 | |
| 277 | 380 | h.activePressedActions = nil | |
| 381 | + | h.mu.Unlock() | |
| 278 | 382 | } | |
| 279 | 383 | ||
| 280 | 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 | 400 | modifiers := Modifier(state) & modifierMask | |
| 401 | + | mode := h.modal.Mode() | |
| 297 | 402 | ||
| 298 | 403 | // 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 | 405 | if onEscape != nil && onEscape(h.ctx) { | |
| 301 | 406 | 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 | 430 | // Determine routing for this key event | |
| 306 | 431 | route := RouteHandleShortcuts // default: process through shortcut system | |
| 307 | - | if routeKey != nil && h.modal.Mode() == ModeNormal { | |
| 432 | + | if routeKey != nil && mode == ModeNormal { | |
| 308 | 433 | route = routeKey(KeyContext{ | |
| 309 | 434 | Keyval: keyval, | |
| 310 | 435 | Keycode: keycode, | |
| @@ -332,28 +457,153 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 332 | 457 | // 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 | 465 | // --- Shortcut processing path --- | |
| 336 | 466 | ||
| 337 | 467 | // Normalize uppercase letters for consistent binding lookup | |
| 338 | 468 | keyval = normalizeKeyval(keyval) | |
| 339 | 469 | ||
| 340 | 470 | binding := KeyBinding{Keyval: keyval, Modifiers: modifiers} | |
| 341 | - | mode := h.modal.Mode() | |
| 342 | 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 | 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 | 488 | 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 | 496 | if h.suppressHeldAction(action, keyval) { | |
| 348 | 497 | log.Trace(). | |
| 349 | 498 | Str("action", string(action)). | |
| 350 | 499 | Msg("held keyboard action repeat suppressed") | |
| 351 | 500 | return true | |
| 352 | 501 | } | |
| 353 | - | ||
| 354 | 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 | 607 | // tryAccentDetection starts long-press detection for accent-eligible keys. | |
| 358 | 608 | // It is used only for the RouteAccentDetection route (GTK Entry widgets such as | |
| 359 | 609 | // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection; | |
| @@ -382,6 +632,43 @@ func normalizeKeyval(keyval uint) uint { | |||
| 382 | 632 | 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 | 672 | // dispatchAction dispatches the action and handles mode-related logic. | |
| 386 | 673 | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |
| 387 | 674 | if h.handleModeAction(action) { | |
| @@ -402,6 +689,9 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |||
| 402 | 689 | if mode == ModeResize && isResizeAction(action) { | |
| 403 | 690 | h.modal.ResetTimeout(h.ctx) | |
| 404 | 691 | } | |
| 692 | + | if mode == ModeVim && isVimScrollAction(action) { | |
| 693 | + | h.modal.ResetTimeout(h.ctx) | |
| 694 | + | } | |
| 405 | 695 | ||
| 406 | 696 | if mode != ModeNormal && ShouldAutoExitMode(action) { | |
| 407 | 697 | h.modal.ExitMode(h.ctx) | |
| @@ -436,6 +726,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool { | |||
| 436 | 726 | ActionEnterPaneMode, | |
| 437 | 727 | ActionEnterSessionMode, | |
| 438 | 728 | ActionEnterResizeMode, | |
| 729 | + | ActionEnterVimMode, | |
| 439 | 730 | ActionNewTab, | |
| 440 | 731 | ActionRenameTab, | |
| 441 | 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 | 820 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | |
| 516 | 821 | h.mu.RLock() | |
| 517 | 822 | workspace := h.workspace | |
| @@ -551,7 +856,22 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||
| 551 | 856 | } | |
| 552 | 857 | h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond) | |
| 553 | 858 | 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 | 871 | case ActionExitMode: | |
| 872 | + | if h.modal.Mode() == ModeVim { | |
| 873 | + | h.stopVimScrollRepeat() | |
| 874 | + | } | |
| 555 | 875 | h.modal.ExitMode(h.ctx) | |
| 556 | 876 | return true | |
| 557 | 877 | default: | |
| @@ -598,33 +918,64 @@ func (h *KeyboardHandler) EnterSessionMode() { | |||
| 598 | 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 | 934 | // ExitMode programmatically exits modal mode. | |
| 602 | 935 | // Useful for testing or programmatic mode changes. | |
| 603 | 936 | func (h *KeyboardHandler) ExitMode() { | |
| 604 | 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 | 950 | // DispatchAction processes an action externally triggered (e.g., by a global | |
| 608 | 951 | // shortcut). Mode-enter actions update modal state; other actions are forwarded | |
| 609 | - | // to the registered action handler. | |
| 610 | - | func (h *KeyboardHandler) DispatchAction(action Action) { | |
| 952 | + | // to the registered action handler. It returns whether the action was consumed. | |
| 953 | + | func (h *KeyboardHandler) DispatchAction(action Action) bool { | |
| 611 | 954 | mode := h.modal.Mode() | |
| 955 | + | if h.shouldPassthroughVimModeActivation(action, mode) { | |
| 956 | + | return false | |
| 957 | + | } | |
| 612 | 958 | h.dispatchAction(action, mode) | |
| 959 | + | return true | |
| 613 | 960 | } | |
| 614 | 961 | ||
| 615 | 962 | // handleKeyRelease processes a key release event for accent detection. | |
| 616 | 963 | func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | |
| 964 | + | keyval = normalizeKeyval(keyval) | |
| 617 | 965 | h.mu.Lock() | |
| 618 | 966 | accentHandler := h.accentHandler | |
| 619 | 967 | if len(h.activePressedActions) > 0 { | |
| 620 | - | keyval = normalizeKeyval(keyval) | |
| 621 | 968 | for action, pressedKeyval := range h.activePressedActions { | |
| 622 | 969 | if pressedKeyval == keyval { | |
| 623 | 970 | delete(h.activePressedActions, action) | |
| 624 | 971 | } | |
| 625 | 972 | } | |
| 626 | 973 | } | |
| 974 | + | stopVimScroll := h.vimScrollRepeatKeyval == keyval | |
| 627 | 975 | h.mu.Unlock() | |
| 976 | + | if stopVimScroll { | |
| 977 | + | h.stopVimScrollRepeat() | |
| 978 | + | } | |
| 628 | 979 | ||
| 629 | 980 | if accentHandler == nil { | |
| 630 | 981 | return | |
M internal/ui/input/keyboard_test.go +787 -0
| @@ -3,9 +3,11 @@ package input | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 9 | "github.com/bnema/puregotk/v4/gdk" | |
| 10 | + | "github.com/bnema/puregotk/v4/glib" | |
| 9 | 11 | "github.com/bnema/puregotk/v4/gtk" | |
| 10 | 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 | 36 | // stubAccentHandler is a test stub for the AccentHandler interface. | |
| 31 | 37 | type stubAccentHandler struct { | |
| 32 | 38 | onKeyPressedResult bool | |
| @@ -362,3 +368,784 @@ func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T | |||
| 362 | 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 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | type modalTimer interface { | |
| 12 | + | Stop() bool | |
| 13 | + | } | |
| 14 | + | ||
| 11 | 15 | // Mode represents the current input mode. | |
| 12 | 16 | type Mode int | |
| 13 | 17 | ||
| @@ -22,6 +26,8 @@ const ( | |||
| 22 | 26 | ModeSession | |
| 23 | 27 | // ModeResize is the modal pane resizing mode. | |
| 24 | 28 | ModeResize | |
| 29 | + | // ModeVim is the modal Vim-style scrolling mode. | |
| 30 | + | ModeVim | |
| 25 | 31 | ) | |
| 26 | 32 | ||
| 27 | 33 | // String returns a human-readable mode name. | |
| @@ -37,6 +43,8 @@ func (m Mode) String() string { | |||
| 37 | 43 | return "session" | |
| 38 | 44 | case ModeResize: | |
| 39 | 45 | return "resize" | |
| 46 | + | case ModeVim: | |
| 47 | + | return "vim" | |
| 40 | 48 | default: | |
| 41 | 49 | return "unknown" | |
| 42 | 50 | } | |
| @@ -53,6 +61,8 @@ func (m Mode) DisplayName() string { | |||
| 53 | 61 | return "SESSION MODE" | |
| 54 | 62 | case ModeResize: | |
| 55 | 63 | return "RESIZE MODE" | |
| 64 | + | case ModeVim: | |
| 65 | + | return "VIM MODE" | |
| 56 | 66 | default: | |
| 57 | 67 | return "" | |
| 58 | 68 | } | |
| @@ -62,9 +72,11 @@ func (m Mode) DisplayName() string { | |||
| 62 | 72 | type ModalState struct { | |
| 63 | 73 | mode Mode | |
| 64 | 74 | timeout time.Duration | |
| 65 | - | timer *time.Timer | |
| 75 | + | timer modalTimer | |
| 66 | 76 | 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 | 80 | // Callback for mode changes (called synchronously under lock). | |
| 69 | 81 | onModeChange func(from, to Mode) | |
| 70 | 82 | ||
| @@ -84,10 +96,16 @@ func NewModalState(ctx context.Context) *ModalState { | |||
| 84 | 96 | log.Debug().Msg("creating modal state") | |
| 85 | 97 | return &ModalState{ | |
| 86 | 98 | mode: ModeNormal, | |
| 99 | + | afterFunc: func(d time.Duration, fn func()) modalTimer { return time.AfterFunc(d, fn) }, | |
| 87 | 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 | 109 | // SetMainThreadScheduler sets the function used to dispatch timer callbacks | |
| 92 | 110 | // to the GTK main thread. In the real app this should be a glib.IdleAdd | |
| 93 | 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 | 241 | m.timerGen++ | |
| 224 | 242 | gen := m.timerGen | |
| 225 | 243 | schedule := m.scheduleOnMainThread | |
| 226 | - | m.timer = time.AfterFunc(timeout, func() { | |
| 244 | + | m.timer = m.afterFunc(timeout, func() { | |
| 227 | 245 | schedule(func() { | |
| 228 | 246 | // Ignore stale callback: time.Timer.Stop can race with the | |
| 229 | 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 | 8 | "strings" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/vimkeys" | |
| 11 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 13 | "github.com/bnema/puregotk/v4/gdk" | |
| 13 | 14 | ) | |
| @@ -190,7 +191,7 @@ const ( | |||
| 190 | 191 | ActionToggleFullscreen Action = "toggle_fullscreen" | |
| 191 | 192 | ActionToggleFloatingPane Action = "toggle_floating_pane" | |
| 192 | 193 | ActionToggleHistorySystemView Action = "toggle_history_systemview" | |
| 193 | - | ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview" | |
| 194 | + | ActionToggleFavoritesSidebar Action = "toggle_favorites_sidebar" | |
| 194 | 195 | ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite" | |
| 195 | 196 | ActionToggleConfigSystemView Action = "toggle_config_systemview" | |
| 196 | 197 | ||
| @@ -202,6 +203,15 @@ const ( | |||
| 202 | 203 | ||
| 203 | 204 | // Application | |
| 204 | 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 | 217 | const floatingProfileActionPrefix = "open_floating_profile:" | |
| @@ -222,6 +232,10 @@ type ShortcutSet struct { | |||
| 222 | 232 | SessionMode ShortcutTable | |
| 223 | 233 | // ResizeMode shortcuts are only active in resize mode. | |
| 224 | 234 | 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 | 241 | // NewShortcutSet creates a ShortcutSet from workspace and session configuration. | |
| @@ -235,6 +249,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||
| 235 | 249 | PaneMode: make(ShortcutTable), | |
| 236 | 250 | SessionMode: make(ShortcutTable), | |
| 237 | 251 | ResizeMode: make(ShortcutTable), | |
| 252 | + | VimMode: make(ShortcutTable), | |
| 238 | 253 | } | |
| 239 | 254 | ||
| 240 | 255 | set.buildGlobalShortcutsFromParts(ctx, workspace, session) | |
| @@ -242,6 +257,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||
| 242 | 257 | set.buildTabModeShortcuts(ctx, workspace) | |
| 243 | 258 | set.buildPaneModeShortcuts(ctx, workspace) | |
| 244 | 259 | set.buildResizeModeShortcuts(ctx, workspace) | |
| 260 | + | set.buildVimModeShortcuts(ctx, workspace) | |
| 245 | 261 | } | |
| 246 | 262 | if session != nil { | |
| 247 | 263 | set.buildSessionModeShortcuts(ctx, session) | |
| @@ -253,6 +269,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||
| 253 | 269 | Int("pane", len(set.PaneMode)). | |
| 254 | 270 | Int("resize", len(set.ResizeMode)). | |
| 255 | 271 | Int("session", len(set.SessionMode)). | |
| 272 | + | Int("vim", len(set.VimMode)). | |
| 256 | 273 | Msg("shortcuts registered") | |
| 257 | 274 | ||
| 258 | 275 | return set | |
| @@ -288,6 +305,30 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity. | |||
| 288 | 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 | 332 | func (s *ShortcutSet) registerActivationShortcutsFromParts( | |
| 292 | 333 | ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig, | |
| 293 | 334 | ) { | |
| @@ -308,6 +349,16 @@ func (s *ShortcutSet) registerActivationShortcutsFromParts( | |||
| 308 | 349 | } | |
| 309 | 350 | 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 | 362 | if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok { | |
| 312 | 363 | s.Global[binding] = ActionEnterTabMode | |
| 313 | 364 | log.Trace(). | |
| @@ -568,8 +619,8 @@ var configActionToAction = map[string]Action{ | |||
| 568 | 619 | "toggle-floating-pane": ActionToggleFloatingPane, | |
| 569 | 620 | "toggle_history_systemview": ActionToggleHistorySystemView, | |
| 570 | 621 | "toggle-history-systemview": ActionToggleHistorySystemView, | |
| 571 | - | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |
| 572 | - | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |
| 622 | + | "toggle_favorites_sidebar": ActionToggleFavoritesSidebar, | |
| 623 | + | "toggle-favorites-sidebar": ActionToggleFavoritesSidebar, | |
| 573 | 624 | "toggle_current_page_favorite": ActionToggleCurrentPageFavorite, | |
| 574 | 625 | "toggle-current-page-favorite": ActionToggleCurrentPageFavorite, | |
| 575 | 626 | "toggle_config_systemview": ActionToggleConfigSystemView, | |
| @@ -642,6 +693,20 @@ var configActionToAction = map[string]Action{ | |||
| 642 | 693 | ||
| 643 | 694 | // Session actions | |
| 644 | 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 | 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 | 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 | 862 | func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |
| 796 | 863 | // Normalize the binding modifiers | |
| 797 | 864 | binding.Modifiers &= modifierMask | |
| @@ -807,12 +874,17 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |||
| 807 | 874 | modeTable = s.ResizeMode | |
| 808 | 875 | case ModeSession: | |
| 809 | 876 | modeTable = s.SessionMode | |
| 877 | + | case ModeVim: | |
| 878 | + | modeTable = s.VimMode | |
| 810 | 879 | } | |
| 811 | 880 | ||
| 812 | 881 | if modeTable != nil { | |
| 813 | 882 | if action, ok := modeTable[binding]; ok { | |
| 814 | 883 | return action, true | |
| 815 | 884 | } | |
| 885 | + | if mode == ModeVim { | |
| 886 | + | return "", false | |
| 887 | + | } | |
| 816 | 888 | } | |
| 817 | 889 | ||
| 818 | 890 | // Fall back to global | |
M internal/ui/input/shortcuts_test.go +143 -2
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | package input | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 6 | 8 | "github.com/bnema/puregotk/v4/gdk" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| @@ -401,7 +403,7 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) { | |||
| 401 | 403 | want Action | |
| 402 | 404 | }{ | |
| 403 | 405 | {name: "toggle_history_systemview", want: ActionToggleHistorySystemView}, | |
| 404 | - | {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView}, | |
| 406 | + | {name: "toggle_favorites_sidebar", want: ActionToggleFavoritesSidebar}, | |
| 405 | 407 | {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite}, | |
| 406 | 408 | {name: "toggle_config_systemview", want: ActionToggleConfigSystemView}, | |
| 407 | 409 | } | |
| @@ -421,7 +423,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) { | |||
| 421 | 423 | want Action | |
| 422 | 424 | }{ | |
| 423 | 425 | {name: "toggle-history-systemview", want: ActionToggleHistorySystemView}, | |
| 424 | - | {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView}, | |
| 426 | + | {name: "toggle-favorites-sidebar", want: ActionToggleFavoritesSidebar}, | |
| 425 | 427 | {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite}, | |
| 426 | 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 | 56 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | |
| 55 | 57 | // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings. | |
| 56 | 58 | // Uses default mode colors. | |
| @@ -58,9 +60,16 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st | |||
| 58 | 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 | 65 | // Text scaling is handled via GtkSettings; CSS here stays scale-independent. | |
| 63 | 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 | 73 | defaults := DefaultFontConfig() | |
| 65 | 74 | fonts = FontConfig{ | |
| 66 | 75 | SansFont: Coalesce(fonts.SansFont, defaults.SansFont), | |
| @@ -102,6 +111,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo | |||
| 102 | 111 | sb.WriteString(generateStackedPaneCSS(p)) | |
| 103 | 112 | sb.WriteString("\n") | |
| 104 | 113 | ||
| 114 | + | // Vim mode styling | |
| 115 | + | sb.WriteString(generateVimModeCSS(transitionDurationMs)) | |
| 116 | + | sb.WriteString("\n") | |
| 117 | + | ||
| 105 | 118 | // Progress bar styling | |
| 106 | 119 | sb.WriteString(generateProgressBarCSS(p)) | |
| 107 | 120 | sb.WriteString("\n") | |
| @@ -146,12 +159,20 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo | |||
| 146 | 159 | sb.WriteString(generateAccentPickerCSS(p)) | |
| 147 | 160 | sb.WriteString("\n") | |
| 148 | 161 | ||
| 149 | - | // History sidebar styling | |
| 150 | - | sb.WriteString(generateHistorySidebarCSS(p)) | |
| 162 | + | appendNativeSidebarCSS(&sb, p) | |
| 151 | 163 | ||
| 152 | 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 | 176 | // generateTabBarCSS creates tab bar styles. | |
| 156 | 177 | // Uses em units for scalable UI. | |
| 157 | 178 | func generateTabBarCSS(p Palette) string { | |
| @@ -572,6 +593,70 @@ entry.find-bar-entry:focus-visible { | |||
| 572 | 593 | ||
| 573 | 594 | // generatePaneCSS creates pane border styles. | |
| 574 | 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 | 660 | func generatePaneCSS(p Palette) string { | |
| 576 | 661 | return `/* ===== Pane Styling ===== */ | |
| 577 | 662 | ||
| @@ -1130,6 +1215,11 @@ func generateToasterCSS(p Palette) string { | |||
| 1130 | 1215 | color: #ffffff; | |
| 1131 | 1216 | } | |
| 1132 | 1217 | ||
| 1218 | + | .toast-vim-mode { | |
| 1219 | + | background-color: var(--pane-mode-color); | |
| 1220 | + | color: #ffffff; | |
| 1221 | + | } | |
| 1222 | + | ||
| 1133 | 1223 | .toast-tab-mode { | |
| 1134 | 1224 | background-color: var(--tab-mode-color); | |
| 1135 | 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 | 47 | func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) { | |
| 38 | 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 | 4 | func generateHistorySidebarCSS(_ Palette) string { | |
| 5 | 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 | 7 | .history-sidebar-group-header { | |
| 28 | 8 | padding: 0.25em 0.625em; | |
| 29 | 9 | padding-top: 0.375em; | |
| @@ -36,37 +16,6 @@ func generateHistorySidebarCSS(_ Palette) string { | |||
| 36 | 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 | 19 | .history-sidebar-row-time { | |
| 71 | 20 | font-size: 0.68em; | |
| 72 | 21 | color: var(--muted); | |
| @@ -74,13 +23,6 @@ func generateHistorySidebarCSS(_ Palette) string { | |||
| 74 | 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 | 26 | .history-sidebar-loading { | |
| 85 | 27 | padding: 1.5em 0.75em; | |
| 86 | 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 | 12 | css := generateHistorySidebarCSS(DefaultDarkPalette()) | |
| 13 | 13 | ||
| 14 | 14 | selectors := []string{ | |
| 15 | - | ".history-sidebar-outer", | |
| 16 | - | ".history-sidebar-search-box", | |
| 17 | - | ".history-sidebar-search", | |
| 18 | - | ".history-sidebar-groups", | |
| 19 | 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 | 16 | ".history-sidebar-row-time", | |
| 27 | - | ".history-sidebar-empty", | |
| 28 | 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) { | |
| 38 | - | css := generateHistorySidebarCSS(DefaultDarkPalette()) | |
| 26 | + | func TestGenerateSidebarCSS_AccentAlphaInterpolation(t *testing.T) { | |
| 27 | + | css := generateSidebarCSS(DefaultDarkPalette()) | |
| 39 | 28 | ||
| 40 | 29 | expectedAlpha := "alpha(var(--accent), 0.18)" | |
| 41 | 30 | assert.Contains(t, css, expectedAlpha, | |
| @@ -60,9 +49,9 @@ func TestGenerateHistorySidebarCSS_DeterministicOutput(t *testing.T) { | |||
| 60 | 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) { | |
| 64 | - | darkCSS := generateHistorySidebarCSS(DefaultDarkPalette()) | |
| 65 | - | lightCSS := generateHistorySidebarCSS(DefaultLightPalette()) | |
| 52 | + | func TestGenerateSidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) { | |
| 53 | + | darkCSS := generateSidebarCSS(DefaultDarkPalette()) | |
| 54 | + | lightCSS := generateSidebarCSS(DefaultLightPalette()) | |
| 66 | 55 | ||
| 67 | 56 | assert.Equal(t, darkCSS, lightCSS, | |
| 68 | 57 | "history sidebar fragment should be palette-independent when it relies on CSS variables") | |
| @@ -73,12 +62,12 @@ func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t | |||
| 73 | 62 | func TestGenerateHistorySidebarCSS_ThroughGenerateCSS(t *testing.T) { | |
| 74 | 63 | fullCSS := GenerateCSS(DefaultDarkPalette()) | |
| 75 | 64 | ||
| 76 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | |
| 77 | - | "full CSS must contain history sidebar selectors") | |
| 65 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", | |
| 66 | + | "full CSS must contain shared sidebar selectors") | |
| 78 | 67 | assert.Contains(t, fullCSS, ".history-sidebar-group-header {", | |
| 79 | 68 | "full CSS must contain group header selector") | |
| 80 | - | assert.Contains(t, fullCSS, ".history-sidebar-row-title {", | |
| 81 | - | "full CSS must contain row title selector") | |
| 69 | + | assert.Contains(t, fullCSS, ".sidebar-row-title {", | |
| 70 | + | "full CSS must contain shared row title selector") | |
| 82 | 71 | assert.Contains(t, fullCSS, "History Sidebar Styling", | |
| 83 | 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 | 93 | customPalette := Palette{ | |
| 105 | 94 | Background: "#111111", | |
| 106 | 95 | Surface: "#222222", | |
| @@ -114,22 +103,28 @@ func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) | |||
| 114 | 103 | Destructive: "#cc2222", | |
| 115 | 104 | } | |
| 116 | 105 | ||
| 117 | - | css := generateHistorySidebarCSS(customPalette) | |
| 106 | + | css := generateSidebarCSS(customPalette) | |
| 118 | 107 | ||
| 119 | 108 | assert.Contains(t, css, "alpha(var(--accent), 0.18)", | |
| 120 | 109 | "sidebar CSS should use the shared accent variable") | |
| 121 | 110 | assert.NotContains(t, css, "#ff6600", | |
| 122 | 111 | "sidebar fragment should not inline palette-specific accent values") | |
| 123 | 112 | ||
| 124 | - | assert.Contains(t, css, ".history-sidebar-outer {") | |
| 125 | - | assert.Contains(t, css, ".history-sidebar-row-title {") | |
| 126 | - | assert.Contains(t, css, ".history-sidebar-row-subtitle {") | |
| 127 | - | assert.Contains(t, css, ".history-sidebar-row-time {") | |
| 128 | - | assert.Contains(t, css, ".history-sidebar-empty {") | |
| 113 | + | assert.Contains(t, css, ".sidebar-outer {") | |
| 114 | + | assert.Contains(t, css, ".sidebar-row-title {") | |
| 115 | + | assert.Contains(t, css, ".sidebar-row-subtitle {") | |
| 116 | + | assert.Contains(t, css, ".sidebar-empty {") | |
| 129 | 117 | } | |
| 130 | 118 | ||
| 131 | - | func TestGenerateHistorySidebarCSS_ContainsTransition(t *testing.T) { | |
| 132 | - | css := generateHistorySidebarCSS(DefaultDarkPalette()) | |
| 119 | + | func TestGenerateSidebarCSS_UsesCompactBalancedSearchChrome(t *testing.T) { | |
| 120 | + | css := generateSidebarCSS(DefaultDarkPalette()) | |
| 121 | + | ||
| 122 | + | assert.Contains(t, css, ".sidebar-search-box {\n\tpadding: 0.5em 0.5em 0.375em;") | |
| 123 | + | assert.Contains(t, css, ".sidebar-search {\n\tmin-height: 0;\n\tpadding: 0.125em 0.375em;") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestGenerateSidebarCSS_ContainsTransition(t *testing.T) { | |
| 127 | + | css := generateSidebarCSS(DefaultDarkPalette()) | |
| 133 | 128 | ||
| 134 | 129 | assert.Contains(t, css, "transition:", | |
| 135 | 130 | "CSS should include transition property for smooth hover effects") | |
| @@ -149,27 +144,27 @@ func TestGenerateHistorySidebarCSS_ContainsUppercaseGroupHeader(t *testing.T) { | |||
| 149 | 144 | func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) { | |
| 150 | 145 | fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | |
| 151 | 146 | ||
| 152 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | |
| 153 | - | "GenerateCSSFull must include history sidebar outer selector for dark palette") | |
| 154 | - | assert.Contains(t, fullCSS, ".history-sidebar-row-title {", | |
| 155 | - | "GenerateCSSFull must include history sidebar row title selector for dark palette") | |
| 156 | - | assert.Contains(t, fullCSS, ".history-sidebar-search {", | |
| 157 | - | "GenerateCSSFull must include history sidebar search selector for dark palette") | |
| 158 | - | assert.Contains(t, fullCSS, ".history-sidebar-empty {", | |
| 159 | - | "GenerateCSSFull must include history sidebar empty selector for dark palette") | |
| 147 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", | |
| 148 | + | "GenerateCSSFull must include shared sidebar outer selector for dark palette") | |
| 149 | + | assert.Contains(t, fullCSS, ".sidebar-row-title {", | |
| 150 | + | "GenerateCSSFull must include shared sidebar row title selector for dark palette") | |
| 151 | + | assert.Contains(t, fullCSS, ".sidebar-search {", | |
| 152 | + | "GenerateCSSFull must include shared sidebar search selector for dark palette") | |
| 153 | + | assert.Contains(t, fullCSS, ".sidebar-empty {", | |
| 154 | + | "GenerateCSSFull must include shared sidebar empty selector for dark palette") | |
| 160 | 155 | } | |
| 161 | 156 | ||
| 162 | 157 | func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) { | |
| 163 | 158 | fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | |
| 164 | 159 | ||
| 165 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | |
| 166 | - | "GenerateCSSFull must include history sidebar outer selector for light palette") | |
| 167 | - | assert.Contains(t, fullCSS, ".history-sidebar-row-title {", | |
| 168 | - | "GenerateCSSFull must include history sidebar row title selector for light palette") | |
| 169 | - | assert.Contains(t, fullCSS, ".history-sidebar-search {", | |
| 170 | - | "GenerateCSSFull must include history sidebar search selector for light palette") | |
| 171 | - | assert.Contains(t, fullCSS, ".history-sidebar-empty {", | |
| 172 | - | "GenerateCSSFull must include history sidebar empty selector for light palette") | |
| 160 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", | |
| 161 | + | "GenerateCSSFull must include shared sidebar outer selector for light palette") | |
| 162 | + | assert.Contains(t, fullCSS, ".sidebar-row-title {", | |
| 163 | + | "GenerateCSSFull must include shared sidebar row title selector for light palette") | |
| 164 | + | assert.Contains(t, fullCSS, ".sidebar-search {", | |
| 165 | + | "GenerateCSSFull must include shared sidebar search selector for light palette") | |
| 166 | + | assert.Contains(t, fullCSS, ".sidebar-empty {", | |
| 167 | + | "GenerateCSSFull must include shared sidebar empty selector for light palette") | |
| 173 | 168 | } | |
| 174 | 169 | ||
| 175 | 170 | func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) { | |
| @@ -181,8 +176,8 @@ func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T | |||
| 181 | 176 | lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | |
| 182 | 177 | ||
| 183 | 178 | // Both must contain history sidebar selectors. | |
| 184 | - | assert.Contains(t, darkCSS, ".history-sidebar-outer {", "dark CSS must have sidebar styles") | |
| 185 | - | assert.Contains(t, lightCSS, ".history-sidebar-outer {", "light CSS must have sidebar styles") | |
| 179 | + | assert.Contains(t, darkCSS, ".sidebar-outer {", "dark CSS must have shared sidebar styles") | |
| 180 | + | assert.Contains(t, lightCSS, ".sidebar-outer {", "light CSS must have shared sidebar styles") | |
| 186 | 181 | ||
| 187 | 182 | // The CSS must differ between palettes (different color values). | |
| 188 | 183 | if darkCSS == lightCSS { | |
| @@ -211,14 +206,14 @@ func TestGenerateHistorySidebarCSS_InGenerateCSSFull_WithCustomScaleAndFonts(t * | |||
| 211 | 206 | ||
| 212 | 207 | fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors) | |
| 213 | 208 | ||
| 214 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | |
| 215 | - | "GenerateCSSFull with custom scale/fonts must include history sidebar selectors") | |
| 216 | - | assert.Contains(t, fullCSS, ".history-sidebar-row:hover {", | |
| 217 | - | "GenerateCSSFull must include hover state for history rows") | |
| 218 | - | assert.Contains(t, fullCSS, ".history-sidebar-row:selected {", | |
| 219 | - | "GenerateCSSFull must include selected state for history rows") | |
| 220 | - | assert.Contains(t, fullCSS, ".history-sidebar-row:focus {", | |
| 221 | - | "GenerateCSSFull must include focus state for history rows") | |
| 209 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", | |
| 210 | + | "GenerateCSSFull with custom scale/fonts must include shared sidebar selectors") | |
| 211 | + | assert.Contains(t, fullCSS, ".sidebar-row:hover {", | |
| 212 | + | "GenerateCSSFull must include hover state for shared sidebar rows") | |
| 213 | + | assert.Contains(t, fullCSS, ".sidebar-row:selected {", | |
| 214 | + | "GenerateCSSFull must include selected state for shared sidebar rows") | |
| 215 | + | assert.Contains(t, fullCSS, ".sidebar-row:focus {", | |
| 216 | + | "GenerateCSSFull must include focus state for shared sidebar rows") | |
| 222 | 217 | } | |
| 223 | 218 | ||
| 224 | 219 | // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the | |
| @@ -247,21 +242,21 @@ func TestGenerateHistorySidebarCSS_LiveReloadFullPath(t *testing.T) { | |||
| 247 | 242 | lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors()) | |
| 248 | 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 | 246 | sidebarSelectors := []string{ | |
| 252 | - | ".history-sidebar-outer", | |
| 253 | - | ".history-sidebar-search-box", | |
| 254 | - | ".history-sidebar-search", | |
| 255 | - | ".history-sidebar-groups", | |
| 247 | + | ".sidebar-outer", | |
| 248 | + | ".sidebar-search-box", | |
| 249 | + | ".sidebar-search", | |
| 250 | + | ".sidebar-list", | |
| 251 | + | ".sidebar-row", | |
| 252 | + | ".sidebar-row:hover", | |
| 253 | + | ".sidebar-row:selected", | |
| 254 | + | ".sidebar-row:focus", | |
| 255 | + | ".sidebar-row-title", | |
| 256 | + | ".sidebar-row-subtitle", | |
| 257 | + | ".sidebar-empty", | |
| 256 | 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 | 259 | ".history-sidebar-row-time", | |
| 264 | - | ".history-sidebar-empty", | |
| 265 | 260 | ".history-sidebar-loading", | |
| 266 | 261 | } | |
| 267 | 262 | ||
| @@ -324,9 +319,9 @@ func TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch(t *testing.T) { | |||
| 324 | 319 | assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n") | |
| 325 | 320 | } | |
| 326 | 321 | ||
| 327 | - | // History sidebar selectors must be present in both. | |
| 328 | - | assert.Contains(t, cssBefore, ".history-sidebar-row:selected {") | |
| 329 | - | assert.Contains(t, cssAfter, ".history-sidebar-row:selected {") | |
| 322 | + | // Shared sidebar selectors must be present in both. | |
| 323 | + | assert.Contains(t, cssBefore, ".sidebar-row:selected {") | |
| 324 | + | assert.Contains(t, cssAfter, ".sidebar-row:selected {") | |
| 330 | 325 | ||
| 331 | 326 | // The before and after CSS must be different (palette changed). | |
| 332 | 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 | 15 | // Manager handles theme state and CSS application. | |
| 16 | 16 | // Theme precedence and fallback are resolved before data reaches this adapter. | |
| 17 | 17 | type Manager struct { | |
| 18 | - | prefersDark bool // Resolved dark mode preference | |
| 19 | - | lightPalette Palette // Light theme colors | |
| 20 | - | darkPalette Palette // Dark theme colors | |
| 21 | - | uiScale float64 // UI scaling factor (1.0 = 100%) | |
| 22 | - | fonts FontConfig | |
| 23 | - | gtkFont string | |
| 24 | - | modeColors ModeColors // Modal mode indicator colors | |
| 25 | - | cssProvider *gtk.CssProvider | |
| 26 | - | appliedFont string | |
| 18 | + | prefersDark bool // Resolved dark mode preference | |
| 19 | + | lightPalette Palette // Light theme colors | |
| 20 | + | darkPalette Palette // Dark theme colors | |
| 21 | + | uiScale float64 // UI scaling factor (1.0 = 100%) | |
| 22 | + | fonts FontConfig | |
| 23 | + | gtkFont string | |
| 24 | + | modeColors ModeColors // Modal mode indicator colors | |
| 25 | + | transitionDurationMs int | |
| 26 | + | cssProvider *gtk.CssProvider | |
| 27 | + | appliedFont string | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | // NewManager creates a new theme manager from an already-resolved theme. | |
| 30 | 31 | func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager { | |
| 31 | 32 | log := logging.FromContext(ctx) | |
| 32 | 33 | ||
| 33 | - | m := &Manager{} | |
| 34 | + | m := &Manager{transitionDurationMs: defaultTransitionDurationMs} | |
| 34 | 35 | m.applyResolvedTheme(resolved) | |
| 35 | 36 | ||
| 36 | 37 | log.Debug(). | |
| @@ -60,6 +61,15 @@ func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) { | |||
| 60 | 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 | 73 | // PrefersDark returns true if dark mode is active. | |
| 64 | 74 | func (m *Manager) PrefersDark() bool { | |
| 65 | 75 | return m.prefersDark | |
| @@ -135,9 +145,10 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||
| 135 | 145 | return | |
| 136 | 146 | } | |
| 137 | 147 | ||
| 138 | - | // Generate CSS with current palette, UI scale, fonts, and mode colors | |
| 148 | + | // Generate CSS with current palette, UI scale, fonts, mode colors, and the | |
| 149 | + | // configured transition duration. | |
| 139 | 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 | 152 | fontName := formatGTKFontName(m.gtkFont, m.uiScale) | |
| 142 | 153 | ||
| 143 | 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 | + | } | |