detached f329585

Merge pull request 'feat(vim): add link hints, visual selection, and text-object yanks' (#1) from feat/vim-hints-visual-yank into main

commit
f329585a3c36a447c2f60604370d36ca7c1a8b0f
author
bnema · · 2026-10-10 08:54 UTC
merge of
aefccc8 + a877e40 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
37 files · +4380 -66
M .github/workflows/ci.yml +2 -1
@@ -106,7 +106,7 @@ jobs:
106 - name: Install dependencies106 - name: Install dependencies
107 run: |107 run: |
108 for attempt in 1 2 3; do108 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; then109+ if pacman -Syu --noconfirm && pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell libadwaita gobject-introspection base-devel brotli dbus nodejs; then
110 exit 0110 exit 0
111 fi111 fi
112 if [ "$attempt" -eq 3 ]; then112 if [ "$attempt" -eq 3 ]; then
@@ -141,6 +141,7 @@ jobs:
141 GDK_BACKEND: wayland141 GDK_BACKEND: wayland
142 WAYLAND_DISPLAY: wayland-0142 WAYLAND_DISPLAY: wayland-0
143 DUMBER_REQUIRE_SYSTEMVIEWS_WASM: 1143 DUMBER_REQUIRE_SYSTEMVIEWS_WASM: 1
144+ DUMBER_REQUIRE_NODE: 1
144 run: go test -v ./...145 run: go test -v ./...
145 - name: Verify purego-only constraints146 - name: Verify purego-only constraints
146 run: make verify-purego147 run: make verify-purego
M .mockery.yaml +1 -0
@@ -61,6 +61,7 @@ packages:
61 PageNavigator: {}61 PageNavigator: {}
62 WebView: {}62 WebView: {}
63 PageScrollable: {}63 PageScrollable: {}
64+ VimPageInteractor: {}
64 WebViewFactory: {}65 WebViewFactory: {}
65 WebViewPool: {}66 WebViewPool: {}
66 MemoryPressureApplier: {}67 MemoryPressureApplier: {}
M CHANGELOG.md +1 -0
@@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
9 - **Named browser windows**: Added `dumber browse --instance <name>` to open or focus a stable named window in the current CEF profile host. Named windows share cookies, tokens, local storage, configuration, history, and favorites; use `--profile` for a separate persistent browser profile/host or `--ephemeral` for a temporary isolated profile/host.9 - **Named browser windows**: Added `dumber browse --instance <name>` to open or focus a stable named window in the current CEF profile host. Named windows share cookies, tokens, local storage, configuration, history, and favorites; use `--profile` for a separate persistent browser profile/host or `--ephemeral` for a temporary isolated profile/host.
10 - **Omnibox visual styles**: Added configurable `command`, `multiplexer`, and `minimal` GTK treatments. The terminal-inspired `command` palette is the default.10 - **Omnibox visual styles**: Added configurable `command`, `multiplexer`, and `minimal` GTK treatments. The terminal-inspired `command` palette is the default.
11 - **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. Pressing `Enter` on a selected CEF heading opens its associated link before leaving Vim Mode. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections.11 - **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. Pressing `Enter` on a selected CEF heading opens its associated link before leaving Vim Mode. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections.
12+- **Vim Mode link hints, text yanks, and visual selection**: `f` labels the visible links and controls and activates the one you pick, `F` opens a link in a new pane, and `yf` copies a link URL. `yah`, `yap`, `yac`, `yat`, and `yal` copy the current section or the first visible paragraph, code block, table, or list. `v` starts text-anchor hints (or uses a visible selection), places a visible caret, and `v`/`V` select characters or lines with Vim motions; `y` copies and returns to the caret. The mode indicator shows `HINTS`, `CARET`, `VISUAL`, or `VISUAL LINE`. While the page owns the keys, the Vim Mode toggle shortcut (`Ctrl+Y` by default) always leaves Vim Mode and ends the interaction.
12 - **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.13 - **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.
13 - **WebP favicon decoding**: Added safe WebP decoding for sites that publish WebP favicons.14 - **WebP favicon decoding**: Added safe WebP decoding for sites that publish WebP favicons.
14 15
M docs/config/index.md +37 -0
@@ -463,6 +463,42 @@ desc = "Jump to next heading"
463 keys = ["[["]463 keys = ["[["]
464 desc = "Jump to previous heading"464 desc = "Jump to previous heading"
465 465
466+[workspace.vim_mode.actions.hint-follow]
467+keys = ["f"]
468+desc = "Show hints and follow a link or control"
469+
470+[workspace.vim_mode.actions.hint-follow-new]
471+keys = ["F"]
472+desc = "Show hints and open a link in a new pane"
473+
474+[workspace.vim_mode.actions.hint-yank-url]
475+keys = ["yf"]
476+desc = "Show hints and yank a link URL"
477+
478+[workspace.vim_mode.actions.visual]
479+keys = ["v"]
480+desc = "Start visual selection (motions extend, y yanks)"
481+
482+[workspace.vim_mode.actions.yank-section]
483+keys = ["yah"]
484+desc = "Yank current section"
485+
486+[workspace.vim_mode.actions.yank-paragraph]
487+keys = ["yap"]
488+desc = "Yank first visible paragraph"
489+
490+[workspace.vim_mode.actions.yank-code]
491+keys = ["yac"]
492+desc = "Yank first visible code block"
493+
494+[workspace.vim_mode.actions.yank-table]
495+keys = ["yat"]
496+desc = "Yank first visible table"
497+
498+[workspace.vim_mode.actions.yank-list]
499+keys = ["yal"]
500+desc = "Yank first visible list"
501+
466 [workspace.vim_mode.actions.confirm]502 [workspace.vim_mode.actions.confirm]
467 keys = ["enter"]503 keys = ["enter"]
468 desc = "Open selected heading link and exit mode"504 desc = "Open selected heading link and exit mode"
@@ -479,6 +515,7 @@ Notes:
479 - `confirm` opens the first link inside the selected heading, or a link wrapping that heading, then exits Vim Mode. If the selected heading has no link, it only exits the mode.515 - `confirm` opens the first link inside the selected heading, or a link wrapping that heading, then exits Vim Mode. If the selected heading has no link, it only exits the mode.
480 - 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.516 - 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.
481 - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active.517 - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active.
518+- `f`, `F`, and `yf` label visible links; `yah`, `yap`, `yac`, `yat`, and `yal` copy a section, paragraph, code block, table, or list; `v` starts caret and visual selection. While a hint or visual interaction runs, the page owns every key except the activation shortcut (`activation_shortcut`, `Ctrl+Y` by default), which always leaves Vim Mode and ends the interaction, whatever the page does. These actions use the CEF engine path.
482 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.519 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.
483 - Live input, heading, heading-link activation, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.520 - Live input, heading, heading-link activation, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.
484 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.521 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.
M docs/reference/keybindings.md +42 -0
@@ -64,6 +64,16 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
64 64
65 `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. `Enter` activates the first link inside the selected heading, or a link wrapping that heading, then leaves Vim Mode; without an associated link, it only leaves the mode. `Escape` leaves without activation. 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, activation, and page-focus actions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic actions yet.65 `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. `Enter` activates the first link inside the selected heading, or a link wrapping that heading, then leaves Vim Mode; without an associated link, it only leaves the mode. `Escape` leaves without activation. 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, activation, and page-focus actions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic actions yet.
66 66
67+Link hints label the visible targets in the viewport; type a label to pick one, `Backspace` to undo a letter, and `Escape` to close the hints and stay in Vim Mode. `f` follows a link or activates a control, `F` opens a link in a new pane, and `yf` copies a link URL.
68+
69+`v` starts visual selection. A non-empty selection that is visible in the viewport is used as is. Otherwise text-anchor hints label the visible text blocks; type a label to put the caret at the start of that text, or press `Escape` to place it at the first visible text of at least 50 characters (any visible text if none is that long). The mode indicator follows the page state: `VIM MODE · HINTS`, `· CARET`, `· VISUAL`, `· VISUAL LINE`.
70+
71+In caret mode a large blinking accent cursor marks the position and motions move it without selecting: `h/j/k/l`, `w/b/e`, `0`/`^`/`$`, `(`/`)` for sentences, `{`/`}` for paragraphs, `gg`/`G` for the document edges, and a count prefix such as `3w`. The view scrolls to keep the cursor visible. `v` starts a character selection from the caret and `V` selects whole lines; in both, the same motions extend the selection and `o` swaps its ends. `v` in a selection returns to the caret, `V` in a character selection switches to lines, and `v` in a line selection switches to characters. `y` or `Enter` copies the selection and returns to the caret at its end, so you can keep moving and copy again. `Escape` steps back from a selection to the caret, and `Escape` in caret mode ends visual selection and leaves plain Vim Mode.
72+
73+Text-object yanks copy one block without selecting it: `yah` copies the current section (the heading nearest the top of the viewport through the next heading of the same or a higher level), and `yap`, `yac`, `yat`, and `yal` copy the first visible paragraph, code block, table, or list. The copied block flashes with the theme accent. Hints, visual selection, and yanks use the CEF engine path.
74+
75+While hints or visual selection are active, the page owns every key; the Vim Mode toggle shortcut (`Ctrl+Y` by default, or your `activation_shortcut`) is never forwarded to it. Pressing it always leaves Vim Mode and ends the interaction, so it is the way out even if a page misbehaves. Caret and visual selection anchor only to text in the page's main document: text inside shadow roots (some web components) is not offered as a caret position, while link hints and text-object yanks still reach into shadow DOM.
76+
67 | Action | Keys |77 | Action | Keys |
68 |--------|------|78 |--------|------|
69 | Scroll left | `H` |79 | Scroll left | `H` |
@@ -76,6 +86,11 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
76 | Next heading | `]]` |86 | Next heading | `]]` |
77 | Previous heading | `[[` |87 | Previous heading | `[[` |
78 | Page focus traversal | `Tab`, `Shift+Tab` |88 | Page focus traversal | `Tab`, `Shift+Tab` |
89+| Follow link or control (hints) | `f` |
90+| Open link in new pane (hints) | `F` |
91+| Yank link URL (hints) | `yf` |
92+| Visual selection (caret, character, line) | `v`, `V` in caret mode |
93+| Yank section / paragraph / code / table / list | `yah`, `yap`, `yac`, `yat`, `yal` |
79 | Activate selected heading link and exit | `Enter` |94 | Activate selected heading link and exit | `Enter` |
80 | Exit without activation | `Escape` |95 | Exit without activation | `Escape` |
81 96
@@ -225,6 +240,33 @@ keys = ["]]"]
225 [workspace.vim_mode.actions.heading-prev]240 [workspace.vim_mode.actions.heading-prev]
226 keys = ["[["]241 keys = ["[["]
227 242
243+[workspace.vim_mode.actions.hint-follow]
244+keys = ["f"]
245+
246+[workspace.vim_mode.actions.hint-follow-new]
247+keys = ["F"]
248+
249+[workspace.vim_mode.actions.hint-yank-url]
250+keys = ["yf"]
251+
252+[workspace.vim_mode.actions.visual]
253+keys = ["v"]
254+
255+[workspace.vim_mode.actions.yank-section]
256+keys = ["yah"]
257+
258+[workspace.vim_mode.actions.yank-paragraph]
259+keys = ["yap"]
260+
261+[workspace.vim_mode.actions.yank-code]
262+keys = ["yac"]
263+
264+[workspace.vim_mode.actions.yank-table]
265+keys = ["yat"]
266+
267+[workspace.vim_mode.actions.yank-list]
268+keys = ["yal"]
269+
228 [workspace.shortcuts.actions.close-pane]270 [workspace.shortcuts.actions.close-pane]
229 keys = ["ctrl+w"]271 keys = ["ctrl+w"]
230 272
A internal/application/dto/vim_page_interaction.go +86 -0
@@ -0,0 +1,86 @@
1+package dto
2+
3+// VimPageInteractionKind identifies an in-page Vim Mode interaction such as
4+// link hints, visual selection, or a one-shot text-object yank.
5+type VimPageInteractionKind int
6+
7+const (
8+ // VimPageHintFollow labels visible clickable elements and activates the chosen one.
9+ VimPageHintFollow VimPageInteractionKind = iota + 1
10+ // VimPageHintFollowNew labels visible links and opens the chosen one in a new pane.
11+ VimPageHintFollowNew
12+ // VimPageHintYankURL labels visible links and copies the chosen URL.
13+ VimPageHintYankURL
14+ // VimPageVisual starts the caret and visual selection flow: text-anchor
15+ // hints, a caret that motions move, and charwise or linewise selections
16+ // that y copies without leaving the caret.
17+ VimPageVisual
18+ // VimPageYankObject copies one structural text object immediately.
19+ VimPageYankObject
20+)
21+
22+// CapturesKeys reports whether the interaction consumes page keys until the
23+// page reports its end. One-shot yanks complete without key capture.
24+func (k VimPageInteractionKind) CapturesKeys() bool {
25+ switch k {
26+ case VimPageHintFollow, VimPageHintFollowNew, VimPageHintYankURL, VimPageVisual:
27+ return true
28+ default:
29+ return false
30+ }
31+}
32+
33+// VimPageMode is the sub-mode a key-capturing page interaction reports as it
34+// moves between states, so the UI can label it.
35+type VimPageMode string
36+
37+const (
38+ // VimPageModeHints is showing text-anchor hints to place the caret.
39+ VimPageModeHints VimPageMode = "hints"
40+ // VimPageModeCaret is moving a visible caret without selecting.
41+ VimPageModeCaret VimPageMode = "caret"
42+ // VimPageModeVisual is extending a charwise selection.
43+ VimPageModeVisual VimPageMode = "visual"
44+ // VimPageModeVisualLine is extending a linewise selection.
45+ VimPageModeVisualLine VimPageMode = "visual-line"
46+)
47+
48+// Valid reports whether the mode is one the page runtime may report.
49+func (m VimPageMode) Valid() bool {
50+ switch m {
51+ case VimPageModeHints, VimPageModeCaret, VimPageModeVisual, VimPageModeVisualLine:
52+ return true
53+ default:
54+ return false
55+ }
56+}
57+
58+// VimPageTextObject identifies the structural block copied by a text-object yank.
59+type VimPageTextObject int
60+
61+const (
62+ VimPageTextObjectSection VimPageTextObject = iota + 1
63+ VimPageTextObjectParagraph
64+ VimPageTextObjectCode
65+ VimPageTextObjectTable
66+ VimPageTextObjectList
67+)
68+
69+// VimPageInteractionRequest describes one in-page Vim interaction.
70+type VimPageInteractionRequest struct {
71+ Kind VimPageInteractionKind
72+ Object VimPageTextObject
73+ HighlightColor string
74+}
75+
76+// VimNavigationOutcome tells the UI how to continue after a Vim action.
77+type VimNavigationOutcome struct {
78+ // CapturePageKeys asks the UI to forward keys to the page interaction until
79+ // the page reports that the interaction ended.
80+ CapturePageKeys bool
81+ // PageKind and PageMode name the key-capturing interaction and the
82+ // sub-mode it starts in, so the UI labels it without knowing actions.
83+ // Both are zero when CapturePageKeys is false.
84+ PageKind VimPageInteractionKind
85+ PageMode VimPageMode
86+}
M internal/application/port/mocks/webview.go +201 -0
@@ -3724,3 +3724,204 @@ func (_c *MockPageScrollable_ScrollPage_Call) RunAndReturn(run func(ctx context.
3724 _c.Call.Return(run)3724 _c.Call.Return(run)
3725 return _c3725 return _c
3726 }3726 }
3727+
3728+// NewMockVimPageInteractor creates a new instance of MockVimPageInteractor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3729+// The first argument is typically a *testing.T value.
3730+func NewMockVimPageInteractor(t interface {
3731+ mock.TestingT
3732+ Cleanup(func())
3733+}) *MockVimPageInteractor {
3734+ if helper, ok := t.(interface{ Helper() }); ok {
3735+ helper.Helper()
3736+ }
3737+
3738+ mock := &MockVimPageInteractor{}
3739+ mock.Mock.Test(t)
3740+
3741+ t.Cleanup(func() {
3742+ if helper, ok := t.(interface{ Helper() }); ok {
3743+ helper.Helper()
3744+ }
3745+ mock.AssertExpectations(t)
3746+ })
3747+
3748+ return mock
3749+}
3750+
3751+// MockVimPageInteractor is an autogenerated mock type for the VimPageInteractor type
3752+type MockVimPageInteractor struct {
3753+ mock.Mock
3754+}
3755+
3756+type MockVimPageInteractor_Expecter struct {
3757+ mock *mock.Mock
3758+}
3759+
3760+func (_m *MockVimPageInteractor) EXPECT() *MockVimPageInteractor_Expecter {
3761+ return &MockVimPageInteractor_Expecter{mock: &_m.Mock}
3762+}
3763+
3764+// CancelVimPageInteraction provides a mock function for the type MockVimPageInteractor
3765+func (_mock *MockVimPageInteractor) CancelVimPageInteraction(ctx context.Context) error {
3766+ ret := _mock.Called(ctx)
3767+
3768+ if len(ret) == 0 {
3769+ panic("no return value specified for CancelVimPageInteraction")
3770+ }
3771+
3772+ var r0 error
3773+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
3774+ r0 = returnFunc(ctx)
3775+ } else {
3776+ r0 = ret.Error(0)
3777+ }
3778+ return r0
3779+}
3780+
3781+// MockVimPageInteractor_CancelVimPageInteraction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelVimPageInteraction'
3782+type MockVimPageInteractor_CancelVimPageInteraction_Call struct {
3783+ *mock.Call
3784+}
3785+
3786+// CancelVimPageInteraction is a helper method to define mock.On call
3787+// - ctx context.Context
3788+func (_e *MockVimPageInteractor_Expecter) CancelVimPageInteraction(ctx any) *MockVimPageInteractor_CancelVimPageInteraction_Call {
3789+ return &MockVimPageInteractor_CancelVimPageInteraction_Call{Call: _e.mock.On("CancelVimPageInteraction", ctx)}
3790+}
3791+
3792+func (_c *MockVimPageInteractor_CancelVimPageInteraction_Call) Run(run func(ctx context.Context)) *MockVimPageInteractor_CancelVimPageInteraction_Call {
3793+ _c.Call.Run(func(args mock.Arguments) {
3794+ var arg0 context.Context
3795+ if args[0] != nil {
3796+ arg0 = args[0].(context.Context)
3797+ }
3798+ run(
3799+ arg0,
3800+ )
3801+ })
3802+ return _c
3803+}
3804+
3805+func (_c *MockVimPageInteractor_CancelVimPageInteraction_Call) Return(err error) *MockVimPageInteractor_CancelVimPageInteraction_Call {
3806+ _c.Call.Return(err)
3807+ return _c
3808+}
3809+
3810+func (_c *MockVimPageInteractor_CancelVimPageInteraction_Call) RunAndReturn(run func(ctx context.Context) error) *MockVimPageInteractor_CancelVimPageInteraction_Call {
3811+ _c.Call.Return(run)
3812+ return _c
3813+}
3814+
3815+// SendVimPageKey provides a mock function for the type MockVimPageInteractor
3816+func (_mock *MockVimPageInteractor) SendVimPageKey(ctx context.Context, key string) error {
3817+ ret := _mock.Called(ctx, key)
3818+
3819+ if len(ret) == 0 {
3820+ panic("no return value specified for SendVimPageKey")
3821+ }
3822+
3823+ var r0 error
3824+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
3825+ r0 = returnFunc(ctx, key)
3826+ } else {
3827+ r0 = ret.Error(0)
3828+ }
3829+ return r0
3830+}
3831+
3832+// MockVimPageInteractor_SendVimPageKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendVimPageKey'
3833+type MockVimPageInteractor_SendVimPageKey_Call struct {
3834+ *mock.Call
3835+}
3836+
3837+// SendVimPageKey is a helper method to define mock.On call
3838+// - ctx context.Context
3839+// - key string
3840+func (_e *MockVimPageInteractor_Expecter) SendVimPageKey(ctx any, key any) *MockVimPageInteractor_SendVimPageKey_Call {
3841+ return &MockVimPageInteractor_SendVimPageKey_Call{Call: _e.mock.On("SendVimPageKey", ctx, key)}
3842+}
3843+
3844+func (_c *MockVimPageInteractor_SendVimPageKey_Call) Run(run func(ctx context.Context, key string)) *MockVimPageInteractor_SendVimPageKey_Call {
3845+ _c.Call.Run(func(args mock.Arguments) {
3846+ var arg0 context.Context
3847+ if args[0] != nil {
3848+ arg0 = args[0].(context.Context)
3849+ }
3850+ var arg1 string
3851+ if args[1] != nil {
3852+ arg1 = args[1].(string)
3853+ }
3854+ run(
3855+ arg0,
3856+ arg1,
3857+ )
3858+ })
3859+ return _c
3860+}
3861+
3862+func (_c *MockVimPageInteractor_SendVimPageKey_Call) Return(err error) *MockVimPageInteractor_SendVimPageKey_Call {
3863+ _c.Call.Return(err)
3864+ return _c
3865+}
3866+
3867+func (_c *MockVimPageInteractor_SendVimPageKey_Call) RunAndReturn(run func(ctx context.Context, key string) error) *MockVimPageInteractor_SendVimPageKey_Call {
3868+ _c.Call.Return(run)
3869+ return _c
3870+}
3871+
3872+// StartVimPageInteraction provides a mock function for the type MockVimPageInteractor
3873+func (_mock *MockVimPageInteractor) StartVimPageInteraction(ctx context.Context, request dto.VimPageInteractionRequest) error {
3874+ ret := _mock.Called(ctx, request)
3875+
3876+ if len(ret) == 0 {
3877+ panic("no return value specified for StartVimPageInteraction")
3878+ }
3879+
3880+ var r0 error
3881+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.VimPageInteractionRequest) error); ok {
3882+ r0 = returnFunc(ctx, request)
3883+ } else {
3884+ r0 = ret.Error(0)
3885+ }
3886+ return r0
3887+}
3888+
3889+// MockVimPageInteractor_StartVimPageInteraction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartVimPageInteraction'
3890+type MockVimPageInteractor_StartVimPageInteraction_Call struct {
3891+ *mock.Call
3892+}
3893+
3894+// StartVimPageInteraction is a helper method to define mock.On call
3895+// - ctx context.Context
3896+// - request dto.VimPageInteractionRequest
3897+func (_e *MockVimPageInteractor_Expecter) StartVimPageInteraction(ctx any, request any) *MockVimPageInteractor_StartVimPageInteraction_Call {
3898+ return &MockVimPageInteractor_StartVimPageInteraction_Call{Call: _e.mock.On("StartVimPageInteraction", ctx, request)}
3899+}
3900+
3901+func (_c *MockVimPageInteractor_StartVimPageInteraction_Call) Run(run func(ctx context.Context, request dto.VimPageInteractionRequest)) *MockVimPageInteractor_StartVimPageInteraction_Call {
3902+ _c.Call.Run(func(args mock.Arguments) {
3903+ var arg0 context.Context
3904+ if args[0] != nil {
3905+ arg0 = args[0].(context.Context)
3906+ }
3907+ var arg1 dto.VimPageInteractionRequest
3908+ if args[1] != nil {
3909+ arg1 = args[1].(dto.VimPageInteractionRequest)
3910+ }
3911+ run(
3912+ arg0,
3913+ arg1,
3914+ )
3915+ })
3916+ return _c
3917+}
3918+
3919+func (_c *MockVimPageInteractor_StartVimPageInteraction_Call) Return(err error) *MockVimPageInteractor_StartVimPageInteraction_Call {
3920+ _c.Call.Return(err)
3921+ return _c
3922+}
3923+
3924+func (_c *MockVimPageInteractor_StartVimPageInteraction_Call) RunAndReturn(run func(ctx context.Context, request dto.VimPageInteractionRequest) error) *MockVimPageInteractor_StartVimPageInteraction_Call {
3925+ _c.Call.Return(run)
3926+ return _c
3927+}
M internal/application/port/webview.go +20 -0
@@ -160,6 +160,15 @@ type WebViewCallbacks struct {
160 // navigation, WebView destruction). The UI must hide any dialog shown for160 // navigation, WebView destruction). The UI must hide any dialog shown for
161 // this WebView without invoking respond.161 // this WebView without invoking respond.
162 OnJSDialogReset func()162 OnJSDialogReset func()
163+
164+ // OnVimPageInteractionEnded is called when an in-page Vim interaction
165+ // (link hints or visual selection) finishes or is canceled by the page.
166+ OnVimPageInteractionEnded func()
167+
168+ // OnVimPageModeChanged is called when a key-capturing in-page Vim
169+ // interaction moves to another sub-mode (hints, caret, visual) without
170+ // ending.
171+ OnVimPageModeChanged func(mode dto.VimPageMode)
163 }172 }
164 173
165 // FindOptions configures search behavior.174 // FindOptions configures search behavior.
@@ -502,6 +511,17 @@ type PageInputFocuser interface {
502 FocusNextInput()511 FocusNextInput()
503 }512 }
504 513
514+// VimPageInteractor is an optional WebView capability for in-page Vim
515+// interactions: link hints, visual selection, and text-object yanks. Keys are
516+// canonical vimkeys strings such as "j", "J", or "<Esc>". Implementations
517+// report the end of a key-capturing interaction through
518+// WebViewCallbacks.OnVimPageInteractionEnded.
519+type VimPageInteractor interface {
520+ StartVimPageInteraction(ctx context.Context, request dto.VimPageInteractionRequest) error
521+ SendVimPageKey(ctx context.Context, key string) error
522+ CancelVimPageInteraction(ctx context.Context) error
523+}
524+
505 // PageFocusNavigator is an optional WebView capability for keeping native-like525 // PageFocusNavigator is an optional WebView capability for keeping native-like
506 // focus traversal inside the active page when the host window would otherwise526 // focus traversal inside the active page when the host window would otherwise
507 // move focus outside the WebView.527 // move focus outside the WebView.
M internal/application/usecase/vim_navigation.go +86 -3
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 7
7 "github.com/bnema/dumber/internal/application/dto"8 "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -23,12 +24,21 @@ func NewVimNavigationUseCase() *VimNavigationUseCase {
23 return &VimNavigationUseCase{}24 return &VimNavigationUseCase{}
24 }25 }
25 26
26-// Execute dispatches a configured semantic Vim action to the active WebView.27+// Execute dispatches a configured Vim action to the active WebView. The
27-func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error {28+// outcome tells the UI whether the page now owns key input.
29+func (uc *VimNavigationUseCase) Execute(
30+ ctx context.Context, wv port.WebView, action string, count int, highlightColor string,
31+) (dto.VimNavigationOutcome, error) {
28 if wv == nil {32 if wv == nil {
29- return errors.New("vim navigation: nil webview")33+ return dto.VimNavigationOutcome{}, errors.New("vim navigation: nil webview")
30 }34 }
35+ if request, ok := vimPageInteractionRequest(action, highlightColor); ok {
36+ return uc.startPageInteraction(ctx, wv, request)
37+ }
38+ return dto.VimNavigationOutcome{}, executeVimNavigation(ctx, wv, action, count, highlightColor)
39+}
31 40
41+func executeVimNavigation(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error {
32 switch action {42 switch action {
33 case "confirm":43 case "confirm":
34 activator, ok := wv.(port.SemanticNavigationActivator)44 activator, ok := wv.(port.SemanticNavigationActivator)
@@ -72,6 +82,79 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio
72 return navigator.NavigateSemantic(ctx, request)82 return navigator.NavigateSemantic(ctx, request)
73 }83 }
74 84
85+var vimYankObjects = map[string]dto.VimPageTextObject{
86+ "yank-section": dto.VimPageTextObjectSection,
87+ "yank-paragraph": dto.VimPageTextObjectParagraph,
88+ "yank-code": dto.VimPageTextObjectCode,
89+ "yank-table": dto.VimPageTextObjectTable,
90+ "yank-list": dto.VimPageTextObjectList,
91+}
92+
93+func vimPageInteractionRequest(action, highlightColor string) (dto.VimPageInteractionRequest, bool) {
94+ request := dto.VimPageInteractionRequest{HighlightColor: highlightColor}
95+ switch action {
96+ case "hint-follow":
97+ request.Kind = dto.VimPageHintFollow
98+ case "hint-follow-new":
99+ request.Kind = dto.VimPageHintFollowNew
100+ case "hint-yank-url":
101+ request.Kind = dto.VimPageHintYankURL
102+ case "visual":
103+ request.Kind = dto.VimPageVisual
104+ default:
105+ object, ok := vimYankObjects[action]
106+ if !ok {
107+ return dto.VimPageInteractionRequest{}, false
108+ }
109+ request.Kind = dto.VimPageYankObject
110+ request.Object = object
111+ }
112+ return request, true
113+}
114+
115+func (*VimNavigationUseCase) startPageInteraction(
116+ ctx context.Context, wv port.WebView, request dto.VimPageInteractionRequest,
117+) (dto.VimNavigationOutcome, error) {
118+ interactor, ok := wv.(port.VimPageInteractor)
119+ if !ok {
120+ return dto.VimNavigationOutcome{}, errUnsupportedVimNavigationEngine
121+ }
122+ if err := interactor.StartVimPageInteraction(ctx, request); err != nil {
123+ return dto.VimNavigationOutcome{}, fmt.Errorf("vim navigation: start page interaction: %w", err)
124+ }
125+ if !request.Kind.CapturesKeys() {
126+ return dto.VimNavigationOutcome{}, nil
127+ }
128+ // Every key-capturing interaction opens on text or link hints; the page
129+ // reports later sub-modes itself.
130+ return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: request.Kind, PageMode: dto.VimPageModeHints}, nil
131+}
132+
133+// SendPageKey forwards one canonical key to the active in-page interaction.
134+func (*VimNavigationUseCase) SendPageKey(ctx context.Context, wv port.WebView, key string) error {
135+ interactor, ok := wv.(port.VimPageInteractor)
136+ if !ok {
137+ return errUnsupportedVimNavigationEngine
138+ }
139+ if err := interactor.SendVimPageKey(ctx, key); err != nil {
140+ return fmt.Errorf("vim navigation: send page key: %w", err)
141+ }
142+ return nil
143+}
144+
145+// CancelPageInteraction ends any in-page interaction, removing its overlay or
146+// selection. WebViews without the capability have nothing to cancel.
147+func (*VimNavigationUseCase) CancelPageInteraction(ctx context.Context, wv port.WebView) error {
148+ interactor, ok := wv.(port.VimPageInteractor)
149+ if !ok {
150+ return nil
151+ }
152+ if err := interactor.CancelVimPageInteraction(ctx); err != nil {
153+ return fmt.Errorf("vim navigation: cancel page interaction: %w", err)
154+ }
155+ return nil
156+}
157+
75 // ClearSemanticNavigationHighlight removes the visual target left by semantic158 // ClearSemanticNavigationHighlight removes the visual target left by semantic
76 // navigation when the active Vim Mode session ends.159 // navigation when the active Vim Mode session ends.
77 func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error {160 func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error {
M internal/application/usecase/vim_navigation_test.go +8 -4
@@ -46,9 +46,12 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
46 wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}46 wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
47 uc := NewVimNavigationUseCase()47 uc := NewVimNavigationUseCase()
48 48
49- require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, ""))49+ _, err := uc.Execute(context.Background(), wv, "focus-input", 1, "")
50- require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc"))50+ require.NoError(t, err)
51- require.NoError(t, uc.Execute(context.Background(), wv, "confirm", 1, ""))51+ _, err = uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")
52+ require.NoError(t, err)
53+ _, err = uc.Execute(context.Background(), wv, "confirm", 1, "")
54+ require.NoError(t, err)
52 55
53 assert.Equal(t, 1, wv.focusNextInputCalls)56 assert.Equal(t, 1, wv.focusNextInputCalls)
54 assert.Equal(t, 1, wv.activateNavigationTargetCalls)57 assert.Equal(t, 1, wv.activateNavigationTargetCalls)
@@ -80,6 +83,7 @@ func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) {
80 func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) {83 func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) {
81 wv := portmocks.NewMockWebView(t)84 wv := portmocks.NewMockWebView(t)
82 85
83- require.ErrorIs(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, ""), errUnsupportedVimNavigationEngine)86+ _, err := NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, "")
87+ require.ErrorIs(t, err, errUnsupportedVimNavigationEngine)
84 assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true))88 assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true))
85 }89 }
A internal/application/usecase/vim_page_interaction_test.go +125 -0
@@ -0,0 +1,125 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
13+)
14+
15+// interactiveWebView composes generated mocks so a WebView also exposes the
16+// optional VimPageInteractor capability.
17+type interactiveWebView struct {
18+ *portmocks.MockWebView
19+ *portmocks.MockVimPageInteractor
20+}
21+
22+func newInteractiveWebView(t *testing.T) (*interactiveWebView, *portmocks.MockVimPageInteractor) {
23+ t.Helper()
24+ interactor := portmocks.NewMockVimPageInteractor(t)
25+ return &interactiveWebView{MockWebView: portmocks.NewMockWebView(t), MockVimPageInteractor: interactor}, interactor
26+}
27+
28+func TestVimNavigationUseCaseStartsPageInteractions(t *testing.T) {
29+ tests := []struct {
30+ action string
31+ want dto.VimPageInteractionRequest
32+ wantCapture bool
33+ }{
34+ {"hint-follow", dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollow, HighlightColor: "#abc"}, true},
35+ {"hint-follow-new", dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollowNew, HighlightColor: "#abc"}, true},
36+ {"hint-yank-url", dto.VimPageInteractionRequest{Kind: dto.VimPageHintYankURL, HighlightColor: "#abc"}, true},
37+ {"visual", dto.VimPageInteractionRequest{Kind: dto.VimPageVisual, HighlightColor: "#abc"}, true},
38+ {"yank-section", dto.VimPageInteractionRequest{
39+ Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectSection, HighlightColor: "#abc",
40+ }, false},
41+ {"yank-paragraph", dto.VimPageInteractionRequest{
42+ Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectParagraph, HighlightColor: "#abc",
43+ }, false},
44+ {"yank-code", dto.VimPageInteractionRequest{
45+ Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#abc",
46+ }, false},
47+ {"yank-table", dto.VimPageInteractionRequest{
48+ Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectTable, HighlightColor: "#abc",
49+ }, false},
50+ {"yank-list", dto.VimPageInteractionRequest{
51+ Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectList, HighlightColor: "#abc",
52+ }, false},
53+ }
54+ for _, tt := range tests {
55+ t.Run(tt.action, func(t *testing.T) {
56+ ctx := context.Background()
57+ wv, interactor := newInteractiveWebView(t)
58+ interactor.EXPECT().StartVimPageInteraction(ctx, tt.want).Return(nil).Once()
59+
60+ outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, tt.action, 1, "#abc")
61+
62+ require.NoError(t, err)
63+ assert.Equal(t, tt.wantCapture, outcome.CapturePageKeys)
64+ if tt.wantCapture {
65+ assert.Equal(t, dto.VimNavigationOutcome{
66+ CapturePageKeys: true, PageKind: tt.want.Kind, PageMode: dto.VimPageModeHints,
67+ }, outcome, "capturing interactions open on hints and name their kind for the label")
68+ } else {
69+ assert.Equal(t, dto.VimNavigationOutcome{}, outcome)
70+ }
71+ })
72+ }
73+}
74+
75+func TestVimNavigationUseCasePageInteractionErrorDoesNotCapture(t *testing.T) {
76+ ctx := context.Background()
77+ wv, interactor := newInteractiveWebView(t)
78+ boom := errors.New("boom")
79+ interactor.EXPECT().StartVimPageInteraction(ctx, dto.VimPageInteractionRequest{Kind: dto.VimPageVisual}).Return(boom).Once()
80+
81+ outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, "visual", 1, "")
82+
83+ require.ErrorIs(t, err, boom)
84+ require.ErrorContains(t, err, "vim navigation: start page interaction")
85+ assert.False(t, outcome.CapturePageKeys)
86+}
87+
88+func TestVimNavigationUseCasePageInteractionUnsupportedEngine(t *testing.T) {
89+ uc := NewVimNavigationUseCase()
90+ wv := portmocks.NewMockWebView(t)
91+
92+ outcome, err := uc.Execute(context.Background(), wv, "hint-follow", 1, "")
93+ require.ErrorIs(t, err, errUnsupportedVimNavigationEngine)
94+ assert.False(t, outcome.CapturePageKeys)
95+
96+ require.ErrorIs(t, uc.SendPageKey(context.Background(), wv, "a"), errUnsupportedVimNavigationEngine)
97+ require.NoError(t, uc.CancelPageInteraction(context.Background(), wv))
98+}
99+
100+func TestVimNavigationUseCaseForwardsPageKeysAndCancel(t *testing.T) {
101+ ctx := context.Background()
102+ wv, interactor := newInteractiveWebView(t)
103+ interactor.EXPECT().SendVimPageKey(ctx, "J").Return(nil).Once()
104+ interactor.EXPECT().CancelVimPageInteraction(ctx).Return(nil).Once()
105+ uc := NewVimNavigationUseCase()
106+
107+ require.NoError(t, uc.SendPageKey(ctx, wv, "J"))
108+ require.NoError(t, uc.CancelPageInteraction(ctx, wv))
109+}
110+
111+func TestVimNavigationUseCaseWrapsPageKeyAndCancelErrors(t *testing.T) {
112+ ctx := context.Background()
113+ wv, interactor := newInteractiveWebView(t)
114+ boom := errors.New("boom")
115+ interactor.EXPECT().SendVimPageKey(ctx, "j").Return(boom).Once()
116+ interactor.EXPECT().CancelVimPageInteraction(ctx).Return(boom).Once()
117+ uc := NewVimNavigationUseCase()
118+
119+ err := uc.SendPageKey(ctx, wv, "j")
120+ require.ErrorIs(t, err, boom)
121+ require.ErrorContains(t, err, "vim navigation: send page key")
122+ err = uc.CancelPageInteraction(ctx, wv)
123+ require.ErrorIs(t, err, boom)
124+ require.ErrorContains(t, err, "vim navigation: cancel page interaction")
125+}
M internal/infrastructure/cef/engine_init.go +1 -0
@@ -323,6 +323,7 @@ func newEngineSchemeHandler(
323 schemeHandler.onPopupClose = eng.handlePopupBridgeClose323 schemeHandler.onPopupClose = eng.handlePopupBridgeClose
324 schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate324 schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate
325 schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage325 schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage
326+ schemeHandler.onVimPage = eng.handleVimPageBridge
326 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest327 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
327 return messageRouter, schemeHandler, nil328 return messageRouter, schemeHandler, nil
328 }329 }
M internal/infrastructure/cef/scheme_handler.go +32 -0
@@ -7,6 +7,7 @@ import (
7 "encoding/base64"7 "encoding/base64"
8 "encoding/hex"8 "encoding/hex"
9 "encoding/json"9 "encoding/json"
10+ "errors"
10 "fmt"11 "fmt"
11 "html"12 "html"
12 "io/fs"13 "io/fs"
@@ -96,6 +97,8 @@ type dumbSchemeHandler struct {
96 onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)97 onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
97 onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload)98 onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload)
98 onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload)99 onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload)
100+ // onVimPage receives Vim Mode page runtime results (copy, open, end).
101+ onVimPage func(browser purecef.Browser, payload vimPageBridgePayload)
99 102
100 // bridgeNonceValidator checks whether a bridge nonce belongs to the active103 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
101 // browser/navigation context that issued the request.104 // browser/navigation context that issued the request.
@@ -267,6 +270,8 @@ func (h *dumbSchemeHandler) handleAPIPost(
267 return h.handlePopupOpenerNavigate(request, browser), true270 return h.handlePopupOpenerNavigate(request, browser), true
268 case "/api/popup-opener-post-message":271 case "/api/popup-opener-post-message":
269 return h.handlePopupOpenerPostMessage(request, browser), true272 return h.handlePopupOpenerPostMessage(request, browser), true
273+ case "/api/vim-page":
274+ return h.handleVimPage(request, browser), true
270 default:275 default:
271 return nil, false276 return nil, false
272 }277 }
@@ -475,6 +480,33 @@ func handlePopupBridgeRequest[T any](
475 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})480 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
476 }481 }
477 482
483+// handleVimPage accepts Vim page runtime results. It does not use the shared
484+// bridge nonce: the payload carries a per-interaction token that the source
485+// WebView validates, so the bridge nonce is never exposed to page-reachable
486+// Vim calls.
487+func (h *dumbSchemeHandler) handleVimPage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
488+ if browser == nil {
489+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
490+ }
491+ body := readBodyFromHeader(request)
492+ if body == nil {
493+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
494+ }
495+ payload, err := decodeVimPageBridgePayload(body)
496+ if errors.Is(err, errVimPagePayloadTooLarge) {
497+ return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
498+ }
499+ if err != nil {
500+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
501+ }
502+ if h.onVimPage == nil {
503+ h.logger.Warn().Msg("cef: vim-page — callback not wired")
504+ } else {
505+ h.onVimPage(browser, payload)
506+ }
507+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
508+}
509+
478 func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {510 func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
479 return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen)511 return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen)
480 }512 }
A internal/infrastructure/cef/testdata/vim_page_harness.js +234 -0
@@ -0,0 +1,234 @@
1+// DOM shim for the Vim page runtime tests. It models a page of single-line
2+// paragraphs (10px per character, 20px lines) with Chromium-like Selection and
3+// Range semantics: crossing a paragraph boundary is one character step that
4+// adds one "\n" to the selected text. Native "word" granularity is a trap: the
5+// runtime must implement word motions itself (Vimium #1441).
6+const posted = [];
7+const scrolls = [];
8+const modifyCalls = [];
9+const frames = new Map();
10+let nextFrame = 1;
11+let toStringCalls = 0;
12+let closestCalls = 0;
13+let rangeRectCalls = 0;
14+const listeners = {};
15+const CHAR_WIDTH = 10;
16+const LINE_HEIGHT = 20;
17+const rect = (x, y, w, h) => ({ left: x, top: y, right: x + w, bottom: y + h, width: w, height: h });
18+
19+class Node_ {
20+ constructor(type, tag) {
21+ this.nodeType = type; this.tagName = tag; this.children = []; this.parentElement = null;
22+ // style.cssText assignments are parsed so tests can read properties.
23+ const style = {};
24+ Object.defineProperty(style, "cssText", { enumerable: false, set(text) {
25+ for (const decl of text.split(";")) { const i = decl.indexOf(":"); if (i > 0) style[decl.slice(0, i).trim()] = decl.slice(i + 1).trim(); }
26+ } });
27+ this.style = style; this.attrs = {}; this.isConnected = true; this.shadowRoot = null;
28+ }
29+ appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
30+ append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }
31+ remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); this.parentElement = null; }
32+ setAttribute(name, value) { this.attrs[name] = value; }
33+ closest() { closestCalls++; return null; }
34+ contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
35+ getClientRects() { return this.rect ? [this.rect] : []; }
36+ getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
37+ getRootNode() { return document; }
38+ querySelectorAll(selector) {
39+ const out = [];
40+ const visit = (node) => { for (const c of node.children) { if (c.nodeType === 1) { if (matches(c, selector)) out.push(c); visit(c); } } };
41+ visit(this);
42+ return out;
43+ }
44+ focus() { document.activeElement = this; }
45+ click() { posted.push({ clicked: this.href }); }
46+ dispatchEvent(event) { posted.push({ dispatched: event.type, bubbles: event.bubbles, cancelable: event.cancelable, target: this.tagName }); return true; }
47+}
48+function matches(el, selector) {
49+ return selector.split(",").some((part) => {
50+ part = part.trim();
51+ if (part === "*") return true;
52+ if (part === "a[href]") return el.tagName === "A" && el.href;
53+ if (part.startsWith("[")) return false;
54+ const base = part.replace(/:not\(.*\)$/, "");
55+ return el.tagName === base.toUpperCase();
56+ });
57+}
58+class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
59+class HTMLElement extends Element {}
60+class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
61+// SVGAElement_ models an SVG <a>: href is an SVGAnimatedString, there is no
62+// click(), and the link is not an HTMLAnchorElement.
63+class SVGAElement_ extends Element {
64+ constructor(href) { super("a"); this.href = { baseVal: href }; this.target = { baseVal: "" }; this.click = undefined; }
65+}
66+class HTMLInputElement extends HTMLElement {}
67+class HTMLTextAreaElement extends HTMLElement {}
68+class HTMLSelectElement extends HTMLElement {}
69+class Text_ extends Node_ { constructor(data) { super(3, "#text"); this.data = data; } }
70+
71+const html = new HTMLElement("html");
72+const body = html.appendChild(new HTMLElement("body"));
73+const texts = [];
74+function paragraph(data, top) {
75+ const p = body.appendChild(new HTMLElement("p"));
76+ p.rect = rect(10, top, 600, LINE_HEIGHT);
77+ const text = p.appendChild(new Text_(data));
78+ texts.push(text);
79+ return { p, text };
80+}
81+const LONG = "The quick brown fox jumps over the lazy dog near the river";
82+const first = paragraph("Alpha bravo charlie", 10);
83+const second = paragraph(LONG, 40);
84+const third = paragraph("Third line, end.", 70);
85+const far = paragraph("Far below the fold", 2000);
86+const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 120, 40, 20);
87+const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 120, 40, 20);
88+// shadowParagraph adds a host whose paragraph lives in an open shadow root;
89+// its text node joins the selection model at the end of the document order.
90+function shadowParagraph(data, top) {
91+ const host = body.appendChild(new HTMLElement("div"));
92+ const p = new HTMLElement("p");
93+ p.rect = rect(10, top, 600, LINE_HEIGHT);
94+ p.parentElement = null;
95+ const text = p.appendChild(new Text_(data));
96+ texts.push(text);
97+ host.shadowRoot = { children: [p], querySelectorAll: (selector) => Node_.prototype.querySelectorAll.call({ children: [p] }, selector), getRootNode: () => document };
98+ return { host, p, text };
99+}
100+
101+const index = (node) => texts.indexOf(node);
102+const order = (node, offset) => index(node) * 100000 + offset;
103+class Range_ {
104+ constructor() { this.startContainer = this.endContainer = null; this.startOffset = this.endOffset = 0; }
105+ get collapsed() { return this.startContainer === this.endContainer && this.startOffset === this.endOffset; }
106+ setStart(node, offset) {
107+ this.startContainer = node; this.startOffset = offset;
108+ if (!this.endContainer || order(node, offset) > order(this.endContainer, this.endOffset)) { this.endContainer = node; this.endOffset = offset; }
109+ }
110+ setEnd(node, offset) {
111+ this.endContainer = node; this.endOffset = offset;
112+ if (!this.startContainer || order(node, offset) < order(this.startContainer, this.startOffset)) { this.startContainer = node; this.startOffset = offset; }
113+ }
114+ collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } }
115+ selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.data ? node.data.length : 0; }
116+ getClientRects() {
117+ rangeRectCalls++;
118+ const node = this.startContainer;
119+ const box = node && node.parentElement && node.parentElement.rect;
120+ if (!box) return [];
121+ const from = this.startOffset;
122+ const to = this.endContainer === node ? this.endOffset : node.data.length;
123+ return [rect(box.left + from * CHAR_WIDTH, box.top, (to - from) * CHAR_WIDTH, LINE_HEIGHT)];
124+ }
125+ getBoundingClientRect() { return this.getClientRects()[0] || rect(0, 0, 0, 0); }
126+}
127+
128+const selection = {
129+ anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, rangeCount: 0,
130+ get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },
131+ setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; },
132+ collapse(node, offset) { this.setBaseAndExtent(node, offset, node, offset); },
133+ modify(alter, direction, granularity) {
134+ modifyCalls.push([alter, direction, granularity]);
135+ if (granularity === "word") throw new Error("native word granularity must not be used");
136+ const step = direction === "forward" ? 1 : -1;
137+ let i = index(this.focusNode);
138+ let o = this.focusOffset;
139+ const len = (n) => texts[n].data.length;
140+ if (granularity === "character") {
141+ if (step > 0) { if (o < len(i)) o++; else if (i < texts.length - 1) { i++; o = 0; } }
142+ else if (o > 0) o--; else if (i > 0) { i--; o = len(i); }
143+ } else if (granularity === "lineboundary") {
144+ o = step > 0 ? len(i) : 0;
145+ } else if (granularity === "line") {
146+ const j = i + step;
147+ if (j >= 0 && j < texts.length) { i = j; o = Math.min(o, len(j)); } else o = step > 0 ? len(i) : 0;
148+ } else if (granularity === "documentboundary") {
149+ i = step > 0 ? texts.length - 1 : 0; o = step > 0 ? len(i) : 0;
150+ }
151+ // Sentence and paragraph granularities are native: this model leaves the
152+ // focus alone, and tests assert the modify() calls instead.
153+ this.focusNode = texts[i]; this.focusOffset = o;
154+ if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; }
155+ },
156+ removeAllRanges() { this.rangeCount = 0; this.anchorNode = this.focusNode = null; },
157+ addRange(range) { this.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); },
158+ getRangeAt() {
159+ const range = new Range_();
160+ const forward = order(this.anchorNode, this.anchorOffset) <= order(this.focusNode, this.focusOffset);
161+ const [s, e] = forward ? [[this.anchorNode, this.anchorOffset], [this.focusNode, this.focusOffset]] : [[this.focusNode, this.focusOffset], [this.anchorNode, this.anchorOffset]];
162+ range.startContainer = s[0]; range.startOffset = s[1]; range.endContainer = e[0]; range.endOffset = e[1];
163+ return range;
164+ },
165+ toString() {
166+ toStringCalls++;
167+ if (!this.rangeCount) return "";
168+ const r = this.getRangeAt();
169+ const a = index(r.startContainer);
170+ const b = index(r.endContainer);
171+ if (a === b) return r.startContainer.data.slice(r.startOffset, r.endOffset);
172+ const parts = [r.startContainer.data.slice(r.startOffset)];
173+ for (let n = a + 1; n < b; n++) parts.push(texts[n].data);
174+ parts.push(r.endContainer.data.slice(0, r.endOffset));
175+ return parts.join("\n");
176+ },
177+};
178+
179+global.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 };
180+global.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4 };
181+global.Element = Element; global.HTMLElement = HTMLElement; global.HTMLAnchorElement = HTMLAnchorElement;
182+global.HTMLInputElement = HTMLInputElement; global.HTMLTextAreaElement = HTMLTextAreaElement; global.HTMLSelectElement = HTMLSelectElement;
183+global.document = {
184+ body, documentElement: html, activeElement: body,
185+ createElement: (tag) => new HTMLElement(tag),
186+ createRange: () => new Range_(),
187+ createTreeWalker: (root) => {
188+ const nodes = []; const visit = (n) => { nodes.push(n); for (const c of n.children) visit(c); }; visit(root);
189+ let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
190+ },
191+ querySelectorAll: (selector) => html.querySelectorAll(selector),
192+ baseURI: "https://example.com/dir/page.html",
193+ elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement), ...body.children.slice().reverse()].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
194+};
195+global.MouseEvent = class { constructor(type, init) { this.type = type; Object.assign(this, init); } };
196+global.window = {
197+ innerWidth: 800, innerHeight: 600,
198+ scrollBy(x, y) { scrolls.push([x, y]); },
199+ addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); },
200+ removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); },
201+ getSelection: () => selection,
202+ getComputedStyle: () => ({ display: "block", visibility: "visible" }),
203+ // The runtime sends btoa(unescape(encodeURIComponent(json))): base64 of the
204+ // UTF-8 bytes, which is what Go decodes.
205+ fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("utf8"))); return Promise.resolve(); },
206+ requestAnimationFrame(fn) { const id = nextFrame++; frames.set(id, fn); return id; },
207+ cancelAnimationFrame(id) { frames.delete(id); },
208+};
209+global.setTimeout = (fn) => { fn(); return 0; };
210+
211+// Helpers for drivers.
212+const pos = (node, offset) => index(node) + ":" + offset;
213+const snap = () => ({
214+ text: selection.toString(),
215+ anchor: selection.rangeCount ? pos(selection.anchorNode, selection.anchorOffset) : null,
216+ focus: selection.rangeCount ? pos(selection.focusNode, selection.focusOffset) : null,
217+});
218+const overlays = (name) => html.children.filter((c) => c.attrs && name in c.attrs);
219+const cursor = () => overlays("data-dumber-vim-caret")[0] || null;
220+const hintLabels = () => overlays("data-dumber-vim-hints").flatMap((root) => root.children.map((s) => s.textContent));
221+const fire = (type) => (listeners[type] || []).slice().forEach((fn) => fn({}));
222+// flushFrames runs the animation frames requested so far.
223+const flushFrames = () => { const pending = Array.from(frames.values()); frames.clear(); pending.forEach((fn) => fn()); };
224+const vp = () => window.__dumberVimPage;
225+const keys = (token, ...list) => list.forEach((k) => window.__dumberVimPage.key(token, k));
226+// startCaret begins the visual flow and places the caret at the start of the
227+// paragraph with the given hint label ("s", "a", "d" for the first, second,
228+// third visible paragraph).
229+const startCaret = (token, label, color) => {
230+ window.__dumberVimPage.start(token, { kind: "visual", color: color || "#fbbf24" });
231+ window.__dumberVimPage.key(token, label);
232+};
233+// S records a named snapshot of the selection (and any extra fields).
234+const S = (n, extra) => posted.push(Object.assign({ n }, snap(), extra));
A internal/infrastructure/cef/vim_page.go +465 -0
@@ -0,0 +1,465 @@
1+package cef
2+
3+import (
4+ "context"
5+ "crypto/subtle"
6+ _ "embed"
7+ "encoding/base64"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "net/url"
12+ "regexp"
13+ "strings"
14+
15+ purecef "github.com/bnema/purego-cef/cef"
16+
17+ "github.com/bnema/dumber/internal/application/dto"
18+ "github.com/bnema/dumber/internal/application/port"
19+ "github.com/bnema/dumber/internal/logging"
20+)
21+
22+var _ port.VimPageInteractor = (*WebView)(nil)
23+
24+//go:embed vim_page_runtime.js
25+var vimPageRuntimeTemplateJS string
26+
27+var (
28+ errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable")
29+ errVimPagePayloadTooLarge = errors.New("vim page: payload too large")
30+ // vimPageAccentColor admits the CSS hex colors the runtime paints with;
31+ // anything else is replaced by the runtime default before reaching JS.
32+ vimPageAccentColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
33+)
34+
35+// vimPageBridgePayload is one result reported by the page runtime. Token is
36+// the per-interaction secret issued by StartVimPageInteraction.
37+type vimPageBridgePayload struct {
38+ Type string `json:"type"`
39+ Token string `json:"token"`
40+ Text string `json:"text,omitempty"`
41+ URL string `json:"url,omitempty"`
42+ // Reason explains an early end (for example no visible targets).
43+ Reason string `json:"reason,omitempty"`
44+ // Mode names the sub-mode of a "mode" message.
45+ Mode string `json:"mode,omitempty"`
46+}
47+
48+const (
49+ vimPageMessageCopy = "copy"
50+ vimPageMessageMode = "mode"
51+ vimPageMessageOpenNew = "open-new"
52+ vimPageMessageEnd = "end"
53+)
54+
55+func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) {
56+ var payload vimPageBridgePayload
57+ if len(body) > maxClipboardBytes {
58+ return payload, errVimPagePayloadTooLarge
59+ }
60+ if err := json.Unmarshal(body, &payload); err != nil {
61+ return payload, err
62+ }
63+ if payload.Token == "" {
64+ return payload, errors.New("missing token")
65+ }
66+ switch payload.Type {
67+ case vimPageMessageCopy:
68+ if payload.Text == "" {
69+ return payload, errors.New("missing text")
70+ }
71+ case vimPageMessageOpenNew:
72+ if payload.URL == "" {
73+ return payload, errors.New("missing url")
74+ }
75+ case vimPageMessageMode:
76+ if !dto.VimPageMode(payload.Mode).Valid() {
77+ return payload, fmt.Errorf("unknown mode %q", payload.Mode)
78+ }
79+ case vimPageMessageEnd:
80+ default:
81+ return payload, fmt.Errorf("unknown type %q", payload.Type)
82+ }
83+ return payload, nil
84+}
85+
86+// isNavigableVimHintURL keeps hint-opened panes to web documents with a host
87+// so a page cannot smuggle javascript: or internal URLs through the bridge.
88+// file: targets are allowed only from a page that is itself a file: document,
89+// matching Chromium's own http(s)-to-file navigation block.
90+func isNavigableVimHintURL(raw, sourceURI string) bool {
91+ parsed, err := url.Parse(raw)
92+ if err != nil {
93+ return false
94+ }
95+ switch strings.ToLower(parsed.Scheme) {
96+ case "http", "https":
97+ return parsed.Host != ""
98+ case "file":
99+ source, err := url.Parse(sourceURI)
100+ return err == nil && strings.EqualFold(source.Scheme, "file")
101+ default:
102+ return false
103+ }
104+}
105+
106+func vimPageKindName(kind dto.VimPageInteractionKind) (string, bool) {
107+ switch kind {
108+ case dto.VimPageHintFollow:
109+ return "hint-follow", true
110+ case dto.VimPageHintFollowNew:
111+ return "hint-follow-new", true
112+ case dto.VimPageHintYankURL:
113+ return "hint-yank-url", true
114+ case dto.VimPageVisual:
115+ return "visual", true
116+ case dto.VimPageYankObject:
117+ return "yank", true
118+ default:
119+ return "", false
120+ }
121+}
122+
123+func vimPageObjectName(object dto.VimPageTextObject) (string, bool) {
124+ switch object {
125+ case dto.VimPageTextObjectSection:
126+ return "section", true
127+ case dto.VimPageTextObjectParagraph:
128+ return "paragraph", true
129+ case dto.VimPageTextObjectCode:
130+ return "code", true
131+ case dto.VimPageTextObjectTable:
132+ return "table", true
133+ case dto.VimPageTextObjectList:
134+ return "list", true
135+ default:
136+ return "", false
137+ }
138+}
139+
140+func vimPageRequestJSON(request dto.VimPageInteractionRequest) (string, error) {
141+ kind, ok := vimPageKindName(request.Kind)
142+ if !ok {
143+ return "", fmt.Errorf("unsupported vim page interaction: %d", request.Kind)
144+ }
145+ object := ""
146+ if request.Kind == dto.VimPageYankObject {
147+ if object, ok = vimPageObjectName(request.Object); !ok {
148+ return "", fmt.Errorf("unsupported vim text object: %d", request.Object)
149+ }
150+ }
151+ color := request.HighlightColor
152+ if !vimPageAccentColor.MatchString(color) {
153+ color = ""
154+ }
155+ encoded, err := json.Marshal(struct {
156+ Kind string `json:"kind"`
157+ Object string `json:"object,omitempty"`
158+ Color string `json:"color,omitempty"`
159+ }{Kind: kind, Object: object, Color: color})
160+ if err != nil {
161+ return "", err
162+ }
163+ return string(encoded), nil
164+}
165+
166+// jsString renders s as a JavaScript string literal (JSON is valid JS).
167+func jsString(s string) string {
168+ encoded, _ := json.Marshal(s)
169+ return string(encoded)
170+}
171+
172+// vimPageStartScript installs the runtime when absent and starts an
173+// interaction. The token is a per-interaction secret, not the shared bridge
174+// nonce, so a page that intercepts the call only learns a token valid for the
175+// interaction the user just started.
176+func vimPageStartScript(token, request string) string {
177+ return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage.start(" + jsString(token) + ", " + request + ");"
178+}
179+
180+// vimPageCallScript invokes key or cancel on the installed runtime without
181+// shipping it again. When the runtime is gone (the document was replaced), it
182+// reports the end of the token so Go releases the key capture.
183+func vimPageCallScript(method, token string, args ...string) string {
184+ call := append([]string{jsString(token)}, args...)
185+ return "window.__dumberVimPage ? window.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ") : " + vimPageEndScript(token)
186+}
187+
188+// vimPageEndScript posts an end message for token over the same bridge the
189+// runtime uses.
190+func vimPageEndScript(token string) string {
191+ body, _ := json.Marshal(map[string]string{"token": token, "type": vimPageMessageEnd})
192+ return `window.fetch("dumb:///api/vim-page", {method: "POST", headers: {"X-Dumber-Body": ` +
193+ jsString(base64.StdEncoding.EncodeToString(body)) + `}}).catch(() => {});`
194+}
195+
196+// armVimPageInteraction issues a fresh token for a new interaction and
197+// invalidates any previous one, so late results from it are rejected.
198+func (wv *WebView) armVimPageInteraction(kind dto.VimPageInteractionKind) (string, error) {
199+ token := newBridgeNonce()
200+ if token == "" {
201+ return "", errVimPageTokenUnavailable
202+ }
203+ wv.mu.Lock()
204+ wv.vimPageToken = token
205+ wv.vimPageKind = kind
206+ wv.vimPageCopyAllowance = 0
207+ wv.vimPageGeneration++
208+ wv.mu.Unlock()
209+ return token, nil
210+}
211+
212+func (wv *WebView) currentVimPageToken() string {
213+ wv.mu.RLock()
214+ defer wv.mu.RUnlock()
215+ return wv.vimPageToken
216+}
217+
218+// disarmVimPageInteraction clears the active token. It reports whether a
219+// key-capturing interaction was active and the generation it belonged to.
220+func (wv *WebView) disarmVimPageInteraction() (bool, uint64) {
221+ wv.mu.Lock()
222+ defer wv.mu.Unlock()
223+ captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys()
224+ wv.vimPageToken = ""
225+ wv.vimPageKind = 0
226+ wv.vimPageCopyAllowance = 0
227+ return captured, wv.vimPageGeneration
228+}
229+
230+// notifyVimPageInteractionEnded runs the ended callback on GTK unless a newer
231+// interaction was armed in the meantime (keys queued ahead of the idle).
232+func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) {
233+ wv.mu.RLock()
234+ cb := wv.callbacks
235+ wv.mu.RUnlock()
236+ if cb == nil || cb.OnVimPageInteractionEnded == nil {
237+ return
238+ }
239+ ended := cb.OnVimPageInteractionEnded
240+ wv.runOnGTK(func() {
241+ wv.mu.RLock()
242+ current := wv.vimPageGeneration
243+ wv.mu.RUnlock()
244+ if current == generation {
245+ ended()
246+ }
247+ })
248+}
249+
250+// vimPageResultEndsInteraction reports whether a result spends the token.
251+// The caret and visual flow is long-lived: its mode changes and copies leave
252+// the interaction running so copies can be chained. Copies stay bounded by
253+// the user's own y/Enter presses (see vimPageCopyAllowance), not by the
254+// token. Every other result is terminal, so a token grants exactly one.
255+func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool {
256+ if kind != dto.VimPageVisual {
257+ return true
258+ }
259+ return messageType != vimPageMessageMode && messageType != vimPageMessageCopy
260+}
261+
262+// notifyVimPageModeChanged reports a sub-mode change on GTK unless a newer
263+// interaction was armed in the meantime.
264+func (wv *WebView) notifyVimPageModeChanged(generation uint64, mode dto.VimPageMode) {
265+ wv.mu.RLock()
266+ cb := wv.callbacks
267+ wv.mu.RUnlock()
268+ if cb == nil || cb.OnVimPageModeChanged == nil {
269+ return
270+ }
271+ changed := cb.OnVimPageModeChanged
272+ wv.runOnGTK(func() {
273+ wv.mu.RLock()
274+ current := wv.vimPageGeneration
275+ wv.mu.RUnlock()
276+ if current == generation {
277+ changed(mode)
278+ }
279+ })
280+}
281+
282+// vimPageResultAllowed ties each result type to the interaction the user
283+// started, so a hijacked token only yields the kind of result requested.
284+func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) bool {
285+ switch messageType {
286+ case vimPageMessageEnd:
287+ return true
288+ case vimPageMessageOpenNew:
289+ return kind == dto.VimPageHintFollow || kind == dto.VimPageHintFollowNew
290+ case vimPageMessageCopy:
291+ return kind == dto.VimPageYankObject || kind == dto.VimPageHintYankURL || kind == dto.VimPageVisual
292+ case vimPageMessageMode:
293+ return kind == dto.VimPageVisual
294+ default:
295+ return false
296+ }
297+}
298+
299+// StartVimPageInteraction starts link hints, visual selection, or a text-object yank.
300+func (wv *WebView) StartVimPageInteraction(_ context.Context, request dto.VimPageInteractionRequest) error {
301+ if wv == nil || wv.destroyed.Load() {
302+ return errDestroyed
303+ }
304+ encoded, err := vimPageRequestJSON(request)
305+ if err != nil {
306+ return err
307+ }
308+ token, err := wv.armVimPageInteraction(request.Kind)
309+ if err != nil {
310+ return err
311+ }
312+ if err := wv.scheduleJavaScript(vimPageStartScript(token, encoded)); err != nil {
313+ wv.disarmVimPageInteraction()
314+ return err
315+ }
316+ return nil
317+}
318+
319+// vimPageKeyRequestsCopy reports whether a forwarded key may make a visual
320+// selection copy: y and Enter yank in the caret and visual flow.
321+func vimPageKeyRequestsCopy(key string) bool {
322+ return key == "y" || key == "<Return>"
323+}
324+
325+// grantVimPageCopyAllowance records that the user pressed a copy key for the
326+// armed visual interaction, so one visual copy result will be accepted. The
327+// allowance is capped at one: y/Enter presses the page ignores (caret or hint
328+// phase, empty selection) must not bank copies for a page holding the token.
329+func (wv *WebView) grantVimPageCopyAllowance(token, key string) {
330+ if !vimPageKeyRequestsCopy(key) {
331+ return
332+ }
333+ wv.mu.Lock()
334+ if wv.vimPageToken == token && wv.vimPageKind == dto.VimPageVisual {
335+ wv.vimPageCopyAllowance = 1
336+ }
337+ wv.mu.Unlock()
338+}
339+
340+// SendVimPageKey forwards one canonical key to the active page interaction.
341+func (wv *WebView) SendVimPageKey(_ context.Context, key string) error {
342+ if wv == nil || wv.destroyed.Load() {
343+ return errDestroyed
344+ }
345+ token := wv.currentVimPageToken()
346+ if key == "" || token == "" {
347+ return nil
348+ }
349+ wv.grantVimPageCopyAllowance(token, key)
350+ return wv.scheduleJavaScript(vimPageCallScript("key", token, jsString(key)))
351+}
352+
353+// CancelVimPageInteraction removes any hint overlay or visual selection.
354+func (wv *WebView) CancelVimPageInteraction(_ context.Context) error {
355+ if wv == nil || wv.destroyed.Load() {
356+ return errDestroyed
357+ }
358+ token := wv.currentVimPageToken()
359+ wv.disarmVimPageInteraction()
360+ if token == "" {
361+ return nil
362+ }
363+ return wv.scheduleJavaScript(vimPageCallScript("cancel", token))
364+}
365+
366+// endVimPageInteractionOnNavigation drops an interaction whose document is
367+// being replaced; its runtime can no longer report the end itself.
368+func (wv *WebView) endVimPageInteractionOnNavigation() {
369+ if captured, generation := wv.disarmVimPageInteraction(); captured {
370+ wv.notifyVimPageInteractionEnded(generation)
371+ }
372+}
373+
374+// handleVimPageBridge routes one runtime result from the trusted bridge.
375+func (e *Engine) handleVimPageBridge(browser purecef.Browser, payload vimPageBridgePayload) {
376+ e.withBridgeSourceWebView(browser, "", "vim-page", func(wv *WebView) {
377+ wv.handleVimPageResult(payload)
378+ })
379+}
380+
381+// spendVimPageResultLocked validates the token of one result and applies its
382+// effect on the armed state: a terminal result disarms the interaction, and a
383+// visual copy spends one allowance. A visual copy is non-terminal, so the
384+// token alone would let a page that read it write the clipboard repeatedly;
385+// each accepted copy needs a y/Enter key the user pressed. wv.mu must be held.
386+func (wv *WebView) spendVimPageResultLocked(payload vimPageBridgePayload) (valid, ends, copyDenied bool) {
387+ armed := wv.vimPageToken
388+ valid = armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1
389+ if !valid {
390+ return false, false, false
391+ }
392+ kind := wv.vimPageKind
393+ if payload.Type == vimPageMessageCopy && kind == dto.VimPageVisual {
394+ if wv.vimPageCopyAllowance <= 0 {
395+ return true, false, true
396+ }
397+ wv.vimPageCopyAllowance--
398+ }
399+ ends = vimPageResultEndsInteraction(kind, payload.Type)
400+ if ends {
401+ wv.vimPageToken = ""
402+ wv.vimPageKind = 0
403+ wv.vimPageCopyAllowance = 0
404+ }
405+ return true, ends, false
406+}
407+
408+// handleVimPageResult accepts a result only for the interaction the user
409+// armed; results from stale or forged interactions are dropped.
410+func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
411+ if wv == nil || wv.destroyed.Load() {
412+ return
413+ }
414+ wv.mu.Lock()
415+ cb := wv.callbacks
416+ sourceURI := wv.uri
417+ kind := wv.vimPageKind
418+ generation := wv.vimPageGeneration
419+ valid, ends, copyDenied := wv.spendVimPageResultLocked(payload)
420+ wv.mu.Unlock()
421+ if !valid {
422+ logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token")
423+ return
424+ }
425+ if copyDenied {
426+ logging.FromContext(wv.ctx).Debug().Msg("cef: vim page copy rejected — no user copy key pending")
427+ return
428+ }
429+
430+ if ends {
431+ logging.FromContext(wv.ctx).Debug().
432+ Str("type", payload.Type).
433+ Str("reason", payload.Reason).
434+ Msg("cef: vim page interaction finished")
435+ if kind.CapturesKeys() {
436+ wv.notifyVimPageInteractionEnded(generation)
437+ }
438+ }
439+ if !vimPageResultAllowed(kind, payload.Type) {
440+ logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — not allowed for interaction")
441+ return
442+ }
443+
444+ switch payload.Type {
445+ case vimPageMessageMode:
446+ wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode))
447+ case vimPageMessageCopy:
448+ // Results arrive on the CEF IO thread; the clipboard write belongs on GTK.
449+ if engine := wv.engine; engine != nil {
450+ id, text := wv.id, payload.Text
451+ wv.runOnGTK(func() { engine.handleExplicitClipboardBridgeText(id, "copy", text) })
452+ }
453+ case vimPageMessageOpenNew:
454+ if !isNavigableVimHintURL(payload.URL, sourceURI) {
455+ logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open rejected — unsupported url")
456+ return
457+ }
458+ if cb == nil || cb.OnLinkMiddleClick == nil {
459+ logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open skipped — handler not available")
460+ return
461+ }
462+ target := payload.URL
463+ wv.runOnGTK(func() { cb.OnLinkMiddleClick(target) })
464+ }
465+}
A internal/infrastructure/cef/vim_page_runtime.js +923 -0
@@ -0,0 +1,923 @@
1+// Dumber Vim Mode page runtime: link hints, caret and visual selection,
2+// text-object yanks. Installed once per document; Go calls
3+// window.__dumberVimPage.* with a per-interaction token and accepts results
4+// at dumb:///api/vim-page only while that token is armed.
5+//
6+// Link hints and yanks report exactly one terminal message (copy, open-new, or
7+// end). The caret/visual interaction is long-lived: it reports "mode" messages
8+// as it moves between hints, caret, visual, and visual line, "copy" messages
9+// that do not end it, and finally one "end".
10+//
11+// The caret and visual selection logic follows Vimium's mode_visual.js
12+// (MIT, Copyright (c) Phil Crosby, Ilya Sukhar): character-by-character word
13+// motions to avoid the native selection.modify word bugs on Linux (Vimium
14+// #1441), focus-direction detection, and the line-mode extension. The text
15+// anchor hints and the visible caret follow the flow of Surfingkeys' visual
16+// mode.
17+(() => {
18+ if (window.__dumberVimPage) return;
19+
20+ const nativeFetch = window.fetch.bind(window);
21+ const HINT_CHARS = "sadfjklewcmpgh";
22+ const ALL_TARGETS = [
23+ "a[href]", "button", "input:not([type=\"hidden\"])", "select", "textarea", "summary",
24+ "[role=\"button\"]", "[role=\"link\"]", "[role=\"tab\"]", "[role=\"checkbox\"]",
25+ "[role=\"menuitem\"]", "[onclick]", "[contenteditable=\"\"]", "[contenteditable=\"true\"]",
26+ "[tabindex]:not([tabindex=\"-1\"])",
27+ ].join(",");
28+ const OBJECT_SELECTORS = {
29+ paragraph: ["p"],
30+ code: ["pre", "code"],
31+ table: ["table"],
32+ list: ["ul,ol,dl"],
33+ };
34+ // Motions that map directly to Selection.modify. w, e, b, ^ and gg are
35+ // handled in code.
36+ const CARET_MOTIONS = {
37+ h: ["backward", "character"], "<Left>": ["backward", "character"],
38+ l: ["forward", "character"], "<Right>": ["forward", "character"],
39+ j: ["forward", "line"], "<Down>": ["forward", "line"],
40+ k: ["backward", "line"], "<Up>": ["backward", "line"],
41+ "0": ["backward", "lineboundary"], "$": ["forward", "lineboundary"],
42+ "(": ["backward", "sentence"], ")": ["forward", "sentence"],
43+ "{": ["backward", "paragraph"], "}": ["forward", "paragraph"],
44+ G: ["forward", "documentboundary"],
45+ };
46+ const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
47+ const MAX_MOTION_STEPS = 20000;
48+ const LONG_TEXT_LENGTH = 50;
49+ const MODE_NAMES = { hints: "hints", caret: "caret", visual: "visual", line: "visual-line" };
50+
51+ let state = null;
52+
53+ // deepQueryAll matches selector across the document and every open shadow
54+ // root; component-heavy sites (for example Reddit) render content there.
55+ function deepQueryAll(selector, root = document) {
56+ const results = Array.from(root.querySelectorAll(selector));
57+ for (const element of root.querySelectorAll("*")) {
58+ if (element.shadowRoot) results.push(...deepQueryAll(selector, element.shadowRoot));
59+ }
60+ return results;
61+ }
62+
63+ // lightTextNodes yields non-blank text nodes in document order. It does not
64+ // enter shadow roots on purpose: Chromium reports Selection positions inside
65+ // a shadow tree relative to its host, so a caret anchored there would sit in
66+ // the wrong place. Link hints and text-object yanks still search shadow DOM.
67+ function* lightTextNodes(root) {
68+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
69+ for (let node = walker.currentNode; node; node = walker.nextNode()) {
70+ if (node.nodeType === Node.TEXT_NODE && node.data.trim()) yield node;
71+ }
72+ }
73+
74+ function post(token, message) {
75+ let encoded = "";
76+ try {
77+ encoded = btoa(unescape(encodeURIComponent(JSON.stringify(Object.assign({ token }, message)))));
78+ } catch (_) {
79+ return;
80+ }
81+ nativeFetch("dumb:///api/vim-page", {
82+ method: "POST",
83+ headers: { "X-Dumber-Body": encoded },
84+ }).catch(() => {});
85+ }
86+
87+ function onScreen(rect) {
88+ return rect.bottom > 0 && rect.right > 0 && rect.top < window.innerHeight && rect.left < window.innerWidth;
89+ }
90+
91+ // visibleRect returns a client rect of element inside the viewport. The
92+ // cheap bounding-box test runs first: most elements of a long page are
93+ // off-screen, and closest/getComputedStyle are the expensive part.
94+ function visibleRect(element) {
95+ if (!(element instanceof Element) || !element.isConnected) return null;
96+ if (!onScreen(element.getBoundingClientRect())) return null;
97+ if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null;
98+ const style = window.getComputedStyle(element);
99+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null;
100+ for (const rect of element.getClientRects()) {
101+ if (rect.width < 1 || rect.height < 1) continue;
102+ if (!onScreen(rect)) continue;
103+ return rect;
104+ }
105+ return null;
106+ }
107+
108+ function isEditable(element) {
109+ if (element.isContentEditable) return true;
110+ if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) return true;
111+ if (!(element instanceof HTMLInputElement)) return false;
112+ return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(element.type);
113+ }
114+
115+ function flash(element, color) {
116+ if (!(element instanceof HTMLElement)) return;
117+ const previousOutline = element.style.outline;
118+ const previousOffset = element.style.outlineOffset;
119+ element.style.outline = "3px solid " + color;
120+ element.style.outlineOffset = "3px";
121+ setTimeout(() => {
122+ element.style.outline = previousOutline;
123+ element.style.outlineOffset = previousOffset;
124+ }, 400);
125+ }
126+
127+ // --- Link hints -----------------------------------------------------------
128+
129+ function hintLabels(count) {
130+ let length = 1;
131+ while (Math.pow(HINT_CHARS.length, length) < count) length++;
132+ const labels = [];
133+ for (let i = 0; i < count; i++) {
134+ let n = i;
135+ let label = "";
136+ for (let j = 0; j < length; j++) {
137+ label = HINT_CHARS[n % HINT_CHARS.length] + label;
138+ n = Math.floor(n / HINT_CHARS.length);
139+ }
140+ labels.push(label);
141+ }
142+ return labels;
143+ }
144+
145+ function hintTargets(linksOnly) {
146+ const seen = new Set();
147+ const targets = [];
148+ for (const element of deepQueryAll(linksOnly ? "a[href]" : ALL_TARGETS)) {
149+ if (seen.has(element) || element.disabled) continue;
150+ const rect = visibleRect(element);
151+ if (!rect) continue;
152+ if (!reachable(element, rect)) continue;
153+ seen.add(element);
154+ targets.push({ element, rect });
155+ }
156+ return targets;
157+ }
158+
159+ // buildHintOverlay renders one label per item at its rect and arms the
160+ // typed-label filter. offsetLeft puts labels before the rect so they do not
161+ // cover the text a hint points at.
162+ function buildHintOverlay(items, color, offsetLeft) {
163+ const labels = hintLabels(items.length);
164+ const root = document.createElement("div");
165+ root.setAttribute("data-dumber-vim-hints", "");
166+ root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;";
167+ const labelWidth = labels.length > 0 ? labels[0].length * 8 + 10 : 0;
168+ const hints = items.map((item, index) => {
169+ const node = document.createElement("span");
170+ const left = offsetLeft && item.rect.left >= labelWidth ? item.rect.left - labelWidth : Math.max(item.rect.left, 0);
171+ node.style.cssText = [
172+ "position:fixed", "left:" + left + "px",
173+ "top:" + Math.max(item.rect.top, 0) + "px", "padding:1px 4px", "border-radius:3px",
174+ "font:bold 11px/1.3 monospace", "text-transform:uppercase", "color:#111",
175+ "background:" + color, "box-shadow:0 1px 3px rgba(0,0,0,.4)",
176+ ].join(";");
177+ node.textContent = labels[index];
178+ root.appendChild(node);
179+ return { label: labels[index], item, node };
180+ });
181+ document.documentElement.appendChild(root);
182+ state.root = root;
183+ state.hints = hints;
184+ state.typed = "";
185+ }
186+
187+ function removeHintOverlay() {
188+ if (state.root) state.root.remove();
189+ state.root = null;
190+ state.hints = [];
191+ }
192+
193+ function startHints(kind, color) {
194+ const targets = hintTargets(kind !== "hint-follow");
195+ if (targets.length === 0) return false;
196+ buildHintOverlay(targets, color, false);
197+ return true;
198+ }
199+
200+ function renderHintFilter() {
201+ for (const hint of state.hints) {
202+ const visible = hint.label.startsWith(state.typed);
203+ hint.node.style.display = visible ? "" : "none";
204+ if (visible) {
205+ hint.node.textContent = "";
206+ const typed = document.createElement("span");
207+ typed.style.opacity = "0.45";
208+ typed.textContent = state.typed;
209+ hint.node.append(typed, hint.label.slice(state.typed.length));
210+ }
211+ }
212+ }
213+
214+ // linkURL resolves the destination of an HTML or SVG anchor. An SVG <a>
215+ // exposes href as an SVGAnimatedString that is not resolved against the
216+ // document, so it is resolved here; Go validates the result before opening it.
217+ function linkURL(element) {
218+ if (element instanceof HTMLAnchorElement) return element.href;
219+ const animated = element.href;
220+ if (!animated || typeof animated.baseVal !== "string" || !animated.baseVal) return "";
221+ try {
222+ return new URL(animated.baseVal, document.baseURI).href;
223+ } catch (_) {
224+ return "";
225+ }
226+ }
227+
228+ function linkTarget(element) {
229+ const target = element.target;
230+ return target && typeof target === "object" ? target.baseVal : target;
231+ }
232+
233+ function clickElement(element) {
234+ if (typeof element.click === "function") {
235+ element.click();
236+ return;
237+ }
238+ element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
239+ }
240+
241+ // activateHint returns the terminal message for the chosen hint.
242+ function activateHint(kind, element) {
243+ const url = linkURL(element);
244+ if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" };
245+ if (kind === "hint-follow-new" || (url && linkTarget(element) === "_blank")) {
246+ return url ? { type: "open-new", url } : { type: "end" };
247+ }
248+ if (typeof element.focus === "function") element.focus({ preventScroll: true });
249+ if (!isEditable(element)) clickElement(element);
250+ return { type: "end" };
251+ }
252+
253+ // hintKey handles typing labels for link hints and for the caret's
254+ // text-anchor hints (the "visual" kind), where Escape places the caret at a
255+ // default position instead of ending.
256+ function hintKey(key) {
257+ const textAnchors = state.kind === "visual";
258+ if (key === "<Escape>") return textAnchors ? placeCaretAtFallback() : finish({ type: "end" });
259+ if (key === "<BackSpace>") {
260+ state.typed = state.typed.slice(0, -1);
261+ return renderHintFilter();
262+ }
263+ if (key.length !== 1) return;
264+ const typed = state.typed + key.toLowerCase();
265+ const matches = state.hints.filter((hint) => hint.label.startsWith(typed));
266+ if (matches.length === 0) return;
267+ state.typed = typed;
268+ if (matches.length === 1 && matches[0].label === typed) {
269+ const item = matches[0].item;
270+ // Remove the overlay before activation so the click hits the page.
271+ removeHintOverlay();
272+ if (textAnchors) return enterCaretAt(item.node, item.offset);
273+ finish(activateHint(state.kind, item.element));
274+ return;
275+ }
276+ renderHintFilter();
277+ }
278+
279+ // --- Caret and visual selection ------------------------------------------
280+ //
281+ // One interaction moves between four phases: "hints" (text-anchor labels),
282+ // "caret" (collapsed selection plus a visible cursor), "visual" (charwise
283+ // selection), and "line" (linewise selection). Each phase change is reported
284+ // to Go as a "mode" message so the indicator follows it.
285+
286+ const SKIP_TEXT_PARENTS = /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|OPTION)$/;
287+
288+ function setPhase(phase) {
289+ if (state.phase === phase) return;
290+ state.phase = phase;
291+ post(state.token, { type: "mode", mode: MODE_NAMES[phase] });
292+ }
293+
294+ // reachable reports whether element is what the user would hit when pointing
295+ // at rect, so hints skip content covered by overlays.
296+ function reachable(element, rect) {
297+ const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
298+ const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
299+ const root = element.getRootNode();
300+ const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
301+ return !hit || element.contains(hit) || hit.contains(element) || !!(hit.shadowRoot && hit.shadowRoot.contains(element));
302+ }
303+
304+ // blockOf returns the nearest non-inline ancestor, so a paragraph with inline
305+ // markup offers one anchor instead of one per fragment.
306+ function blockOf(element) {
307+ let current = element;
308+ while (current.parentElement && /^(inline|contents)$/.test(window.getComputedStyle(current).display || "")) {
309+ current = current.parentElement;
310+ }
311+ return current;
312+ }
313+
314+ function offsetAtPoint(node, x, y) {
315+ let container = null;
316+ let offset = 0;
317+ if (document.caretRangeFromPoint) {
318+ const range = document.caretRangeFromPoint(x, y);
319+ if (range) { container = range.startContainer; offset = range.startOffset; }
320+ } else if (document.caretPositionFromPoint) {
321+ const position = document.caretPositionFromPoint(x, y);
322+ if (position) { container = position.offsetNode; offset = position.offset; }
323+ }
324+ return container === node ? offset : -1;
325+ }
326+
327+ // firstVisibleAnchor finds where visible text of node starts: the first
328+ // non-blank character when its first line is visible, otherwise the start of
329+ // the first fully visible line.
330+ function firstVisibleAnchor(node, element) {
331+ const range = document.createRange();
332+ range.selectNodeContents(node);
333+ const rects = Array.from(range.getClientRects());
334+ for (let i = 0; i < rects.length; i++) {
335+ const rect = rects[i];
336+ if (rect.width < 1 || rect.height < 1) continue;
337+ if (rect.top < 0 || rect.left < 0 || rect.bottom > window.innerHeight || rect.right > window.innerWidth) continue;
338+ const offset = i === 0 ? Math.max(node.data.search(/\S/), 0) : offsetAtPoint(node, rect.left + 1, rect.top + rect.height / 2);
339+ if (offset < 0 || !visibleRect(element) || !reachable(element, rect)) continue;
340+ return { node, offset, rect, element };
341+ }
342+ return null;
343+ }
344+
345+ // visibleTextAnchors yields visible text starts in document order. A text
346+ // node whose parent element is off-screen cannot be visible, so that cheap
347+ // check (cached per element) runs before the range measurements.
348+ function* visibleTextAnchors(minLength) {
349+ const onScreenElements = new Map();
350+ for (const node of lightTextNodes(document.body || document.documentElement)) {
351+ const element = node.parentElement;
352+ if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) continue;
353+ if (node.data.trim().length < minLength) continue;
354+ if (!onScreenElements.has(element)) onScreenElements.set(element, onScreen(element.getBoundingClientRect()));
355+ if (!onScreenElements.get(element)) continue;
356+ const anchor = firstVisibleAnchor(node, element);
357+ if (anchor) yield anchor;
358+ }
359+ }
360+
361+ // collectTextAnchors lists visible text starts in document order. perBlock
362+ // keeps the first anchor of each block.
363+ function collectTextAnchors({ perBlock, limit }) {
364+ const anchors = [];
365+ const blocks = new Set();
366+ for (const anchor of visibleTextAnchors(1)) {
367+ if (perBlock) {
368+ const block = blockOf(anchor.element);
369+ if (blocks.has(block)) continue;
370+ blocks.add(block);
371+ }
372+ anchors.push(anchor);
373+ if (anchors.length >= limit) break;
374+ }
375+ return anchors;
376+ }
377+
378+ // Esc during the text-anchor hints: Vimium's heuristic picks the first
379+ // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are
380+ // likely banners); any visible text is the fallback. One pass finds both.
381+ function fallbackAnchor() {
382+ let first = null;
383+ for (const anchor of visibleTextAnchors(1)) {
384+ if (anchor.node.data.trim().length >= LONG_TEXT_LENGTH) return anchor;
385+ if (!first) first = anchor;
386+ }
387+ return first;
388+ }
389+
390+ function placeCaretAtFallback() {
391+ const anchor = fallbackAnchor();
392+ removeHintOverlay();
393+ if (!anchor) return finish({ type: "end", reason: "no-visible-text" });
394+ enterCaretAt(anchor.node, anchor.offset);
395+ }
396+
397+ // The accent paints selections so they stand out on pages that restyle
398+ // ::selection; the style is removed with the interaction.
399+ function styleVisual(color) {
400+ const style = document.createElement("style");
401+ style.setAttribute("data-dumber-vim-visual", "");
402+ style.textContent = "::selection { background: " + color + " !important; color: #111 !important; }";
403+ document.documentElement.appendChild(style);
404+ state.style = style;
405+ }
406+
407+ // focusRect measures the selection focus. A collapsed range can report an
408+ // empty rect at node boundaries, so the adjacent character is measured then.
409+ function focusRect(selection) {
410+ const node = selection.focusNode;
411+ if (!node) return null;
412+ const offset = selection.focusOffset;
413+ const range = document.createRange();
414+ range.setStart(node, offset);
415+ range.collapse(true);
416+ const first = (r) => Array.from(r.getClientRects()).find((rect) => rect.height > 0) || null;
417+ const direct = first(range);
418+ if (direct) return direct;
419+ if (node.nodeType === Node.TEXT_NODE) {
420+ if (offset < node.data.length) {
421+ range.setEnd(node, offset + 1);
422+ const next = first(range);
423+ if (next) return { left: next.left, right: next.left, top: next.top, bottom: next.bottom, width: 0, height: next.height };
424+ }
425+ if (offset > 0) {
426+ const before = document.createRange();
427+ before.setStart(node, offset - 1);
428+ before.setEnd(node, offset);
429+ const previous = first(before);
430+ if (previous) return { left: previous.right, right: previous.right, top: previous.top, bottom: previous.bottom, width: 0, height: previous.height };
431+ }
432+ }
433+ const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
434+ const rect = element && element.getBoundingClientRect();
435+ return rect && rect.height > 0 ? rect : null;
436+ }
437+
438+ // The caret overlay is a blinking accent bar at the focus. It is page
439+ // content, so it follows scrolling and is removed with the interaction.
440+ function beginCaretUI(selection) {
441+ if (!state.style) styleVisual(state.color);
442+ if (!state.cursor) {
443+ const cursor = document.createElement("div");
444+ cursor.setAttribute("data-dumber-vim-caret", "");
445+ cursor.style.cssText = [
446+ "position:fixed", "width:4px", "pointer-events:none", "z-index:2147483647",
447+ "background:" + state.color, "border-radius:2px",
448+ "box-shadow:0 0 0 2px rgba(17,17,17,.85), 0 0 10px 2px " + state.color,
449+ ].join(";");
450+ document.documentElement.appendChild(cursor);
451+ if (typeof cursor.animate === "function") {
452+ cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity });
453+ }
454+ state.cursor = cursor;
455+ // Scroll and resize fire at frame rate or faster; repaint once per frame.
456+ state.onViewportChange = () => {
457+ if (!state || state.frame) return;
458+ state.frame = window.requestAnimationFrame(() => {
459+ if (!state) return;
460+ state.frame = 0;
461+ updateCursor(window.getSelection());
462+ });
463+ };
464+ window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true });
465+ window.addEventListener("resize", state.onViewportChange, { passive: true });
466+ }
467+ updateCursor(selection);
468+ }
469+
470+ function updateCursor(selection) {
471+ if (!state || !state.cursor || !selection) return;
472+ const rect = focusRect(selection);
473+ const visible = rect && rect.bottom > 0 && rect.top < window.innerHeight && rect.right > -4 && rect.left < window.innerWidth;
474+ state.cursor.style.display = visible ? "" : "none";
475+ if (!visible) return;
476+ // Taller than the text line so the caret reads at a glance.
477+ const height = Math.max(rect.height, 14) + 6;
478+ state.cursor.style.left = Math.round(rect.left - 2) + "px";
479+ state.cursor.style.top = Math.round(rect.top + (rect.height - height) / 2) + "px";
480+ state.cursor.style.height = Math.round(height) + "px";
481+ }
482+
483+ function revealFocus(selection) {
484+ const rect = focusRect(selection);
485+ if (rect) {
486+ const margin = 40;
487+ if (rect.top < margin) window.scrollBy(0, rect.top - margin);
488+ else if (rect.bottom > window.innerHeight - margin) window.scrollBy(0, rect.bottom - window.innerHeight + margin);
489+ }
490+ updateCursor(selection);
491+ }
492+
493+ function enterCaretAt(node, offset) {
494+ const selection = window.getSelection();
495+ selection.setBaseAndExtent(node, offset, node, offset);
496+ state.count = "";
497+ state.pendingG = false;
498+ beginCaretUI(selection);
499+ setPhase("caret");
500+ revealFocus(selection);
501+ }
502+
503+ // A visible range selection is used as is, as in Vimium; a selection that
504+ // scrolled out of view is stale and ignored.
505+ function hasVisibleRangeSelection(selection) {
506+ if (selection.rangeCount === 0 || selection.isCollapsed) return false;
507+ const rect = selection.getRangeAt(0).getBoundingClientRect();
508+ return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
509+ }
510+
511+ function startVisual(color) {
512+ const selection = window.getSelection();
513+ if (!selection) return false;
514+ state.color = color;
515+ state.count = "";
516+ state.pendingG = false;
517+ if (hasVisibleRangeSelection(selection)) {
518+ beginCaretUI(selection);
519+ setPhase("visual");
520+ return true;
521+ }
522+ const anchors = collectTextAnchors({ perBlock: true, limit: HINT_CHARS.length * HINT_CHARS.length });
523+ if (anchors.length === 0) return false;
524+ buildHintOverlay(anchors, color, true);
525+ setPhase("hints");
526+ return true;
527+ }
528+
529+ // --- Selection primitives (after Vimium's Movement class) ---
530+
531+ function isBackward(selection) {
532+ if (selection.anchorNode === selection.focusNode && selection.anchorOffset === selection.focusOffset) return false;
533+ const range = document.createRange();
534+ range.setStart(selection.anchorNode, selection.anchorOffset);
535+ range.setEnd(selection.focusNode, selection.focusOffset);
536+ return range.collapsed;
537+ }
538+
539+ function swapEnds(selection) {
540+ selection.setBaseAndExtent(selection.focusNode, selection.focusOffset, selection.anchorNode, selection.anchorOffset);
541+ }
542+
543+ function collapseToFocus(selection) {
544+ selection.collapse(selection.focusNode, selection.focusOffset);
545+ }
546+
547+ function focusMoved(selection, node, offset) {
548+ return selection.focusNode !== node || selection.focusOffset !== offset;
549+ }
550+
551+ // extendByOne extends the focus one character and reports whether it moved
552+ // (1) or could not (0). It compares focus positions: reading the whole
553+ // selection's text per step made long selections quadratic.
554+ function extendByOne(selection, direction) {
555+ const node = selection.focusNode;
556+ const offset = selection.focusOffset;
557+ selection.modify("extend", direction, "character");
558+ return focusMoved(selection, node, offset) ? 1 : 0;
559+ }
560+
561+ // characterAtFocus reads the character next to the focus from its text node.
562+ // It returns null when the focus is not inside a text node or sits on its
563+ // boundary, where the neighbor lives in another node.
564+ function characterAtFocus(selection, forward) {
565+ const node = selection.focusNode;
566+ const offset = selection.focusOffset;
567+ if (!node || node.nodeType !== Node.TEXT_NODE) return null;
568+ const data = node.data;
569+ if (forward) return offset < data.length ? String.fromCodePoint(data.codePointAt(offset)) : null;
570+ if (offset <= 0) return null;
571+ const unit = data.charCodeAt(offset - 1);
572+ const paired = offset > 1 && unit >= 0xdc00 && unit <= 0xdfff;
573+ return paired ? data.slice(offset - 2, offset) : data[offset - 1];
574+ }
575+
576+ // probeCharacter finds the character across a node boundary by stepping the
577+ // focus over it and back. Landing on a text node boundary or outside a text
578+ // node means a block boundary was crossed, which counts as a line break.
579+ function probeCharacter(selection, direction) {
580+ const node = selection.focusNode;
581+ const offset = selection.focusOffset;
582+ selection.modify("extend", direction, "character");
583+ if (!focusMoved(selection, node, offset)) return undefined;
584+ const landed = selection.focusNode;
585+ const landedOffset = selection.focusOffset;
586+ let character = "\n";
587+ if (landed && landed.nodeType === Node.TEXT_NODE) {
588+ if (direction === "forward" && landedOffset > 0) character = landed.data[landedOffset - 1];
589+ else if (direction === "backward" && landedOffset < landed.data.length) character = landed.data[landedOffset];
590+ }
591+ selection.modify("extend", direction === "forward" ? "backward" : "forward", "character");
592+ return character;
593+ }
594+
595+ // nextCharacter returns the character after the focus without moving it, or
596+ // undefined at the end of the document.
597+ function nextCharacter(selection) {
598+ const character = characterAtFocus(selection, true);
599+ return character === null ? probeCharacter(selection, "forward") : character;
600+ }
601+
602+ // previousCharacter returns the character before the focus without moving it.
603+ function previousCharacter(selection) {
604+ const character = characterAtFocus(selection, false);
605+ return character === null ? probeCharacter(selection, "backward") : character;
606+ }
607+
608+ function isWordCharacter(character) {
609+ return character !== undefined && WORD_CHARACTER.test(character);
610+ }
611+
612+ // Word motions run character by character: the native word granularity
613+ // differs between Linux and other platforms (Vimium #1441).
614+ function wordForward(selection, wordFirst) {
615+ const skipWord = () => {
616+ for (let i = 0; i < MAX_MOTION_STEPS && isWordCharacter(nextCharacter(selection)); i++) {
617+ if (extendByOne(selection, "forward") === 0) return false;
618+ }
619+ return true;
620+ };
621+ const skipOther = () => {
622+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
623+ const character = nextCharacter(selection);
624+ if (character === undefined || isWordCharacter(character)) return true;
625+ if (extendByOne(selection, "forward") === 0) return false;
626+ }
627+ return true;
628+ };
629+ // w: finish the current word, then skip to the next one. e: skip to a
630+ // word, then finish it.
631+ if (wordFirst) {
632+ if (skipWord()) skipOther();
633+ } else if (skipOther()) {
634+ skipWord();
635+ }
636+ }
637+
638+ function wordBackward(selection) {
639+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
640+ const character = previousCharacter(selection);
641+ if (character === undefined || isWordCharacter(character)) break;
642+ if (extendByOne(selection, "backward") === 0) return;
643+ }
644+ for (let i = 0; i < MAX_MOTION_STEPS && isWordCharacter(previousCharacter(selection)); i++) {
645+ if (extendByOne(selection, "backward") === 0) return;
646+ }
647+ }
648+
649+ function firstNonBlank(selection, alter) {
650+ selection.modify(alter, "backward", "lineboundary");
651+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
652+ const character = nextCharacter(selection);
653+ if (character === undefined || !/[ \t\u00a0]/.test(character)) break;
654+ if (extendByOne(selection, "forward") === 0) break;
655+ }
656+ // A caret measures with extend, then settles where the measuring ended.
657+ if (alter === "move") collapseToFocus(selection);
658+ }
659+
660+ // extendToLines grows the selection so both ends sit on line boundaries.
661+ function extendToLines(selection) {
662+ const forward = !isBackward(selection);
663+ for (const direction of forward ? ["forward", "backward"] : ["backward", "forward"]) {
664+ selection.modify("extend", direction, "lineboundary");
665+ swapEnds(selection);
666+ }
667+ }
668+
669+ // moveOnce applies one motion. In caret phase the selection stays collapsed:
670+ // native granularities use "move", and the character-by-character motions
671+ // extend and then collapse to the focus.
672+ function moveOnce(selection, key) {
673+ const caret = state.phase === "caret";
674+ const alter = caret ? "move" : "extend";
675+ if (CARET_MOTIONS[key]) {
676+ selection.modify(alter, CARET_MOTIONS[key][0], CARET_MOTIONS[key][1]);
677+ return true;
678+ }
679+ switch (key) {
680+ case "w": wordForward(selection, true); break;
681+ case "e": wordForward(selection, false); break;
682+ case "b": wordBackward(selection); break;
683+ case "^": return firstNonBlank(selection, alter), true;
684+ case "gg": selection.modify(alter, "backward", "documentboundary"); return true;
685+ default: return false;
686+ }
687+ if (caret) collapseToFocus(selection);
688+ return true;
689+ }
690+
691+ function runMotion(selection, key, count) {
692+ for (let i = 0; i < count; i++) {
693+ if (state.phase === "line") {
694+ const saved = [selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset];
695+ if (!moveOnce(selection, key)) return;
696+ if (selection.isCollapsed) selection.setBaseAndExtent(...saved);
697+ extendToLines(selection);
698+ } else if (!moveOnce(selection, key)) {
699+ return;
700+ }
701+ }
702+ }
703+
704+ // --- Phase transitions ---
705+
706+ // toCaret collapses a selection onto its last selected character, so the
707+ // caret returns to the character that v included instead of drifting one
708+ // position right on every v then Escape.
709+ function toCaret(selection) {
710+ if (!selection.isCollapsed && !isBackward(selection)) extendByOne(selection, "backward");
711+ collapseToFocus(selection);
712+ setPhase("caret");
713+ revealFocus(selection);
714+ }
715+
716+ function toVisual(selection) {
717+ // Include the character under the caret, or the one before at the end.
718+ if (extendByOne(selection, "forward") === 0 && extendByOne(selection, "backward") !== 0) {
719+ // At the end of the text the last character is selected backward; flip
720+ // it so the focus stays at the end, like a forward selection.
721+ swapEnds(selection);
722+ }
723+ setPhase("visual");
724+ revealFocus(selection);
725+ }
726+
727+ function toLine(selection) {
728+ extendToLines(selection);
729+ setPhase("line");
730+ revealFocus(selection);
731+ }
732+
733+ // yankSelection copies the selection and returns to the caret at its focus,
734+ // so further copies can be chained without restarting.
735+ function yankSelection(selection) {
736+ const text = selection.toString();
737+ if (text) post(state.token, { type: "copy", text });
738+ toCaret(selection);
739+ }
740+
741+ function caretKey(key) {
742+ const selection = window.getSelection();
743+ if (!selection || selection.rangeCount === 0) return finish({ type: "end" });
744+ const phase = state.phase;
745+ if (key === "<Escape>") {
746+ state.count = "";
747+ state.pendingG = false;
748+ if (phase === "caret") {
749+ selection.removeAllRanges();
750+ return finish({ type: "end" });
751+ }
752+ return toCaret(selection);
753+ }
754+ if (key === "v") {
755+ state.count = "";
756+ state.pendingG = false;
757+ if (phase === "caret") return toVisual(selection);
758+ if (phase === "visual") return toCaret(selection);
759+ setPhase("visual");
760+ return updateCursor(selection);
761+ }
762+ if (key === "V") {
763+ state.count = "";
764+ state.pendingG = false;
765+ return phase === "line" ? toCaret(selection) : toLine(selection);
766+ }
767+ if ((key === "y" || key === "<Return>")) {
768+ state.count = "";
769+ state.pendingG = false;
770+ return phase === "caret" ? undefined : yankSelection(selection);
771+ }
772+ if (key === "o") {
773+ state.count = "";
774+ state.pendingG = false;
775+ if (phase === "caret") return;
776+ swapEnds(selection);
777+ return revealFocus(selection);
778+ }
779+ if (/^[1-9]$/.test(key) || (key === "0" && state.count !== "")) {
780+ state.count += key;
781+ return;
782+ }
783+ let motion = key;
784+ if (key === "g") {
785+ if (!state.pendingG) {
786+ state.pendingG = true;
787+ return;
788+ }
789+ motion = "gg";
790+ }
791+ state.pendingG = false;
792+ const count = Math.min(parseInt(state.count || "1", 10), 500);
793+ state.count = "";
794+ runMotion(selection, motion, count);
795+ revealFocus(selection);
796+ }
797+
798+ // --- Text objects ---------------------------------------------------------
799+
800+ function selectionText(range) {
801+ const selection = window.getSelection();
802+ if (!selection) return range.toString();
803+ const saved = [];
804+ for (let i = 0; i < selection.rangeCount; i++) saved.push(selection.getRangeAt(i));
805+ selection.removeAllRanges();
806+ selection.addRange(range);
807+ const text = selection.toString();
808+ selection.removeAllRanges();
809+ saved.forEach((savedRange) => selection.addRange(savedRange));
810+ return text;
811+ }
812+
813+ function sectionText(color) {
814+ const headings = deepQueryAll("h1,h2,h3,h4,h5,h6")
815+ .filter((heading) => heading.getClientRects().length > 0);
816+ if (headings.length === 0) return "";
817+ const threshold = window.innerHeight * 0.25;
818+ let index = -1;
819+ headings.forEach((heading, i) => {
820+ if (heading.getBoundingClientRect().top <= threshold) index = i;
821+ });
822+ if (index < 0) index = 0;
823+ const heading = headings[index];
824+ const level = Number(heading.tagName[1]);
825+ const boundary = headings.slice(index + 1).find((next) => Number(next.tagName[1]) <= level);
826+ const range = document.createRange();
827+ range.setStartBefore(heading);
828+ if (boundary) range.setEndBefore(boundary);
829+ else range.setEndAfter(document.body ? document.body.lastChild || heading : heading);
830+ flash(heading, color);
831+ return selectionText(range);
832+ }
833+
834+ function objectText(object, color) {
835+ if (object === "section") return sectionText(color);
836+ for (const selector of OBJECT_SELECTORS[object] || []) {
837+ const candidates = deepQueryAll(selector);
838+ const element = candidates.find((candidate) =>
839+ visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate)));
840+ if (element) {
841+ flash(element, color);
842+ return element.innerText;
843+ }
844+ }
845+ return "";
846+ }
847+
848+ // --- Lifecycle ------------------------------------------------------------
849+
850+ function cleanup() {
851+ if (state) {
852+ for (const node of [state.root, state.style, state.cursor]) if (node) node.remove();
853+ if (state.frame) window.cancelAnimationFrame(state.frame);
854+ if (state.onViewportChange) {
855+ window.removeEventListener("scroll", state.onViewportChange, { capture: true });
856+ window.removeEventListener("resize", state.onViewportChange);
857+ }
858+ }
859+ state = null;
860+ }
861+
862+ // finish tears down the interaction and reports its single terminal message.
863+ function finish(message) {
864+ const token = state ? state.token : "";
865+ cleanup();
866+ if (token) post(token, message);
867+ }
868+
869+ // A document leaving the page (navigation, bfcache) takes the overlay along.
870+ window.addEventListener("pagehide", () => { if (state) cleanup(); });
871+
872+ Object.defineProperty(window, "__dumberVimPage", {
873+ configurable: false,
874+ enumerable: false,
875+ value: Object.freeze({
876+ start(token, request) {
877+ try {
878+ cleanup();
879+ const color = request.color || "#fbbf24";
880+ if (request.kind === "yank") {
881+ const text = objectText(request.object, color).trim();
882+ post(token, text ? { type: "copy", text } : { type: "end" });
883+ return;
884+ }
885+ state = { kind: request.kind, token, color };
886+ const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
887+ if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });
888+ } catch (_) {
889+ // A page quirk must never leave the user's keys captured.
890+ cleanup();
891+ post(token, { type: "end", reason: "error" });
892+ }
893+ },
894+ key(token, key) {
895+ if (!state || state.token !== token) {
896+ // Unknown interaction (for example the runtime was reinstalled):
897+ // release that token's key capture without touching live state.
898+ post(token, { type: "end" });
899+ return;
900+ }
901+ try {
902+ if (state.phase === "hints" || state.kind !== "visual") return hintKey(key);
903+ return caretKey(key);
904+ } catch (_) {
905+ // A page quirk must never leave the user's keys captured.
906+ if (state) finish({ type: "end", reason: "error" });
907+ }
908+ },
909+ cancel(token) {
910+ if (!state || state.token !== token) return;
911+ try {
912+ if (state.kind === "visual" && state.phase !== "hints") {
913+ const selection = window.getSelection();
914+ if (selection) selection.removeAllRanges();
915+ }
916+ } catch (_) {
917+ // Fall through: the overlay must go even when the selection cannot.
918+ }
919+ cleanup();
920+ },
921+ }),
922+ });
923+})();
A internal/infrastructure/cef/vim_page_runtime_test.go +770 -0
@@ -0,0 +1,770 @@
1+package cef
2+
3+import (
4+ _ "embed"
5+ "encoding/json"
6+ "os"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// vimPageRuntimeHarness is a DOM shim for the embedded runtime: three visible
15+// paragraphs and one below the fold, two links, a Selection and Range with
16+// Chromium-like semantics, and a fetch that records every message posted to
17+// the Go bridge. See testdata/vim_page_harness.js.
18+//
19+//go:embed testdata/vim_page_harness.js
20+var vimPageRuntimeHarness string
21+
22+// runVimPageRuntime executes the embedded runtime inside the harness, then
23+// the given driver script, and returns every message posted to the bridge
24+// plus the driver's own output appended to the "posted" array.
25+func runVimPageRuntime(t *testing.T, driver string) []map[string]any {
26+ t.Helper()
27+ node, err := exec.LookPath("node")
28+ if err != nil {
29+ if os.Getenv("DUMBER_REQUIRE_NODE") == "1" {
30+ t.Fatal("node is required (DUMBER_REQUIRE_NODE=1) to execute the vim page runtime")
31+ }
32+ t.Skip("node is required to execute the vim page runtime")
33+ }
34+ script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver +
35+ "\nprocess.stdout.write(JSON.stringify(posted));"
36+ output, err := exec.Command(node, "-e", script).CombinedOutput()
37+ require.NoError(t, err, "runtime failed: %s", output)
38+ var posted []map[string]any
39+ require.NoError(t, json.Unmarshal(output, &posted), "runtime output: %s", output)
40+ return posted
41+}
42+
43+// named returns the driver snapshot recorded under name.
44+func named(t *testing.T, posted []map[string]any, name string) map[string]any {
45+ t.Helper()
46+ for _, entry := range posted {
47+ if entry["n"] == name {
48+ return entry
49+ }
50+ }
51+ require.FailNow(t, "snapshot not recorded", name)
52+ return nil
53+}
54+
55+// bridgeMessages returns the runtime's own bridge messages, without snapshots.
56+func bridgeMessages(posted []map[string]any) []map[string]any {
57+ var out []map[string]any
58+ for _, entry := range posted {
59+ if _, ok := entry["token"]; ok {
60+ out = append(out, entry)
61+ }
62+ }
63+ return out
64+}
65+
66+func modeSequence(posted []map[string]any) []any {
67+ var out []any
68+ for _, entry := range bridgeMessages(posted) {
69+ if entry["type"] == "mode" {
70+ out = append(out, entry["mode"])
71+ }
72+ }
73+ return out
74+}
75+
76+func TestVimPageRuntimeVisualStartsWithTextAnchorHints(t *testing.T) {
77+ posted := runVimPageRuntime(t, `
78+window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" });
79+posted.push({ n: "hints", labels: hintLabels(), selection: selection.rangeCount, caret: cursor() !== null });
80+`)
81+ hints := named(t, posted, "hints")
82+ require.Equal(t, []any{"s", "a", "d"}, hints["labels"], "one label per visible text block, none for text below the fold")
83+ require.EqualValues(t, 0, hints["selection"])
84+ require.Equal(t, false, hints["caret"], "no caret until an anchor is picked")
85+ require.Equal(t, []any{"hints"}, modeSequence(posted))
86+}
87+
88+func TestVimPageRuntimeTextAnchorLabelPlacesCaret(t *testing.T) {
89+ posted := runVimPageRuntime(t, `
90+startCaret("tok", "a");
91+S("caret", { hints: hintLabels(), cursor: cursor() && cursor().style });
92+`)
93+ caret := named(t, posted, "caret")
94+ require.Empty(t, caret["text"])
95+ require.Equal(t, "1:0", caret["anchor"], "the label puts the caret at the start of that text")
96+ require.Equal(t, "1:0", caret["focus"])
97+ require.Empty(t, caret["hints"], "the hint overlay is gone once the caret is placed")
98+ style := caret["cursor"].(map[string]any)
99+ require.Equal(t, "fixed", style["position"])
100+ require.Equal(t, "#fbbf24", style["background"], "the caret uses the accent color")
101+ require.Equal(t, "none", style["pointer-events"])
102+ require.Equal(t, "2147483647", style["z-index"])
103+ require.Equal(t, "8px", style["left"])
104+ require.Equal(t, "37px", style["top"], "the caret is taller than the line, centered on it")
105+ require.Equal(t, "26px", style["height"])
106+ require.Equal(t, "4px", style["width"])
107+ require.Equal(t, []any{"hints", "caret"}, modeSequence(posted))
108+ require.Empty(t, bridgeMessagesOfType(posted, "end"))
109+}
110+
111+func bridgeMessagesOfType(posted []map[string]any, messageType string) []map[string]any {
112+ var out []map[string]any
113+ for _, message := range bridgeMessages(posted) {
114+ if message["type"] == messageType {
115+ out = append(out, message)
116+ }
117+ }
118+ return out
119+}
120+
121+func TestVimPageRuntimeEscapeInTextAnchorHintsPicksFirstLongText(t *testing.T) {
122+ posted := runVimPageRuntime(t, `
123+window.__dumberVimPage.start("tok", { kind: "visual" });
124+vp().key("tok", "<Escape>");
125+S("caret");
126+`)
127+ caret := named(t, posted, "caret")
128+ require.Equal(t, "1:0", caret["focus"], "the first visible text of at least 50 characters is chosen over short banners")
129+ require.Equal(t, []any{"hints", "caret"}, modeSequence(posted))
130+}
131+
132+func TestVimPageRuntimeEscapeInTextAnchorHintsFallsBackToAnyVisibleText(t *testing.T) {
133+ posted := runVimPageRuntime(t, `
134+second.text.data = "short";
135+window.__dumberVimPage.start("tok", { kind: "visual" });
136+vp().key("tok", "<Escape>");
137+S("caret");
138+`)
139+ require.Equal(t, "0:0", named(t, posted, "caret")["focus"])
140+}
141+
142+func TestVimPageRuntimeVisualEndsWithReasonWhenNoVisibleText(t *testing.T) {
143+ posted := runVimPageRuntime(t, `
144+for (const t of texts) t.parentElement.rect = rect(10, 5000, 600, 20);
145+window.__dumberVimPage.start("tok", { kind: "visual" });
146+`)
147+ require.Equal(t, []map[string]any{{"token": "tok", "type": "end", "reason": "no-visible-text"}}, bridgeMessages(posted))
148+}
149+
150+func TestVimPageRuntimeEscapeFallbackEndsWhenNothingVisible(t *testing.T) {
151+ posted := runVimPageRuntime(t, `
152+window.__dumberVimPage.start("tok", { kind: "visual" });
153+for (const t of texts) t.parentElement.rect = rect(10, 5000, 600, 20);
154+vp().key("tok", "<Escape>");
155+`)
156+ ends := bridgeMessagesOfType(posted, "end")
157+ require.Len(t, ends, 1)
158+ require.Equal(t, "no-visible-text", ends[0]["reason"])
159+}
160+
161+func TestVimPageRuntimeVisibleSelectionStartsVisualDirectly(t *testing.T) {
162+ posted := runVimPageRuntime(t, `
163+selection.setBaseAndExtent(first.text, 0, first.text, 5);
164+window.__dumberVimPage.start("tok", { kind: "visual" });
165+S("start", { hints: hintLabels(), cursor: cursor() !== null });
166+vp().key("tok", "l");
167+S("extended");
168+`)
169+ start := named(t, posted, "start")
170+ require.Equal(t, "Alpha", start["text"], "an existing visible selection is used as is")
171+ require.Empty(t, start["hints"])
172+ require.Equal(t, true, start["cursor"])
173+ require.Equal(t, "Alpha ", named(t, posted, "extended")["text"])
174+ require.Equal(t, []any{"visual"}, modeSequence(posted))
175+}
176+
177+func TestVimPageRuntimeSelectionOutsideViewportIsIgnored(t *testing.T) {
178+ posted := runVimPageRuntime(t, `
179+selection.setBaseAndExtent(far.text, 0, far.text, 3);
180+window.__dumberVimPage.start("tok", { kind: "visual" });
181+posted.push({ n: "hints", labels: hintLabels() });
182+`)
183+ require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"])
184+ require.Equal(t, []any{"hints"}, modeSequence(posted))
185+}
186+
187+func TestVimPageRuntimeCaretMotionsMoveWithoutSelecting(t *testing.T) {
188+ tests := []struct {
189+ name string
190+ start string // hint label
191+ keys []string
192+ want string // focus
193+ }{
194+ {"l", "s", []string{"l", "l"}, "0:2"},
195+ {"counted l", "s", []string{"3", "l"}, "0:3"},
196+ {"h clamps", "s", []string{"l", "h", "h"}, "0:0"},
197+ {"w", "s", []string{"w"}, "0:6"},
198+ {"2w", "s", []string{"2", "w"}, "0:12"},
199+ {"w crosses line", "s", []string{"3", "w"}, "1:0"},
200+ {"e", "s", []string{"e"}, "0:5"},
201+ {"b", "s", []string{"w", "w", "b"}, "0:6"},
202+ {"b at start", "s", []string{"b"}, "0:0"},
203+ {"$", "s", []string{"$"}, "0:19"},
204+ {"0", "s", []string{"$", "0"}, "0:0"},
205+ {"^", "s", []string{"$", "^"}, "0:0"},
206+ {"j", "s", []string{"l", "l", "j"}, "1:2"},
207+ {"k", "a", []string{"l", "k"}, "0:1"},
208+ {"G", "s", []string{"G"}, "3:18"},
209+ {"gg", "a", []string{"G", "g", "g"}, "0:0"},
210+ }
211+ for _, tt := range tests {
212+ t.Run(tt.name, func(t *testing.T) {
213+ keys, err := json.Marshal(tt.keys)
214+ require.NoError(t, err)
215+ posted := runVimPageRuntime(t, `
216+startCaret("tok", `+jsString(tt.start)+`);
217+keys("tok", ...`+string(keys)+`);
218+S("end");
219+`)
220+ end := named(t, posted, "end")
221+ require.Equal(t, tt.want, end["focus"])
222+ require.Equal(t, end["focus"], end["anchor"], "the caret stays collapsed")
223+ require.Empty(t, end["text"])
224+ require.Empty(t, bridgeMessagesOfType(posted, "end"))
225+ })
226+ }
227+}
228+
229+// Sentence and paragraph motions are native Selection.modify granularities
230+// the DOM shim cannot emulate, so the contract checked here is the call: the
231+// alter mode follows the phase and the direction and granularity are exact.
232+func TestVimPageRuntimeNativeGranularityMotions(t *testing.T) {
233+ tests := []struct {
234+ key string
235+ direction string
236+ granularity string
237+ }{
238+ {"(", "backward", "sentence"},
239+ {")", "forward", "sentence"},
240+ {"{", "backward", "paragraph"},
241+ {"}", "forward", "paragraph"},
242+ {"0", "backward", "lineboundary"},
243+ {"$", "forward", "lineboundary"},
244+ {"G", "forward", "documentboundary"},
245+ }
246+ for _, tt := range tests {
247+ for _, phase := range []struct{ name, setup, alter string }{
248+ {"caret", "", "move"},
249+ {"visual", `keys("tok", "v");`, "extend"},
250+ } {
251+ t.Run(phase.name+" "+tt.key, func(t *testing.T) {
252+ posted := runVimPageRuntime(t, `
253+startCaret("tok", "s");
254+`+phase.setup+`
255+modifyCalls.length = 0;
256+keys("tok", `+jsString(tt.key)+`);
257+posted.push({ n: "calls", calls: modifyCalls });
258+`)
259+ require.Equal(t, []any{[]any{phase.alter, tt.direction, tt.granularity}}, named(t, posted, "calls")["calls"])
260+ })
261+ }
262+ }
263+}
264+
265+func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) {
266+ posted := runVimPageRuntime(t, `
267+startCaret("tok", "d");
268+keys("tok", "w");
269+S("w1");
270+keys("tok", "w");
271+S("w2");
272+keys("tok", "b");
273+S("b1");
274+`)
275+ // "Third line, end.": w from "Third" -> "line"; w -> "end" (the comma and
276+ // space are skipped); b -> back to "line".
277+ require.Equal(t, "2:6", named(t, posted, "w1")["focus"])
278+ require.Equal(t, "2:12", named(t, posted, "w2")["focus"])
279+ require.Equal(t, "2:6", named(t, posted, "b1")["focus"])
280+}
281+
282+func TestVimPageRuntimeCaretMotionScrollsFocusIntoView(t *testing.T) {
283+ posted := runVimPageRuntime(t, `
284+startCaret("tok", "s");
285+scrolls.length = 0;
286+keys("tok", "G");
287+posted.push({ n: "scrolled", scrolls });
288+`)
289+ scrolled := named(t, posted, "scrolled")["scrolls"].([]any)
290+ require.NotEmpty(t, scrolled, "moving the caret below the fold scrolls it into view")
291+ require.Greater(t, scrolled[0].([]any)[1].(float64), float64(1000))
292+}
293+
294+func TestVimPageRuntimeCaretFollowsScrollAndIsRemovedOnEnd(t *testing.T) {
295+ posted := runVimPageRuntime(t, `
296+startCaret("tok", "s");
297+const bar = cursor();
298+first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away
299+fire("scroll");
300+flushFrames();
301+const hiddenWhileOffscreen = bar.style.display;
302+first.p.rect = rect(10, 100, 600, 20);
303+fire("scroll");
304+flushFrames();
305+posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display });
306+keys("tok", "<Escape>");
307+posted.push({ n: "cleanup", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, scrollListeners: (listeners.scroll || []).length, resize: (listeners.resize || []).length, selection: selection.rangeCount });
308+`)
309+ follow := named(t, posted, "follow")
310+ require.Equal(t, "none", follow["hidden"], "the caret hides while its text is off screen")
311+ require.Equal(t, "97px", follow["top"], "the caret follows its text when the page scrolls")
312+ require.Empty(t, follow["display"])
313+ cleanup := named(t, posted, "cleanup")
314+ require.Equal(t, true, cleanup["caret"], "the caret overlay is removed on cleanup")
315+ require.EqualValues(t, 0, cleanup["styles"])
316+ require.EqualValues(t, 0, cleanup["scrollListeners"])
317+ require.EqualValues(t, 0, cleanup["resize"])
318+ require.EqualValues(t, 0, cleanup["selection"])
319+}
320+
321+func TestVimPageRuntimeVisualFromCaretExtendsAndYankReturnsToCaret(t *testing.T) {
322+ posted := runVimPageRuntime(t, `
323+startCaret("tok", "s");
324+keys("tok", "v");
325+S("v");
326+keys("tok", "l", "l");
327+S("extended");
328+keys("tok", "w");
329+S("word");
330+keys("tok", "y");
331+S("after");
332+keys("tok", "l");
333+S("moved");
334+keys("tok", "y");
335+S("caretYank");
336+keys("tok", "v", "e");
337+S("second");
338+keys("tok", "<Return>");
339+S("afterReturn");
340+`)
341+ require.Equal(t, "A", named(t, posted, "v")["text"], "v starts charwise from the caret")
342+ require.Equal(t, "Alp", named(t, posted, "extended")["text"])
343+ require.Equal(t, "Alpha ", named(t, posted, "word")["text"])
344+
345+ after := named(t, posted, "after")
346+ require.Empty(t, after["text"], "y returns to a collapsed caret")
347+ require.Equal(t, "0:5", after["focus"], "on the last selected character, where v put the caret")
348+ require.Equal(t, after["focus"], after["anchor"])
349+ require.Equal(t, "0:6", named(t, posted, "moved")["focus"], "the caret keeps moving after a yank")
350+ require.Equal(t, "0:6", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing")
351+
352+ copies := bridgeMessagesOfType(posted, "copy")
353+ require.Len(t, copies, 2, "y in caret mode copies nothing; Enter chains a second copy")
354+ require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, copies[0])
355+ require.Equal(t, "bravo", copies[1]["text"], "v includes the character under the caret and e extends to the word end")
356+ require.Empty(t, named(t, posted, "afterReturn")["text"])
357+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction")
358+ require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted))
359+}
360+
361+func TestVimPageRuntimeVisualOSwapsEnds(t *testing.T) {
362+ posted := runVimPageRuntime(t, `
363+startCaret("tok", "s");
364+keys("tok", "l", "l", "l", "v");
365+S("before");
366+keys("tok", "o");
367+S("swapped");
368+keys("tok", "h", "h");
369+S("shrunk");
370+keys("tok", "o", "l");
371+S("back");
372+`)
373+ before := named(t, posted, "before")
374+ require.Equal(t, "0:3", before["anchor"])
375+ require.Equal(t, "0:4", before["focus"])
376+ swapped := named(t, posted, "swapped")
377+ require.Equal(t, "0:4", swapped["anchor"])
378+ require.Equal(t, "0:3", swapped["focus"])
379+ require.Equal(t, "0:4", named(t, posted, "shrunk")["anchor"])
380+ require.Equal(t, "0:1", named(t, posted, "shrunk")["focus"])
381+ require.Equal(t, "lph", named(t, posted, "shrunk")["text"])
382+ back := named(t, posted, "back")
383+ require.Equal(t, "0:1", back["anchor"])
384+ require.Equal(t, "0:5", back["focus"], "after o the motions extend the other end")
385+}
386+
387+func TestVimPageRuntimeVisualLine(t *testing.T) {
388+ posted := runVimPageRuntime(t, `
389+startCaret("tok", "a");
390+keys("tok", "l", "l");
391+keys("tok", "V");
392+S("line");
393+keys("tok", "j");
394+S("down");
395+keys("tok", "k", "k");
396+S("up");
397+keys("tok", "o");
398+S("swapped");
399+keys("tok", "y");
400+S("yanked");
401+`)
402+ require.Equal(t, "The quick brown fox jumps over the lazy dog near the river", named(t, posted, "line")["text"], "V selects the whole line")
403+ require.Equal(t, "The quick brown fox jumps over the lazy dog near the river\nThird line, end.", named(t, posted, "down")["text"])
404+ up := named(t, posted, "up")
405+ require.Equal(t, "Alpha bravo charlie\nThe quick brown fox jumps over the lazy dog near the river", up["text"], "motions extend by whole lines")
406+ require.Equal(t, "1:58", up["anchor"])
407+ require.Equal(t, "0:0", up["focus"])
408+ require.Equal(t, "0:0", named(t, posted, "swapped")["anchor"], "o swaps the ends")
409+ yanked := named(t, posted, "yanked")
410+ require.Empty(t, yanked["text"])
411+ require.Equal(t, yanked["focus"], yanked["anchor"], "y returns to a caret at the focus")
412+ copies := bridgeMessagesOfType(posted, "copy")
413+ require.Len(t, copies, 1)
414+ require.Equal(t, "Alpha bravo charlie\nThe quick brown fox jumps over the lazy dog near the river", copies[0]["text"])
415+ require.Equal(t, []any{"hints", "caret", "visual-line", "caret"}, modeSequence(posted))
416+}
417+
418+func TestVimPageRuntimeModeSwitchingKeys(t *testing.T) {
419+ posted := runVimPageRuntime(t, `
420+startCaret("tok", "s");
421+keys("tok", "l", "l");
422+keys("tok", "v", "l");
423+S("visual");
424+keys("tok", "V");
425+S("line");
426+keys("tok", "v");
427+S("visualAgain");
428+keys("tok", "V");
429+keys("tok", "V");
430+S("caretFromLine");
431+keys("tok", "v");
432+keys("tok", "v");
433+S("caretFromVisual");
434+keys("tok", "v", "l", "<Escape>");
435+S("escapeVisual");
436+keys("tok", "V", "<Escape>");
437+S("escapeLine");
438+`)
439+ require.Equal(t, "ph", named(t, posted, "visual")["text"], "v includes the character under the caret, then l extends")
440+ require.Equal(t, "Alpha bravo charlie", named(t, posted, "line")["text"])
441+ require.Equal(t, "Alpha bravo charlie", named(t, posted, "visualAgain")["text"], "v in visual line switches to charwise without changing the range")
442+ require.Empty(t, named(t, posted, "caretFromLine")["text"], "V in visual line goes back to caret")
443+ require.Empty(t, named(t, posted, "caretFromVisual")["text"], "v in visual goes back to caret")
444+ require.Empty(t, named(t, posted, "escapeVisual")["text"], "Escape in visual goes back to caret")
445+ require.Empty(t, named(t, posted, "escapeLine")["text"], "Escape in visual line goes back to caret")
446+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "none of these end the interaction")
447+ require.Equal(t, []any{
448+ "hints", "caret", "visual", "visual-line", "visual", "visual-line", "caret",
449+ "visual", "caret", "visual", "caret", "visual-line", "caret",
450+ }, modeSequence(posted))
451+}
452+
453+func TestVimPageRuntimeEscapeInCaretEndsInteraction(t *testing.T) {
454+ posted := runVimPageRuntime(t, `
455+startCaret("tok", "s");
456+keys("tok", "l", "<Escape>");
457+posted.push({ n: "after", selection: selection.rangeCount, caret: cursor() === null });
458+`)
459+ require.Equal(t, []map[string]any{{"token": "tok", "type": "end"}}, bridgeMessagesOfType(posted, "end"))
460+ after := named(t, posted, "after")
461+ require.EqualValues(t, 0, after["selection"])
462+ require.Equal(t, true, after["caret"])
463+}
464+
465+func TestVimPageRuntimeCancelRemovesCaretAndSelection(t *testing.T) {
466+ posted := runVimPageRuntime(t, `
467+startCaret("tok", "s");
468+keys("tok", "v", "l");
469+vp().cancel("tok");
470+posted.push({ n: "after", selection: selection.rangeCount, caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length });
471+`)
472+ after := named(t, posted, "after")
473+ require.EqualValues(t, 0, after["selection"])
474+ require.Equal(t, true, after["caret"])
475+ require.EqualValues(t, 0, after["styles"])
476+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "cancel is Go-initiated and reports nothing")
477+}
478+
479+func TestVimPageRuntimeCancelRemovesHintOverlay(t *testing.T) {
480+ posted := runVimPageRuntime(t, `
481+window.__dumberVimPage.start("tok", { kind: "visual" });
482+vp().cancel("tok");
483+posted.push({ n: "after", labels: hintLabels() });
484+`)
485+ require.Empty(t, named(t, posted, "after")["labels"])
486+}
487+
488+func TestVimPageRuntimeKeyErrorReleasesCapture(t *testing.T) {
489+ posted := runVimPageRuntime(t, `
490+startCaret("tok", "s");
491+selection.modify = () => { throw new Error("page quirk"); };
492+keys("tok", "l");
493+`)
494+ ends := bridgeMessagesOfType(posted, "end")
495+ require.Len(t, ends, 1, "a failing motion must not leave the keyboard captured")
496+ require.Equal(t, "error", ends[0]["reason"])
497+}
498+
499+func TestVimPageRuntimeHintsFollowAndYank(t *testing.T) {
500+ posted := runVimPageRuntime(t, `
501+const vpage = window.__dumberVimPage;
502+vpage.start("a", { kind: "hint-follow" });
503+posted.push({ n: "labels", labels: hintLabels() });
504+vpage.key("a", "a");
505+vpage.start("b", { kind: "hint-yank-url" });
506+vpage.key("b", "s");
507+vpage.start("c", { kind: "hint-follow-new" });
508+vpage.key("c", "a");
509+vpage.start("d", { kind: "hint-follow" });
510+vpage.key("d", "<Escape>");
511+`)
512+ require.ElementsMatch(t, []any{"s", "a"}, named(t, posted, "labels")["labels"], "two visible links get one-letter labels")
513+ require.Equal(t, "https://example.com/two", recordedField(posted, "clicked"), "follow clicks the chosen link")
514+ messages := bridgeMessages(posted)
515+ require.Equal(t, []map[string]any{
516+ {"token": "a", "type": "end"},
517+ {"token": "b", "type": "copy", "text": "file:///one.html"},
518+ {"token": "c", "type": "open-new", "url": "https://example.com/two"},
519+ {"token": "d", "type": "end"},
520+ }, messages)
521+ require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes")
522+}
523+
524+// recordedField returns a field recorded by a harness side effect (such as a click).
525+func recordedField(posted []map[string]any, key string) any {
526+ for _, entry := range posted {
527+ if value, ok := entry[key]; ok {
528+ return value
529+ }
530+ }
531+ return nil
532+}
533+
534+func TestVimPageRuntimeYankParagraph(t *testing.T) {
535+ posted := runVimPageRuntime(t, `
536+first.p.innerText = first.text.data;
537+window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
538+`)
539+ messages := bridgeMessages(posted)
540+ require.Len(t, messages, 1)
541+ require.Equal(t, "copy", messages[0]["type"])
542+ require.True(t, strings.HasPrefix(messages[0]["text"].(string), "Alpha"))
543+}
544+
545+func TestVimPageRuntimeStaleTokenKeyReleasesCapture(t *testing.T) {
546+ posted := runVimPageRuntime(t, `
547+startCaret("live", "s");
548+vp().key("stale", "j");
549+posted.push({ n: "live", selection: selection.rangeCount });
550+`)
551+ require.Equal(t, []map[string]any{{"token": "stale", "type": "end"}}, bridgeMessagesOfType(posted, "end"))
552+ require.EqualValues(t, 1, named(t, posted, "live")["selection"], "a stale key must not disturb the live interaction")
553+}
554+
555+// Entering visual selects the character under the caret and leaving it must
556+// put the caret back on that character; the caret used to move one position
557+// right on every v then Escape.
558+func TestVimPageRuntimeVisualRoundTripDoesNotDriftCaret(t *testing.T) {
559+ posted := runVimPageRuntime(t, `
560+startCaret("tok", "s");
561+keys("tok", "l", "l");
562+S("start");
563+for (let i = 0; i < 5; i++) keys("tok", "v", "<Escape>");
564+S("afterEscapes");
565+for (let i = 0; i < 3; i++) keys("tok", "v", "v");
566+S("afterToggles");
567+for (let i = 0; i < 3; i++) keys("tok", "v", "y");
568+S("afterYanks");
569+keys("tok", "V", "V");
570+S("afterLine");
571+`)
572+ start := named(t, posted, "start")
573+ require.Equal(t, "0:2", start["focus"])
574+ for _, name := range []string{"afterEscapes", "afterToggles", "afterYanks"} {
575+ after := named(t, posted, name)
576+ require.Equal(t, start["focus"], after["focus"], name)
577+ require.Equal(t, start["anchor"], after["anchor"], name)
578+ require.Empty(t, after["text"], name)
579+ }
580+ copies := bridgeMessagesOfType(posted, "copy")
581+ require.Len(t, copies, 3)
582+ for _, c := range copies {
583+ require.Equal(t, "p", c["text"], "each yank copies the character under the caret")
584+ }
585+ require.Equal(t, "0:18", named(t, posted, "afterLine")["focus"], "V then V returns to the end of the line")
586+}
587+
588+func TestVimPageRuntimeVisualRoundTripAtEndOfTextKeepsCaret(t *testing.T) {
589+ posted := runVimPageRuntime(t, `
590+startCaret("tok", "d");
591+keys("tok", "G");
592+S("end");
593+keys("tok", "v");
594+S("visual");
595+keys("tok", "<Escape>");
596+S("back");
597+`)
598+ end := named(t, posted, "end")
599+ require.Equal(t, "3:18", end["focus"])
600+ visual := named(t, posted, "visual")
601+ require.Equal(t, "d", visual["text"], "at the end of the text v selects the last character")
602+ require.Equal(t, "3:18", visual["focus"])
603+ require.Equal(t, "3:17", named(t, posted, "back")["focus"], "the caret returns onto the last character")
604+}
605+
606+// Motions must not read the whole selection text per step: that made long
607+// selections quadratic.
608+func TestVimPageRuntimeMotionsDoNotReadSelectionText(t *testing.T) {
609+ posted := runVimPageRuntime(t, `
610+startCaret("tok", "s");
611+keys("tok", "v");
612+toStringCalls = 0;
613+keys("tok", "w", "w", "e", "b", "$", "^");
614+posted.push({ n: "reads", calls: toStringCalls });
615+S("sel");
616+`)
617+ require.EqualValues(t, 0, named(t, posted, "reads")["calls"])
618+ require.NotEmpty(t, named(t, posted, "sel")["focus"])
619+}
620+
621+func TestVimPageRuntimeStartAndCancelFailuresReleaseCapture(t *testing.T) {
622+ posted := runVimPageRuntime(t, `
623+const realGetSelection = window.getSelection;
624+window.getSelection = () => { throw new Error("page quirk"); };
625+window.__dumberVimPage.start("bad", { kind: "visual" });
626+window.getSelection = realGetSelection;
627+posted.push({ n: "afterStart", hints: hintLabels().length, caret: cursor() === null });
628+
629+startCaret("tok", "s");
630+selection.removeAllRanges = () => { throw new Error("page quirk"); };
631+vp().cancel("tok");
632+posted.push({ n: "afterCancel", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, listeners: (listeners.scroll || []).length });
633+`)
634+ ends := bridgeMessagesOfType(posted, "end")
635+ require.Equal(t, []map[string]any{{"token": "bad", "type": "end", "reason": "error"}}, ends, "a failing start reports the end")
636+ afterStart := named(t, posted, "afterStart")
637+ require.EqualValues(t, 0, afterStart["hints"])
638+ cancel := named(t, posted, "afterCancel")
639+ require.Equal(t, true, cancel["caret"], "cancel removes the overlay even when the selection cannot be cleared")
640+ require.EqualValues(t, 0, cancel["styles"])
641+ require.EqualValues(t, 0, cancel["listeners"])
642+}
643+
644+func TestVimPageRuntimePageHideCleansUp(t *testing.T) {
645+ posted := runVimPageRuntime(t, `
646+startCaret("tok", "s");
647+fire("pagehide");
648+posted.push({ n: "gone", caret: cursor() === null, scroll: (listeners.scroll || []).length, frames: frames.size });
649+fire("pagehide"); // without an interaction it is a no-op
650+`)
651+ gone := named(t, posted, "gone")
652+ require.Equal(t, true, gone["caret"])
653+ require.EqualValues(t, 0, gone["scroll"])
654+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "the document is leaving; nothing is reported")
655+}
656+
657+// SVG anchors expose href as an SVGAnimatedString and have no click().
658+func TestVimPageRuntimeSVGLinks(t *testing.T) {
659+ posted := runVimPageRuntime(t, `
660+const svg = body.appendChild(new SVGAElement_("../rel.html"));
661+svg.rect = rect(150, 120, 40, 20);
662+const w = window.__dumberVimPage;
663+w.start("y", { kind: "hint-yank-url" });
664+w.key("y", "d");
665+w.start("n", { kind: "hint-follow-new" });
666+w.key("n", "d");
667+w.start("f", { kind: "hint-follow" });
668+w.key("f", "d");
669+`)
670+ require.Equal(t, []map[string]any{
671+ {"token": "y", "type": "copy", "text": "https://example.com/rel.html"},
672+ {"token": "n", "type": "open-new", "url": "https://example.com/rel.html"},
673+ {"token": "f", "type": "end"},
674+ }, bridgeMessages(posted), "relative SVG hrefs resolve against the document")
675+ require.Equal(t, "click", recordedField(posted, "dispatched"), "following dispatches a click when the element has no click()")
676+ require.Equal(t, true, recordedField(posted, "bubbles"))
677+ require.Equal(t, true, recordedField(posted, "cancelable"))
678+}
679+
680+// Chromium reports Selection positions inside a shadow tree relative to the
681+// host, so shadow text never anchors the caret, while link hints and text
682+// objects keep reaching into shadow roots.
683+func TestVimPageRuntimeShadowTextIsNotACaretAnchor(t *testing.T) {
684+ posted := runVimPageRuntime(t, `
685+shadowParagraph("Shadow text that is visible", 200);
686+window.__dumberVimPage.start("tok", { kind: "visual" });
687+posted.push({ n: "hints", labels: hintLabels() });
688+vp().key("tok", "<Escape>");
689+S("fallback");
690+vp().cancel("tok");
691+
692+for (const t of texts) if (t !== texts[texts.length - 1]) t.parentElement.rect = rect(10, 5000, 600, 20);
693+window.__dumberVimPage.start("only", { kind: "visual" });
694+`)
695+ require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"], "no label for the shadow paragraph")
696+ require.Equal(t, "1:0", named(t, posted, "fallback")["focus"], "the fallback anchor ignores shadow text too")
697+ ends := bridgeMessagesOfType(posted, "end")
698+ require.Len(t, ends, 1)
699+ require.Equal(t, "no-visible-text", ends[0]["reason"], "shadow text alone offers no anchor")
700+}
701+
702+func TestVimPageRuntimeYankObjectStillReadsShadowDOM(t *testing.T) {
703+ posted := runVimPageRuntime(t, `
704+for (const t of texts.slice(0, 4)) t.parentElement.rect = rect(10, 5000, 600, 20);
705+const shadow = shadowParagraph("Inside shadow", 200);
706+shadow.p.innerText = "Inside shadow";
707+window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
708+`)
709+ require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": "Inside shadow"}}, bridgeMessages(posted))
710+}
711+
712+// Off-screen elements are rejected from their bounding box, before the costly
713+// closest() and range measurements run.
714+func TestVimPageRuntimeSkipsOffscreenWork(t *testing.T) {
715+ posted := runVimPageRuntime(t, `
716+for (let i = 0; i < 5; i++) {
717+ const link = body.appendChild(new HTMLAnchorElement("https://example.com/" + i));
718+ link.rect = rect(10, 4000 + i * 30, 40, 20);
719+}
720+closestCalls = 0;
721+window.__dumberVimPage.start("h", { kind: "hint-follow" });
722+posted.push({ n: "hints", closest: closestCalls, labels: hintLabels().length });
723+vp().cancel("h");
724+rangeRectCalls = 0;
725+window.__dumberVimPage.start("v", { kind: "visual" });
726+posted.push({ n: "anchors", ranges: rangeRectCalls, labels: hintLabels().length });
727+`)
728+ hints := named(t, posted, "hints")
729+ require.EqualValues(t, 2, hints["labels"])
730+ require.EqualValues(t, 2, hints["closest"], "only the two on-screen links reach closest()")
731+ anchors := named(t, posted, "anchors")
732+ require.EqualValues(t, 3, anchors["labels"])
733+ require.EqualValues(t, 3, anchors["ranges"], "the paragraph below the fold is never measured")
734+}
735+
736+// Scroll and resize repaint the caret once per animation frame, and a pending
737+// frame is canceled with the interaction.
738+func TestVimPageRuntimeBatchesViewportUpdatesPerFrame(t *testing.T) {
739+ posted := runVimPageRuntime(t, `
740+startCaret("tok", "s");
741+const bar = cursor();
742+first.p.rect = rect(10, 100, 600, 20);
743+for (let i = 0; i < 10; i++) { fire("scroll"); fire("resize"); }
744+const pending = frames.size;
745+const stale = bar.style.top;
746+flushFrames();
747+posted.push({ n: "batched", pending, stale, top: bar.style.top });
748+fire("scroll");
749+const queued = frames.size;
750+vp().cancel("tok");
751+posted.push({ n: "canceled", queued, left: frames.size });
752+`)
753+ batched := named(t, posted, "batched")
754+ require.EqualValues(t, 1, batched["pending"], "a burst of events schedules one repaint")
755+ require.NotEqual(t, batched["stale"], batched["top"], "the repaint happens in the frame")
756+ canceled := named(t, posted, "canceled")
757+ require.EqualValues(t, 1, canceled["queued"])
758+ require.EqualValues(t, 0, canceled["left"], "cleanup cancels a pending frame")
759+}
760+
761+// The bridge carries UTF-8: accents, CJK and astral characters must reach Go
762+// exactly as the page holds them.
763+func TestVimPageRuntimeCopiesNonASCIIText(t *testing.T) {
764+ const text = "héllo wörld — 日本語 😀 ñ"
765+ posted := runVimPageRuntime(t, `
766+first.p.innerText = `+jsString(text)+`;
767+window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
768+`)
769+ require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": text}}, bridgeMessages(posted))
770+}
A internal/infrastructure/cef/vim_page_test.go +511 -0
@@ -0,0 +1,511 @@
1+package cef
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "net/http"
7+ "regexp"
8+ "strings"
9+ "testing"
10+
11+ purecef "github.com/bnema/purego-cef/cef"
12+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/dumber/internal/application/dto"
18+ "github.com/bnema/dumber/internal/application/port"
19+)
20+
21+// expectVimPageScript expects one ExecuteJavaScript call and captures the
22+// script that was actually sent to the page.
23+func expectVimPageScript(t *testing.T) (*WebView, *string) {
24+ t.Helper()
25+ browser := cefmocks.NewMockBrowser(t)
26+ frame := cefmocks.NewMockFrame(t)
27+ var script string
28+ browser.EXPECT().GetMainFrame().Return(frame).Once()
29+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(code, _ string, _ int32) { script = code }).Once()
30+ return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"}, &script
31+}
32+
33+func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) {
34+ wv, script := expectVimPageScript(t)
35+
36+ err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{
37+ Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456",
38+ })
39+ require.NoError(t, err)
40+ token := wv.currentVimPageToken()
41+ require.NotEmpty(t, token)
42+ assert.NotEqual(t, "shared-bridge-nonce", token)
43+ assert.Contains(t, *script, "if (window.__dumberVimPage) return;", "start ships the runtime")
44+ assert.Contains(t, *script, `dumb:///api/vim-page`)
45+ assert.NotContains(t, *script, "shared-bridge-nonce")
46+ assert.Contains(t, *script, `window.__dumberVimPage.start("`+token+`", {"kind":"yank","object":"code","color":"#123456"});`)
47+}
48+
49+// Keys and cancels run on the already installed runtime: shipping the whole
50+// runtime for every key press would cost tens of kilobytes per keystroke.
51+func TestWebViewSendVimPageKeyDoesNotShipRuntime(t *testing.T) {
52+ wv, script := expectVimPageScript(t)
53+ wv.vimPageToken = "tok"
54+
55+ require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a"))
56+ require.NoError(t, wv.SendVimPageKey(context.Background(), ""))
57+
58+ assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.key("tok", "\"\u0007") : `), *script)
59+ assert.NotContains(t, *script, "if (window.__dumberVimPage) return;")
60+ assert.Less(t, len(*script), 1024)
61+}
62+
63+// A document replaced between start and key leaves no runtime; the script must
64+// then report the end of that token so the UI releases its key capture.
65+func TestVimPageCallScriptReportsEndWhenRuntimeMissing(t *testing.T) {
66+ script := vimPageCallScript("cancel", "tok")
67+
68+ assert.Contains(t, script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `)
69+ assert.Contains(t, script, `dumb:///api/vim-page`)
70+ match := regexp.MustCompile(`"X-Dumber-Body": "([^"]+)"`).FindStringSubmatch(script)
71+ require.Len(t, match, 2)
72+ body, err := base64.StdEncoding.DecodeString(match[1])
73+ require.NoError(t, err)
74+ assert.JSONEq(t, `{"token":"tok","type":"end"}`, string(body))
75+}
76+
77+func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) {
78+ wv := &WebView{browser: cefmocks.NewMockBrowser(t)}
79+
80+ require.NoError(t, wv.SendVimPageKey(context.Background(), "j"))
81+}
82+
83+func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) {
84+ wv, script := expectVimPageScript(t)
85+ wv.vimPageToken = "tok"
86+
87+ require.NoError(t, wv.CancelVimPageInteraction(context.Background()))
88+ assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `), *script)
89+ assert.Empty(t, wv.currentVimPageToken())
90+ require.NoError(t, wv.CancelVimPageInteraction(context.Background()))
91+}
92+
93+func TestVimPageRequestJSONRejectsUnknownRequests(t *testing.T) {
94+ _, err := vimPageRequestJSON(dto.VimPageInteractionRequest{})
95+ require.Error(t, err)
96+ _, err = vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageYankObject})
97+ require.Error(t, err)
98+
99+ got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollowNew})
100+ require.NoError(t, err)
101+ assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got)
102+}
103+
104+// The accent reaches the page as a CSS color; only hex colors are sent.
105+func TestVimPageRequestJSONValidatesAccentColor(t *testing.T) {
106+ for color, want := range map[string]string{
107+ "#fbbf24": `{"kind":"visual","color":"#fbbf24"}`,
108+ "#FFF": `{"kind":"visual","color":"#FFF"}`,
109+ "#aabbccdd": `{"kind":"visual","color":"#aabbccdd"}`,
110+ "": `{"kind":"visual"}`,
111+ "red": `{"kind":"visual"}`,
112+ "#12": `{"kind":"visual"}`,
113+ "#12345": `{"kind":"visual"}`,
114+ "#1234567": `{"kind":"visual"}`,
115+ "#123456789": `{"kind":"visual"}`,
116+ "#abc;} body{display:none": `{"kind":"visual"}`,
117+ "url(javascript:alert(1))": `{"kind":"visual"}`,
118+ "#abc\"; alert(1); //": `{"kind":"visual"}`,
119+ } {
120+ got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageVisual, HighlightColor: color})
121+ require.NoError(t, err)
122+ assert.JSONEq(t, want, got, "color %q", color)
123+ }
124+}
125+
126+func TestDecodeVimPageBridgePayload(t *testing.T) {
127+ tests := []struct {
128+ name string
129+ body string
130+ wantErr bool
131+ }{
132+ {"copy", `{"type":"copy","token":"t","text":"hello"}`, false},
133+ {"copy empty", `{"type":"copy","token":"t"}`, true},
134+ {"missing token", `{"type":"end"}`, true},
135+ {"open", `{"type":"open-new","token":"t","url":"https://example.com/a"}`, false},
136+ {"open empty", `{"type":"open-new","token":"t"}`, true},
137+ {"end", `{"type":"end","token":"t"}`, false},
138+ {"mode caret", `{"type":"mode","token":"t","mode":"caret"}`, false},
139+ {"mode visual line", `{"type":"mode","token":"t","mode":"visual-line"}`, false},
140+ {"mode unknown", `{"type":"mode","token":"t","mode":"insert"}`, true},
141+ {"mode missing", `{"type":"mode","token":"t"}`, true},
142+ {"unknown", `{"type":"eval","token":"t"}`, true},
143+ {"malformed", `{`, true},
144+ }
145+ for _, tt := range tests {
146+ t.Run(tt.name, func(t *testing.T) {
147+ _, err := decodeVimPageBridgePayload([]byte(tt.body))
148+ if tt.wantErr {
149+ require.Error(t, err)
150+ return
151+ }
152+ require.NoError(t, err)
153+ })
154+ }
155+}
156+
157+func TestIsNavigableVimHintURL(t *testing.T) {
158+ assert.True(t, isNavigableVimHintURL("https://example.com/a", "https://site.test/"))
159+ assert.False(t, isNavigableVimHintURL("https:///nohost", "https://site.test/"))
160+ assert.False(t, isNavigableVimHintURL("javascript:alert(1)", "https://site.test/"))
161+ assert.False(t, isNavigableVimHintURL("dumb://config", "https://site.test/"))
162+ assert.False(t, isNavigableVimHintURL("file:///etc/passwd", "https://site.test/"))
163+ assert.True(t, isNavigableVimHintURL("file:///tmp/b.html", "file:///tmp/a.html"))
164+}
165+
166+func newVimResultWebView(opened *string, ended *int) *WebView {
167+ return &WebView{ctx: context.Background(), uri: "https://site.test/", callbacks: &port.WebViewCallbacks{
168+ OnLinkMiddleClick: func(uri string) bool { *opened = uri; return true },
169+ OnVimPageInteractionEnded: func() { *ended++ },
170+ }}
171+}
172+
173+// Mode messages and copies of the caret/visual flow are not terminal: the
174+// token stays armed so the user can keep moving and chain copies, and the UI
175+// is told about each sub-mode without being asked to release key capture.
176+func TestWebViewHandleVimPageResultVisualFlowStaysArmedAcrossModeAndCopy(t *testing.T) {
177+ var opened string
178+ ended := 0
179+ var modes []dto.VimPageMode
180+ wv := newVimResultWebView(&opened, &ended)
181+ wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) }
182+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
183+ wv.vimPageCopyAllowance = 1
184+
185+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
186+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"})
187+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "copied"})
188+
189+ assert.Equal(t, []dto.VimPageMode{dto.VimPageModeCaret, dto.VimPageModeVisualLine}, modes)
190+ assert.Zero(t, ended, "mode and copy must not end the interaction")
191+ assert.Equal(t, "tok", wv.currentVimPageToken())
192+
193+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageEnd, Token: "tok"})
194+ assert.Equal(t, 1, ended)
195+ assert.Empty(t, wv.currentVimPageToken())
196+}
197+
198+func TestWebViewHandleVimPageResultCopyEndsHintYankAndYankObject(t *testing.T) {
199+ for _, kind := range []dto.VimPageInteractionKind{dto.VimPageHintYankURL, dto.VimPageYankObject} {
200+ var opened string
201+ ended := 0
202+ wv := newVimResultWebView(&opened, &ended)
203+ wv.vimPageToken, wv.vimPageKind = "tok", kind
204+
205+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "x"})
206+
207+ assert.Empty(t, wv.currentVimPageToken(), "kind %d: copy stays terminal", kind)
208+ assert.Equal(t, map[bool]int{true: 1, false: 0}[kind.CapturesKeys()], ended, "kind %d", kind)
209+ }
210+}
211+
212+func TestWebViewHandleVimPageResultModeRejectedForLinkHints(t *testing.T) {
213+ var opened string
214+ ended := 0
215+ modes := 0
216+ wv := newVimResultWebView(&opened, &ended)
217+ wv.callbacks.OnVimPageModeChanged = func(dto.VimPageMode) { modes++ }
218+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollow
219+
220+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
221+
222+ assert.Zero(t, modes, "hint interactions never report sub-modes")
223+}
224+
225+func TestWebViewHandleVimPageResultAcceptsArmedTokenOnce(t *testing.T) {
226+ var opened string
227+ ended := 0
228+ wv := newVimResultWebView(&opened, &ended)
229+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew
230+
231+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://example.com"})
232+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://evil.test"})
233+
234+ assert.Equal(t, "https://example.com", opened)
235+ assert.Equal(t, 1, ended)
236+ assert.Empty(t, wv.currentVimPageToken())
237+}
238+
239+func TestWebViewHandleVimPageResultRejectsStaleOrForgedTokens(t *testing.T) {
240+ var opened string
241+ ended := 0
242+ wv := newVimResultWebView(&opened, &ended)
243+ wv.vimPageToken, wv.vimPageKind = "current", dto.VimPageHintFollowNew
244+
245+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageEnd, Token: "previous"})
246+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "forged", URL: "https://example.com"})
247+
248+ assert.Empty(t, opened)
249+ assert.Zero(t, ended)
250+ assert.Equal(t, "current", wv.currentVimPageToken())
251+}
252+
253+func TestWebViewHandleVimPageResultRejectsDisallowedURL(t *testing.T) {
254+ var opened string
255+ ended := 0
256+ wv := newVimResultWebView(&opened, &ended)
257+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew
258+
259+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "file:///etc/passwd"})
260+
261+ assert.Empty(t, opened)
262+ assert.Equal(t, 1, ended, "the interaction still ends so key capture is released")
263+}
264+
265+func TestWebViewEndVimPageInteractionOnNavigation(t *testing.T) {
266+ var opened string
267+ ended := 0
268+ wv := newVimResultWebView(&opened, &ended)
269+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew
270+
271+ wv.endVimPageInteractionOnNavigation()
272+ wv.endVimPageInteractionOnNavigation()
273+
274+ assert.Equal(t, 1, ended)
275+ assert.Empty(t, wv.currentVimPageToken())
276+}
277+
278+func TestSchemeHandler_APIVimPageForwardsValidPayloads(t *testing.T) {
279+ oldNewResourceHandler := cefNewResourceHandler
280+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
281+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
282+
283+ tests := []struct {
284+ name string
285+ body string
286+ wantStatus int
287+ wantCalled bool
288+ }{
289+ {"valid", `{"type":"copy","token":"tok","text":"yanked"}`, http.StatusOK, true},
290+ {"missing token", `{"type":"copy","text":"yanked"}`, http.StatusBadRequest, false},
291+ }
292+ for _, tt := range tests {
293+ t.Run(tt.name, func(t *testing.T) {
294+ h := newTestDumbSchemeHandler(t)
295+ browser := cefmocks.NewMockBrowser(t)
296+ var received vimPageBridgePayload
297+ called := false
298+ h.onVimPage = func(_ purecef.Browser, payload vimPageBridgePayload) {
299+ called = true
300+ received = payload
301+ }
302+
303+ request := cefmocks.NewMockRequest(t)
304+ encoded := base64.StdEncoding.EncodeToString([]byte(tt.body))
305+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
306+ response := cefmocks.NewMockResponse(t)
307+ response.EXPECT().SetStatus(int32(tt.wantStatus)).Once()
308+ response.EXPECT().SetStatusText(http.StatusText(tt.wantStatus)).Once()
309+ response.EXPECT().SetMimeType("application/json").Once()
310+ expectAPIResponseHeaders(response)
311+
312+ handler := h.handleAPI(browser, http.MethodPost, "/api/vim-page", request)
313+ require.NotNil(t, handler)
314+ var length int64
315+ handler.GetResponseHeaders(response, &length, 0)
316+
317+ assert.Equal(t, tt.wantCalled, called)
318+ if tt.wantCalled {
319+ assert.Equal(t, vimPageBridgePayload{Type: "copy", Token: "tok", Text: "yanked"}, received)
320+ }
321+ })
322+ }
323+}
324+
325+func TestWebViewHandleVimPageResultRejectsTypeNotMatchingInteraction(t *testing.T) {
326+ var opened string
327+ ended := 0
328+ wv := newVimResultWebView(&opened, &ended)
329+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageYankObject
330+
331+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://example.com"})
332+
333+ assert.Empty(t, opened)
334+ assert.Empty(t, wv.currentVimPageToken(), "the token is still spent")
335+}
336+
337+func TestWebViewVimPageEndedCallbackSkippedAfterRearm(t *testing.T) {
338+ var opened string
339+ ended := 0
340+ wv := newVimResultWebView(&opened, &ended)
341+ _, err := wv.armVimPageInteraction(dto.VimPageHintFollow)
342+ require.NoError(t, err)
343+ _, generation := wv.disarmVimPageInteraction()
344+ _, err = wv.armVimPageInteraction(dto.VimPageHintFollow)
345+ require.NoError(t, err)
346+
347+ wv.notifyVimPageInteractionEnded(generation)
348+
349+ assert.Zero(t, ended)
350+}
351+
352+// handleVimPageResult runs on the CEF IO thread. The clipboard write must be
353+// handed to GTK instead of running there.
354+func newVimClipboardWebView(t *testing.T, kind dto.VimPageInteractionKind) (*WebView, *recordingClipboardTextOrchestrator, *[]func()) {
355+ t.Helper()
356+ orchestrator := &recordingClipboardTextOrchestrator{}
357+ queued := &[]func(){}
358+ wv := &WebView{
359+ ctx: context.Background(), id: 7, uri: "https://site.test/",
360+ engine: &Engine{clipboardTextOrchestrator: orchestrator},
361+ gtkDispatch: func(fn func()) { *queued = append(*queued, fn) },
362+ callbacks: &port.WebViewCallbacks{},
363+ }
364+ wv.vimPageToken, wv.vimPageKind = "tok", kind
365+ return wv, orchestrator, queued
366+}
367+
368+func TestWebViewHandleVimPageResultCopyRunsOnGTK(t *testing.T) {
369+ wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject)
370+
371+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "yanked"})
372+
373+ assert.Empty(t, orchestrator.explicit.Text, "the clipboard must not be written on the IO thread")
374+ require.Len(t, *queued, 1)
375+ (*queued)[0]()
376+ assert.Equal(t, dto.ExplicitClipboardInput{
377+ Text: "yanked", Action: "copy", SourceEngine: dto.ClipboardSourceCEF, ViewID: 7,
378+ }, orchestrator.explicit)
379+}
380+
381+// A hostile page that read the token must not be able to write the clipboard
382+// again and again: visual copies are non-terminal, so each needs a y/Enter the
383+// user pressed and Go forwarded.
384+func TestWebViewHandleVimPageResultVisualCopyNeedsForwardedCopyKey(t *testing.T) {
385+ wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageVisual)
386+ copyResult := vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "stolen"}
387+
388+ wv.handleVimPageResult(copyResult)
389+ assert.Empty(t, *queued, "no key was forwarded: the copy is refused")
390+ assert.Equal(t, "tok", wv.currentVimPageToken(), "a refused copy leaves the interaction running")
391+
392+ wv.grantVimPageCopyAllowance("tok", "j")
393+ wv.handleVimPageResult(copyResult)
394+ assert.Empty(t, *queued, "motion keys grant no copy")
395+
396+ wv.grantVimPageCopyAllowance("tok", "y")
397+ wv.handleVimPageResult(copyResult)
398+ wv.handleVimPageResult(copyResult)
399+ require.Len(t, *queued, 1, "one forwarded key allows exactly one copy")
400+ (*queued)[0]()
401+ assert.Equal(t, "stolen", orchestrator.explicit.Text)
402+
403+ wv.grantVimPageCopyAllowance("tok", "<Return>")
404+ wv.grantVimPageCopyAllowance("other", "y")
405+ wv.handleVimPageResult(copyResult)
406+ assert.Len(t, *queued, 2)
407+
408+ // y presses the page ignored (caret phase, empty selection) do not bank
409+ // copies for later.
410+ wv.grantVimPageCopyAllowance("tok", "y")
411+ wv.grantVimPageCopyAllowance("tok", "y")
412+ wv.grantVimPageCopyAllowance("tok", "y")
413+ wv.handleVimPageResult(copyResult)
414+ wv.handleVimPageResult(copyResult)
415+ assert.Len(t, *queued, 3, "ignored copy keys do not accumulate allowance")
416+}
417+
418+func TestWebViewSendVimPageKeyGrantsCopyAllowanceOnlyForVisual(t *testing.T) {
419+ for kind, want := range map[dto.VimPageInteractionKind]int{dto.VimPageVisual: 1, dto.VimPageHintYankURL: 0} {
420+ browser := cefmocks.NewMockBrowser(t)
421+ frame := cefmocks.NewMockFrame(t)
422+ browser.EXPECT().GetMainFrame().Return(frame).Times(3)
423+ frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Times(3)
424+ wv := &WebView{browser: browser, vimPageToken: "tok", vimPageKind: kind}
425+
426+ require.NoError(t, wv.SendVimPageKey(context.Background(), "y"))
427+ require.NoError(t, wv.SendVimPageKey(context.Background(), "j"))
428+ require.NoError(t, wv.SendVimPageKey(context.Background(), "<Return>"))
429+
430+ assert.Equal(t, want, wv.vimPageCopyAllowance, "kind %d", kind)
431+ }
432+}
433+
434+func TestWebViewArmVimPageInteractionResetsCopyAllowance(t *testing.T) {
435+ wv := &WebView{vimPageToken: "old", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 3}
436+
437+ _, err := wv.armVimPageInteraction(dto.VimPageVisual)
438+ require.NoError(t, err)
439+ assert.Zero(t, wv.vimPageCopyAllowance)
440+ wv.vimPageCopyAllowance = 2
441+ wv.disarmVimPageInteraction()
442+ assert.Zero(t, wv.vimPageCopyAllowance)
443+}
444+
445+func TestWebViewHandleVimPageResultIgnoredAfterDestroy(t *testing.T) {
446+ wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject)
447+ ended := 0
448+ wv.callbacks.OnVimPageInteractionEnded = func() { ended++ }
449+ wv.destroyed.Store(true)
450+
451+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "late"})
452+
453+ assert.Empty(t, *queued)
454+ assert.Empty(t, orchestrator.explicit.Text)
455+ assert.Zero(t, ended)
456+}
457+
458+func TestWebViewDestroyClearsVimPageInteraction(t *testing.T) {
459+ wv := &WebView{ctx: context.Background(), vimPageToken: "tok", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 1}
460+
461+ wv.Destroy()
462+
463+ assert.Empty(t, wv.currentVimPageToken())
464+ assert.Zero(t, wv.vimPageKind)
465+ assert.Zero(t, wv.vimPageCopyAllowance)
466+}
467+
468+// A crashed renderer takes the page runtime with it, so it can never report
469+// the end; the render-process-terminated handler must release the capture.
470+func TestHandlerSetOnRenderProcessTerminatedEndsVimPageInteraction(t *testing.T) {
471+ var opened string
472+ ended := 0
473+ wv := newVimResultWebView(&opened, &ended)
474+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
475+ h := &handlerSet{wv: wv}
476+
477+ h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "")
478+
479+ assert.Equal(t, 1, ended)
480+ assert.Empty(t, wv.currentVimPageToken())
481+}
482+
483+func TestSchemeHandler_APIVimPageRejectsOversizedPayloadWith413(t *testing.T) {
484+ oldNewResourceHandler := cefNewResourceHandler
485+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
486+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
487+
488+ h := newTestDumbSchemeHandler(t)
489+ h.onVimPage = func(purecef.Browser, vimPageBridgePayload) { t.Fatal("oversized payload must not reach the handler") }
490+ request := cefmocks.NewMockRequest(t)
491+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"type":"copy","token":"t","text":"` + strings.Repeat("a", maxClipboardBytes) + `"}`))
492+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
493+ response := cefmocks.NewMockResponse(t)
494+ response.EXPECT().SetStatus(int32(http.StatusRequestEntityTooLarge)).Once()
495+ response.EXPECT().SetStatusText(http.StatusText(http.StatusRequestEntityTooLarge)).Once()
496+ response.EXPECT().SetMimeType("application/json").Once()
497+ expectAPIResponseHeaders(response)
498+
499+ handler := h.handleAPI(cefmocks.NewMockBrowser(t), http.MethodPost, "/api/vim-page", request)
500+ require.NotNil(t, handler)
501+ var length int64
502+ handler.GetResponseHeaders(response, &length, 0)
503+}
504+
505+func TestDecodeVimPageBridgePayloadOversizedIsSentinel(t *testing.T) {
506+ _, err := decodeVimPageBridgePayload(make([]byte, maxClipboardBytes+1))
507+ require.ErrorIs(t, err, errVimPagePayloadTooLarge)
508+ _, err = decodeVimPageBridgePayload([]byte(`{`))
509+ require.Error(t, err)
510+ require.NotErrorIs(t, err, errVimPagePayloadTooLarge)
511+}
M internal/infrastructure/cef/webview.go +32 -13
@@ -203,8 +203,10 @@ type WebView struct {
203 // GTK sync dispatch hooks are injectable for tests. Production uses the GTK203 // GTK sync dispatch hooks are injectable for tests. Production uses the GTK
204 // default main context through runOnGTK and isOnGTKThread.204 // default main context through runOnGTK and isOnGTKThread.
205 gtkSyncDispatch func(func())205 gtkSyncDispatch func(func())
206- gtkSyncIsOwner func() bool206+ // gtkDispatch replaces the async GTK idle hop in tests.
207- gtkSyncTimeout time.Duration207+ gtkDispatch func(func())
208+ gtkSyncIsOwner func() bool
209+ gtkSyncTimeout time.Duration
208 210
209 // scrollCancelSeam overrides the adapter for scroll cancellation in211 // scrollCancelSeam overrides the adapter for scroll cancellation in
210 // tests. Production leaves it nil so the live view bridge is used.212 // tests. Production leaves it nil so the live view bridge is used.
@@ -262,17 +264,27 @@ type WebView struct {
262 // (for example https://dumber.invalid/history). The two forms are updated264 // (for example https://dumber.invalid/history). The two forms are updated
263 // together through setCommittedURLsLocked; never compare one form against265 // together through setCommittedURLsLocked; never compare one form against
264 // the other.266 // the other.
265- uri string267+ uri string
266- committedURL string268+ committedURL string
267- title string269+ title string
268- progress float64270+ progress float64
269- canGoBack bool271+ canGoBack bool
270- canGoFwd bool272+ canGoFwd bool
271- isLoading bool273+ isLoading bool
272- selectedText string274+ selectedText string
273- focusedEditable bool275+ focusedEditable bool
274- inputAttached bool276+ inputAttached bool
275- bridgeNonce string277+ bridgeNonce string
278+ // vimPageToken is the secret for the armed Vim page interaction; results
279+ // carrying any other token are rejected. Empty when none is armed.
280+ vimPageToken string
281+ vimPageKind dto.VimPageInteractionKind
282+ // vimPageCopyAllowance counts y/Enter keys Go forwarded in a visual
283+ // interaction that no accepted copy result has consumed yet.
284+ vimPageCopyAllowance int
285+ // vimPageGeneration increments on every arm so deferred end callbacks
286+ // from an older interaction cannot release a newer one's key capture.
287+ vimPageGeneration uint64
276 selectionDebounceTimer stoppableTimer288 selectionDebounceTimer stoppableTimer
277 selectionDebounceSeq uint64289 selectionDebounceSeq uint64
278 selectionDebounceDelay *time.Duration290 selectionDebounceDelay *time.Duration
@@ -1353,6 +1365,9 @@ func (wv *WebView) Destroy() {
1353 wv.nativePopupFallbackStarted = false1365 wv.nativePopupFallbackStarted = false
1354 wv.popupOpenerBridgeParent = nil1366 wv.popupOpenerBridgeParent = nil
1355 wv.popupOpenerBridgeParentURI = ""1367 wv.popupOpenerBridgeParentURI = ""
1368+ wv.vimPageToken = ""
1369+ wv.vimPageKind = 0
1370+ wv.vimPageCopyAllowance = 0
1356 wv.mu.Unlock()1371 wv.mu.Unlock()
1357 wv.stopNativePopupFallbackTimer()1372 wv.stopNativePopupFallbackTimer()
1358 wv.stopRenderStallWatchdog()1373 wv.stopRenderStallWatchdog()
@@ -2618,6 +2633,10 @@ func (wv *WebView) runOnGTK(fn func()) {
2618 if fn == nil {2633 if fn == nil {
2619 return2634 return
2620 }2635 }
2636+ if wv.gtkDispatch != nil {
2637+ wv.gtkDispatch(fn)
2638+ return
2639+ }
2621 // When engine is nil (test/bootstrap), call directly.2640 // When engine is nil (test/bootstrap), call directly.
2622 if wv.engine == nil {2641 if wv.engine == nil {
2623 fn()2642 fn()
M internal/infrastructure/cef/webview_handlers.go +3 -0
@@ -536,6 +536,7 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
536 // A new document replaces the page that asked for any open JS dialog.536 // A new document replaces the page that asked for any open JS dialog.
537 h.wv.cancelJSDialogs()537 h.wv.cancelJSDialogs()
538 h.wv.invalidateScrollMotion()538 h.wv.invalidateScrollMotion()
539+ h.wv.endVimPageInteractionOnNavigation()
539 h.wv.mu.Lock()540 h.wv.mu.Lock()
540 h.wv.documentSeq++541 h.wv.documentSeq++
541 h.wv.faviconSourcePendingToken = 0542 h.wv.faviconSourcePendingToken = 0
@@ -1307,6 +1308,8 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef
1307 // (and the dead page's callback) hanging.1308 // (and the dead page's callback) hanging.
1308 h.wv.cancelJSDialogs()1309 h.wv.cancelJSDialogs()
1309 1310
1311+ // The page runtime died with the renderer and can no longer report its end.
1312+ h.wv.endVimPageInteractionOnNavigation()
1310 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {1313 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
1311 return // suppress to break the loop1314 return // suppress to break the loop
1312 }1315 }
M internal/infrastructure/config/defaults.go +8 -0
@@ -370,6 +370,14 @@ func DefaultConfig() *Config {
370 "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"},370 "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"},
371 "outline": {Keys: []string{"gO"}, Desc: "Show page outline"},371 "outline": {Keys: []string{"gO"}, Desc: "Show page outline"},
372 "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"},372 "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"},
373+ "yank-paragraph": {Keys: []string{"yap"}, Desc: "Yank first visible paragraph"},
374+ "yank-code": {Keys: []string{"yac"}, Desc: "Yank first visible code block"},
375+ "yank-table": {Keys: []string{"yat"}, Desc: "Yank first visible table"},
376+ "yank-list": {Keys: []string{"yal"}, Desc: "Yank first visible list"},
377+ "hint-follow": {Keys: []string{"f"}, Desc: "Show hints and follow a link or control"},
378+ "hint-follow-new": {Keys: []string{"F"}, Desc: "Show hints and open a link in a new pane"},
379+ "hint-yank-url": {Keys: []string{"yf"}, Desc: "Show hints and yank a link URL"},
380+ "visual": {Keys: []string{"v"}, Desc: "Start visual selection (motions extend, y yanks)"},
373 "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"},381 "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"},
374 "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"},382 "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"},
375 },383 },
M internal/infrastructure/config/vim_mode_config_test.go +27 -14
@@ -20,18 +20,26 @@ var legacyVimModeActions = []string{
20 }20 }
21 21
22 var sequenceVimModeActions = map[string]string{22 var sequenceVimModeActions = map[string]string{
23- "heading-next": "]]",23+ "heading-next": "]]",
24- "heading-prev": "[[",24+ "heading-prev": "[[",
25- "focus-input": "gi",25+ "focus-input": "gi",
26- "code-next": "]c",26+ "code-next": "]c",
27- "code-prev": "[c",27+ "code-prev": "[c",
28- "table-next": "]t",28+ "table-next": "]t",
29- "image-next": "]i",29+ "image-next": "]i",
30- "list-next": "]l",30+ "list-next": "]l",
31- "outline": "gO",31+ "outline": "gO",
32- "yank-section": "yah",32+ "yank-section": "yah",
33- "half-page-down": "<C-d>",33+ "yank-paragraph": "yap",
34- "half-page-up": "<C-u>",34+ "yank-code": "yac",
35+ "yank-table": "yat",
36+ "yank-list": "yal",
37+ "hint-yank-url": "yf",
38+ "half-page-down": "<C-d>",
39+ "half-page-up": "<C-u>",
40+ "hint-follow": "f",
41+ "hint-follow-new": "F",
42+ "visual": "v",
35 }43 }
36 44
37 func TestDefaultConfig_VimModeDefaults(t *testing.T) {45 func TestDefaultConfig_VimModeDefaults(t *testing.T) {
@@ -66,7 +74,7 @@ func TestVimModeSequenceDefaults(t *testing.T) {
66 vimMode := cfg.Workspace.VimMode74 vimMode := cfg.Workspace.VimMode
67 75
68 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)76 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
69- require.Len(t, sequenceVimModeActions, 12)77+ require.Len(t, sequenceVimModeActions, 20)
70 78
71 for action, key := range sequenceVimModeActions {79 for action, key := range sequenceVimModeActions {
72 requireActionBinding(t, vimMode.Actions, action, []string{key})80 requireActionBinding(t, vimMode.Actions, action, []string{key})
@@ -129,7 +137,12 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
129 assert.Equal(t, "yank-section", bindings["yah"])137 assert.Equal(t, "yank-section", bindings["yah"])
130 assert.Equal(t, "half-page-down", bindings["<C-d>"])138 assert.Equal(t, "half-page-down", bindings["<C-d>"])
131 assert.Equal(t, "half-page-up", bindings["<C-u>"])139 assert.Equal(t, "half-page-up", bindings["<C-u>"])
132- assert.Len(t, bindings, 20)140+ assert.Equal(t, "yank-code", bindings["yac"])
141+ assert.Equal(t, "hint-follow", bindings["f"])
142+ assert.Equal(t, "hint-follow-new", bindings["F"])
143+ assert.Equal(t, "hint-yank-url", bindings["yf"])
144+ assert.Equal(t, "visual", bindings["v"])
145+ assert.Len(t, bindings, 28)
133 }146 }
134 147
135 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {148 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
M internal/ui/app.go +11 -1
@@ -2221,13 +2221,17 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
2221 if navigationUC == nil {2221 if navigationUC == nil {
2222 return2222 return
2223 }2223 }
2224- if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil {2224+ outcome, err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor())
2225+ if err != nil {
2225 logging.FromContext(ctx).Debug().2226 logging.FromContext(ctx).Debug().
2226 Err(err).2227 Err(err).
2227 Str("action", action).2228 Str("action", action).
2228 Msg("vim navigation unavailable")2229 Msg("vim navigation unavailable")
2229 return2230 return
2230 }2231 }
2232+ if outcome.CapturePageKeys {
2233+ a.captureVimPageKeys(ctx, bw, wv, outcome)
2234+ }
2231 if bw != nil && (action == "heading-next" || action == "heading-prev") {2235 if bw != nil && (action == "heading-next" || action == "heading-prev") {
2232 if _, ok := wv.(port.SemanticNavigable); ok {2236 if _, ok := wv.(port.SemanticNavigable); ok {
2233 if bw.vimNavigationHighlightedWebViewIDs == nil {2237 if bw.vimNavigationHighlightedWebViewIDs == nil {
@@ -3423,6 +3427,12 @@ func (a *App) initCoordinators(ctx context.Context) {
3423 a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {3427 a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
3424 a.handlePageEditableFocusChanged(ctx, paneID, editable)3428 a.handlePageEditableFocusChanged(ctx, paneID, editable)
3425 })3429 })
3430+ a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) {
3431+ a.handleVimPageInteractionEnded(ctx, paneID)
3432+ })
3433+ a.contentCoord.SetOnVimPageModeChanged(func(paneID entity.PaneID, mode dto.VimPageMode) {
3434+ a.handleVimPageModeChanged(ctx, paneID, mode)
3435+ })
3426 3436
3427 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.3437 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
3428 a.contentCoord.SetPaneViewResolver(a.paneViewForPane)3438 a.contentCoord.SetPaneViewResolver(a.paneViewForPane)
A internal/ui/app_vim_page_test.go +216 -0
@@ -0,0 +1,216 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/bnema/dumber/internal/ui/input"
15+ "github.com/bnema/dumber/internal/ui/layout/mocks"
16+)
17+
18+// newVimPageTestWindow builds a window in Vim Mode with a real mode toaster
19+// bound to the pending-sequence callback, as initChrome does. It records every
20+// text shown on the toast so tests can assert what the user sees.
21+func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) {
22+ t.Helper()
23+ factory := mocks.NewMockWidgetFactory(t)
24+ toaster, container, label := newModeToasterForVimModeTest(t, factory)
25+ shown := &[]string{}
26+ container.EXPECT().RemoveCssClass(mock.Anything).Maybe()
27+ container.EXPECT().AddCssClass(mock.Anything).Maybe()
28+ container.EXPECT().SetHalign(mock.Anything).Maybe()
29+ container.EXPECT().SetValign(mock.Anything).Maybe()
30+ container.EXPECT().SetVisible(mock.Anything).Maybe()
31+ label.EXPECT().SetText(mock.Anything).Run(func(text string) { *shown = append(*shown, text) }).Maybe()
32+
33+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
34+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
35+ ModeIndicatorToasterEnabled: true,
36+ }}},
37+ })}
38+ bw := &browserWindow{id: "win-1", modeToaster: toaster}
39+ app.browserWindows = map[string]*browserWindow{bw.id: bw}
40+ bw.keyboardHandler = newKeyboardHandlerInVimMode(t)
41+ app.bindVimModeSequenceToaster(context.Background(), bw)
42+ return app, bw, shown
43+}
44+
45+var visualOutcome = hintsOutcome(dto.VimPageVisual)
46+
47+func hintsOutcome(kind dto.VimPageInteractionKind) dto.VimNavigationOutcome {
48+ return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: kind, PageMode: dto.VimPageModeHints}
49+}
50+
51+// Starting a key-capturing interaction must leave the sub-mode label on the
52+// toast. The capture path used to reset the pending sequence with
53+// notification, which repainted the toast back to plain "VIM MODE" in the
54+// same instant and made the interaction look as if it had ended.
55+func TestCaptureVimPageKeysKeepsSubModeLabelOnToast(t *testing.T) {
56+ app, bw, shown := newVimPageTestWindow(t)
57+ wv := portmocks.NewMockWebView(t)
58+
59+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
60+
61+ require.NotEmpty(t, *shown)
62+ require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1], "every interaction opens on hints")
63+ require.True(t, bw.keyboardHandler.PageKeyCaptureActive())
64+}
65+
66+// A completed sequence is reported with an empty pending string before its
67+// action runs. When that action starts a page interaction, the stale pending
68+// notification must not reach the toast after the sub-mode label.
69+func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) {
70+ app, bw, shown := newVimPageTestWindow(t)
71+ wv := portmocks.NewMockWebView(t)
72+
73+ app.showPendingSequence(context.Background(), bw, "")
74+ app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollow))
75+ // Any pending notification emitted while the page owns the keys is noise.
76+ app.showPendingSequence(context.Background(), bw, "")
77+
78+ require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
79+}
80+
81+// The legend is a key reference for Vim Mode bindings; while the page owns the
82+// keys it would describe keys that do not apply, so it must be hidden and
83+// restored when the interaction ends.
84+func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
85+ app, bw, _ := newVimPageTestWindow(t)
86+ frame := &modeFrame{mode: input.ModeVim, visible: true}
87+ bw.modeFrame = frame
88+ wv := portmocks.NewMockWebView(t)
89+
90+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
91+ require.True(t, frame.suspended, "legend must be suspended while the page captures keys")
92+
93+ wv.EXPECT().ID().Return(1).Maybe()
94+ app.endVimPageInteraction(context.Background(), bw, true)
95+ require.False(t, frame.suspended, "legend must be restored when the interaction ends")
96+ require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
97+}
98+
99+// The page moves between hints, caret, visual and visual line without ending
100+// the interaction: the toast follows each state while key capture and the
101+// suspended legend stay in place.
102+func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
103+ app, bw, shown := newVimPageTestWindow(t)
104+ frame := &modeFrame{mode: input.ModeVim, visible: true}
105+ bw.modeFrame = frame
106+ wv := portmocks.NewMockWebView(t)
107+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
108+ require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
109+
110+ for mode, want := range map[dto.VimPageMode]string{
111+ dto.VimPageModeCaret: "VIM MODE · CARET",
112+ dto.VimPageModeVisual: "VIM MODE · VISUAL",
113+ dto.VimPageModeVisualLine: "VIM MODE · VISUAL LINE",
114+ dto.VimPageModeHints: "VIM MODE · HINTS",
115+ } {
116+ app.showVimPageMode(context.Background(), bw, mode)
117+ require.Equal(t, want, (*shown)[len(*shown)-1])
118+ require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode)
119+ require.NotNil(t, bw.vimPageInteractionWebView)
120+ require.True(t, frame.suspended, "legend stays hidden while the page owns the keys")
121+ }
122+}
123+
124+func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) {
125+ app, bw, shown := newVimPageTestWindow(t)
126+
127+ app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret)
128+
129+ require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended")
130+}
131+
132+// exitModeWithin runs fn and fails the test if it does not return in time,
133+// so a lock re-entry deadlock shows up as a failure instead of a hung run.
134+func exitModeWithin(t *testing.T, fn func()) {
135+ t.Helper()
136+ done := make(chan struct{})
137+ go func() {
138+ defer close(done)
139+ fn()
140+ }()
141+ select {
142+ case <-done:
143+ case <-time.After(3 * time.Second):
144+ t.Fatal("ExitMode did not return: mode-change callback re-entered the modal lock")
145+ }
146+}
147+
148+// ExitMode runs the mode-change callback under the modal lock. Releasing the
149+// page capture from that callback must not read the mode back through the lock.
150+func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) {
151+ app, bw, _ := newVimPageTestWindow(t)
152+ bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
153+ app.handleModeChange(context.Background(), bw, from, to)
154+ })
155+ wv := portmocks.NewMockWebView(t)
156+ wv.EXPECT().ID().Return(1).Maybe()
157+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
158+
159+ exitModeWithin(t, bw.keyboardHandler.ExitMode)
160+
161+ require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
162+ require.Nil(t, bw.vimPageInteractionWebView)
163+ require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode())
164+}
165+
166+func TestVimPageModeLabel(t *testing.T) {
167+ for mode, want := range map[dto.VimPageMode]string{
168+ dto.VimPageModeHints: "HINTS",
169+ dto.VimPageModeCaret: "CARET",
170+ dto.VimPageModeVisual: "VISUAL",
171+ dto.VimPageModeVisualLine: "VISUAL LINE",
172+ } {
173+ got, ok := vimPageModeLabel(mode)
174+ require.True(t, ok, mode)
175+ require.Equal(t, want, got)
176+ }
177+ _, ok := vimPageModeLabel("bogus")
178+ require.False(t, ok)
179+}
180+
181+// One mapping drives every label: the sub-mode, plus the target of hints that
182+// do more than follow. Later page-reported modes drop the hint target.
183+func TestVimPageLabel(t *testing.T) {
184+ tests := []struct {
185+ kind dto.VimPageInteractionKind
186+ mode dto.VimPageMode
187+ want string
188+ }{
189+ {dto.VimPageVisual, dto.VimPageModeHints, "HINTS"},
190+ {dto.VimPageHintFollow, dto.VimPageModeHints, "HINTS"},
191+ {dto.VimPageHintFollowNew, dto.VimPageModeHints, "HINTS · NEW PANE"},
192+ {dto.VimPageHintYankURL, dto.VimPageModeHints, "HINTS · YANK URL"},
193+ {dto.VimPageVisual, dto.VimPageModeCaret, "CARET"},
194+ {dto.VimPageVisual, dto.VimPageModeVisual, "VISUAL"},
195+ {dto.VimPageVisual, dto.VimPageModeVisualLine, "VISUAL LINE"},
196+ }
197+ for _, tt := range tests {
198+ got, ok := vimPageLabel(tt.kind, tt.mode)
199+ require.True(t, ok)
200+ require.Equal(t, tt.want, got, "kind %d mode %s", tt.kind, tt.mode)
201+ }
202+ _, ok := vimPageLabel(dto.VimPageVisual, "bogus")
203+ require.False(t, ok)
204+}
205+
206+func TestVimPageInteractionLabelsFollowTheOutcome(t *testing.T) {
207+ app, bw, shown := newVimPageTestWindow(t)
208+ wv := portmocks.NewMockWebView(t)
209+
210+ app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollowNew))
211+ require.Equal(t, "VIM MODE · HINTS · NEW PANE", (*shown)[len(*shown)-1])
212+ app.releaseVimPageKeys(context.Background(), bw, "test", true)
213+
214+ app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintYankURL))
215+ require.Equal(t, "VIM MODE · HINTS · YANK URL", (*shown)[len(*shown)-1])
216+}
M internal/ui/browser_window.go +8 -0
@@ -6,6 +6,7 @@ import (
6 "slices"6 "slices"
7 "sort"7 "sort"
8 8
9+ "github.com/bnema/dumber/internal/application/dto"
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/application/usecase"11 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
@@ -59,6 +60,11 @@ type browserWindow struct {
59 // navigation target that must be cleared when this window leaves Vim Mode.60 // navigation target that must be cleared when this window leaves Vim Mode.
60 vimNavigationHighlightedWebViews []port.WebView61 vimNavigationHighlightedWebViews []port.WebView
61 vimNavigationHighlightedWebViewIDs map[port.WebViewID]struct{}62 vimNavigationHighlightedWebViewIDs map[port.WebViewID]struct{}
63+ // vimPageInteractionWebView is the WebView running link hints or visual
64+ // selection while this window's keys are captured for the page.
65+ vimPageInteractionWebView port.WebView
66+ // vimPageInteractionKind names that interaction for its indicator label.
67+ vimPageInteractionKind dto.VimPageInteractionKind
62 }68 }
63 69
64 func (bw *browserWindow) detachInputForDestroy() {70 func (bw *browserWindow) detachInputForDestroy() {
@@ -121,6 +127,8 @@ func (bw *browserWindow) clearShellState() {
121 bw.vimModePaneID = ""127 bw.vimModePaneID = ""
122 bw.vimNavigationHighlightedWebViews = nil128 bw.vimNavigationHighlightedWebViews = nil
123 bw.vimNavigationHighlightedWebViewIDs = nil129 bw.vimNavigationHighlightedWebViewIDs = nil
130+ bw.vimPageInteractionWebView = nil
131+ bw.vimPageInteractionKind = 0
124 }132 }
125 133
126 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {134 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
M internal/ui/coordinator/content/callbacks.go +17 -0
@@ -5,6 +5,7 @@ import (
5 "net/url"5 "net/url"
6 "strings"6 "strings"
7 7
8+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
@@ -187,12 +188,28 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
187 }188 }
188 }189 }
189 190
191+ c.setupVimPageCallbacks(paneID, callbacks)
192+
190 // Add popup create handler if popup handling is configured193 // Add popup create handler if popup handling is configured
191 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)194 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
192 195
193 wv.SetCallbacks(callbacks)196 wv.SetCallbacks(callbacks)
194 }197 }
195 198
199+// setupVimPageCallbacks forwards in-page Vim interaction events for one pane.
200+func (c *Coordinator) setupVimPageCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) {
201+ callbacks.OnVimPageInteractionEnded = func() {
202+ if c.onVimPageInteractionEnded != nil {
203+ c.onVimPageInteractionEnded(paneID)
204+ }
205+ }
206+ callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) {
207+ if c.onVimPageModeChanged != nil {
208+ c.onVimPageModeChanged(paneID, mode)
209+ }
210+ }
211+}
212+
196 // handlePermissionRequest processes media permission requests from WebKit.213 // handlePermissionRequest processes media permission requests from WebKit.
197 // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.214 // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
198 func (c *Coordinator) handlePermissionRequest(215 func (c *Coordinator) handlePermissionRequest(
M internal/ui/coordinator/content/coordinator.go +17 -0
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "sync"5 "sync"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -121,6 +122,10 @@ type Coordinator struct {
121 122
122 // Callback when a page editable target gains or loses focus.123 // Callback when a page editable target gains or loses focus.
123 onEditableFocusChanged func(paneID entity.PaneID, editable bool)124 onEditableFocusChanged func(paneID entity.PaneID, editable bool)
125+ // onVimPageInteractionEnded fires when a page finishes link hints or visual selection.
126+ onVimPageInteractionEnded func(paneID entity.PaneID)
127+ // onVimPageModeChanged fires when a page interaction moves to another sub-mode.
128+ onVimPageModeChanged func(paneID entity.PaneID, mode dto.VimPageMode)
124 129
125 // Callback for first load_started event (triggers deferred initialization)130 // Callback for first load_started event (triggers deferred initialization)
126 onFirstLoadStarted func()131 onFirstLoadStarted func()
@@ -277,6 +282,18 @@ func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, ed
277 c.onEditableFocusChanged = fn282 c.onEditableFocusChanged = fn
278 }283 }
279 284
285+// SetOnVimPageInteractionEnded sets the callback for the end of an in-page
286+// Vim interaction (link hints or visual selection).
287+func (c *Coordinator) SetOnVimPageInteractionEnded(fn func(paneID entity.PaneID)) {
288+ c.onVimPageInteractionEnded = fn
289+}
290+
291+// SetOnVimPageModeChanged sets the callback for sub-mode changes of an
292+// in-page Vim interaction (hints, caret, visual).
293+func (c *Coordinator) SetOnVimPageModeChanged(fn func(paneID entity.PaneID, mode dto.VimPageMode)) {
294+ c.onVimPageModeChanged = fn
295+}
296+
280 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.297 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.
281 // This is used to trigger deferred initialization after the initial load_uri()298 // This is used to trigger deferred initialization after the initial load_uri()
282 // has been processed by the GTK main loop.299 // has been processed by the GTK main loop.
M internal/ui/input/keyboard.go +10 -0
@@ -121,6 +121,10 @@ type KeyboardHandler struct {
121 vimScrollNow func() time.Time121 vimScrollNow func() time.Time
122 122
123 seq vimModeSequenceState123 seq vimModeSequenceState
124+
125+ // pageKeyCapture forwards Vim Mode keys to an in-page interaction (link
126+ // hints or visual selection) while it is active.
127+ pageKeyCapture PageKeyForwarder
124 }128 }
125 129
126 // NewKeyboardHandler creates a new keyboard handler.130 // NewKeyboardHandler creates a new keyboard handler.
@@ -218,6 +222,7 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
218 h.setControllerPhase(gtk.PhaseCaptureValue)222 h.setControllerPhase(gtk.PhaseCaptureValue)
219 }223 }
220 if to != ModeVim {224 if to != ModeVim {
225+ h.ClearPageKeyCapture()
221 h.stopVimScrollRepeat()226 h.stopVimScrollRepeat()
222 // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry).227 // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry).
223 h.resetPendingSequence(false)228 h.resetPendingSequence(false)
@@ -407,6 +412,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
407 }412 }
408 }413 }
409 414
415+ // An in-page Vim interaction owns every key, including Escape and Enter.
416+ if h.forwardPageKey(mode, keyval, state) {
417+ return true
418+ }
419+
410 if h.handleGuaranteedModalExit(mode, keyval, modifiers) {420 if h.handleGuaranteedModalExit(mode, keyval, modifiers) {
411 return true421 return true
412 }422 }
A internal/ui/input/vim_page_capture.go +101 -0
@@ -0,0 +1,101 @@
1+package input
2+
3+import (
4+ "github.com/bnema/dumber/internal/domain/vimkeys"
5+ "github.com/bnema/puregotk/v4/gdk"
6+)
7+
8+// PageKeyForwarder receives canonical vimkeys strings ("j", "J", "<Escape>")
9+// while an in-page Vim interaction owns keyboard input.
10+type PageKeyForwarder func(key string)
11+
12+// SetPageKeyCapture routes Vim Mode keys to fn until ClearPageKeyCapture or
13+// Vim Mode exits. Pending sequences are discarded so they cannot complete
14+// against keys meant for the page. The reset is silent: the pending listener
15+// repaints the mode indicator, and a notification here would overwrite the
16+// sub-mode label the caller shows for the interaction.
17+func (h *KeyboardHandler) SetPageKeyCapture(fn PageKeyForwarder) {
18+ h.resetPendingSequence(false)
19+ h.mu.Lock()
20+ h.pageKeyCapture = fn
21+ h.mu.Unlock()
22+}
23+
24+// ClearPageKeyCapture returns key handling to Vim Mode bindings.
25+func (h *KeyboardHandler) ClearPageKeyCapture() {
26+ h.mu.Lock()
27+ h.pageKeyCapture = nil
28+ h.mu.Unlock()
29+}
30+
31+// PageKeyCaptureActive reports whether an in-page interaction owns keys.
32+func (h *KeyboardHandler) PageKeyCaptureActive() bool {
33+ h.mu.RLock()
34+ defer h.mu.RUnlock()
35+ return h.pageKeyCapture != nil
36+}
37+
38+// isVimModeToggleKey reports whether the key is the configured Vim Mode
39+// activation shortcut. While Vim Mode is active it exits the mode, so it must
40+// stay with Go even when a page interaction owns every other key.
41+func (h *KeyboardHandler) isVimModeToggleKey(keyval uint, state gdk.ModifierType) bool {
42+ h.mu.RLock()
43+ shortcuts := h.shortcuts
44+ h.mu.RUnlock()
45+ if shortcuts == nil {
46+ return false
47+ }
48+ binding := KeyBinding{Keyval: normalizeKeyval(keyval), Modifiers: Modifier(state) & modifierMask}
49+ action, found := shortcuts.Lookup(binding, ModeVim)
50+ return found && action == ActionEnterVimMode
51+}
52+
53+// forwardPageKey sends a Vim Mode key to the active page capture, except the
54+// Vim Mode toggle shortcut, which always falls through. Escape is
55+// forwarded like any other key: the page decides whether it steps back (visual
56+// to caret) or ends the interaction, and reports the end so the capture is
57+// released. A page that cannot be reached ends the capture through the
58+// forwarder's error path, navigation, or leaving Vim Mode.
59+func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.ModifierType) bool {
60+ if mode != ModeVim {
61+ return false
62+ }
63+ h.mu.RLock()
64+ forward := h.pageKeyCapture
65+ h.mu.RUnlock()
66+ if forward == nil {
67+ return false
68+ }
69+ if isModifierKeyval(keyval) {
70+ return true
71+ }
72+ if h.isVimModeToggleKey(keyval, state) {
73+ // Escape hatch: a page can never swallow the shortcut that leaves Vim Mode.
74+ return false
75+ }
76+ key, ok := KeyvalToVimKey(keyval, state)
77+ if !ok {
78+ return true
79+ }
80+ if key.Sym == "Esc" {
81+ // The page must see a plain Escape whatever modifiers are held.
82+ key.Mods = 0
83+ }
84+ forward(vimkeys.Sequence{key}.String())
85+ // Captured keys bypass dispatchAction, so keep Vim Mode's timeout alive.
86+ h.modal.ResetTimeout(h.ctx)
87+ return true
88+}
89+
90+func isModifierKeyval(keyval uint) bool {
91+ switch keyval {
92+ case uint(gdk.KEY_Shift_L), uint(gdk.KEY_Shift_R),
93+ uint(gdk.KEY_Control_L), uint(gdk.KEY_Control_R),
94+ uint(gdk.KEY_Alt_L), uint(gdk.KEY_Alt_R),
95+ uint(gdk.KEY_Super_L), uint(gdk.KEY_Super_R),
96+ uint(gdk.KEY_Caps_Lock), uint(gdk.KEY_ISO_Level3_Shift):
97+ return true
98+ default:
99+ return false
100+ }
101+}
A internal/ui/input/vim_page_capture_test.go +118 -0
@@ -0,0 +1,118 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/puregotk/v4/gdk"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/domain/entity"
12+)
13+
14+func TestPageKeyCaptureForwardsVimModeKeys(t *testing.T) {
15+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
16+ "vim-scroll-down": {Keys: []string{"j"}},
17+ "yank-section": {Keys: []string{"yah"}},
18+ })
19+ h := NewKeyboardHandler(context.Background(), ws, newTestSession())
20+ actions := captureSequenceActions(h)
21+ enterVimMode(t, h)
22+
23+ var forwarded []string
24+ h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) })
25+
26+ require.True(t, h.handleKeyPress(uint('y'), 0, 0))
27+ require.True(t, h.handleKeyPress(uint('J'), 0, gdk.ShiftMaskValue))
28+ require.True(t, h.handleKeyPress(uint(gdk.KEY_Shift_L), 0, 0))
29+ require.True(t, h.handleKeyPress(uint(gdk.KEY_Return), 0, 0))
30+
31+ assert.Equal(t, []string{"y", "J", "<Return>"}, forwarded)
32+ assert.Empty(t, *actions, "captured keys must not reach Vim bindings")
33+ assert.Equal(t, ModeVim, h.Mode(), "Enter is forwarded instead of confirming Vim Mode")
34+ assert.Empty(t, h.PendingSequence())
35+}
36+
37+func TestPageKeyCaptureEscapeIsForwardedAndKeepsCaptureForPageToEnd(t *testing.T) {
38+ h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession())
39+ enterVimMode(t, h)
40+ var forwarded []string
41+ h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) })
42+
43+ require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0))
44+
45+ assert.Equal(t, []string{"<Escape>"}, forwarded)
46+ assert.True(t, h.PageKeyCaptureActive(), "the page steps back or reports the end itself")
47+ assert.Equal(t, ModeVim, h.Mode())
48+
49+ h.ClearPageKeyCapture()
50+ assert.False(t, h.PageKeyCaptureActive())
51+}
52+
53+func TestPageKeyCaptureClearedWhenLeavingVimMode(t *testing.T) {
54+ h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession())
55+ enterVimMode(t, h)
56+ h.SetPageKeyCapture(func(string) {})
57+
58+ h.ExitMode()
59+
60+ assert.False(t, h.PageKeyCaptureActive())
61+}
62+
63+func TestPageKeyCaptureModifiedEscapeForwardsPlainEscape(t *testing.T) {
64+ h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession())
65+ enterVimMode(t, h)
66+ var forwarded []string
67+ h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) })
68+
69+ require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, gdk.ShiftMaskValue))
70+
71+ assert.Equal(t, []string{"<Escape>"}, forwarded)
72+}
73+
74+// Capturing keys for the page must not report a cleared pending sequence:
75+// the pending callback repaints the mode toast and would erase the sub-mode
76+// label shown an instant earlier.
77+func TestSetPageKeyCaptureDoesNotNotifyPendingListeners(t *testing.T) {
78+ h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession())
79+ enterVimMode(t, h)
80+ pending := capturePending(h)
81+
82+ h.SetPageKeyCapture(func(string) {})
83+
84+ assert.Empty(t, *pending)
85+ assert.Empty(t, h.PendingSequence())
86+}
87+
88+// The Vim Mode toggle shortcut is the escape hatch from a page interaction:
89+// it must reach Go, leave Vim Mode and release the capture, never the page.
90+func TestPageKeyCaptureLeavesVimModeToggleShortcutToGo(t *testing.T) {
91+ h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession())
92+ enterVimMode(t, h)
93+ var forwarded []string
94+ h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) })
95+ h.handleKeyRelease(uint('y')) // the activation press is over; this is a new press
96+
97+ require.True(t, h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue))
98+
99+ assert.Empty(t, forwarded, "the toggle shortcut must not be forwarded to the page")
100+ assert.Equal(t, ModeNormal, h.Mode())
101+ assert.False(t, h.PageKeyCaptureActive(), "leaving Vim Mode releases the capture")
102+}
103+
104+func TestPageKeyCaptureHonorsConfiguredToggleShortcut(t *testing.T) {
105+ ws := vimModeSequenceWorkspace(nil)
106+ ws.VimMode.ActivationShortcut = "ctrl+g"
107+ h := NewKeyboardHandler(context.Background(), ws, newTestSession())
108+ h.EnterVimMode()
109+ var forwarded []string
110+ h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) })
111+
112+ require.True(t, h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue), "ctrl+y is an ordinary key now")
113+ assert.Equal(t, []string{"<C-y>"}, forwarded)
114+ require.True(t, h.handleKeyPress(uint('g'), 0, gdk.ControlMaskValue))
115+
116+ assert.Equal(t, []string{"<C-y>"}, forwarded)
117+ assert.Equal(t, ModeNormal, h.Mode())
118+}
M internal/ui/input/vimkeys_dispatch.go +18 -0
@@ -35,6 +35,19 @@ func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) {
35 return nil, false35 return nil, false
36 }36 }
37 37
38+// trieOnlyActionBinding owns a single-chord binding when its action has no
39+// legacy shortcut mapping and must therefore complete through the trie.
40+func trieOnlyActionBinding(action, raw string) (vimkeys.Sequence, bool) {
41+ if mapModeConfigAction(action, "vim") != "" {
42+ return nil, false
43+ }
44+ seq, err := vimkeys.ParseBinding(raw)
45+ if err != nil || len(seq) != 1 || isCountStartKey(seq[0]) {
46+ return nil, false
47+ }
48+ return seq, true
49+}
50+
38 // buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are51 // buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are
39 // sorted so the first sorted name wins on Insert conflicts (validation already52 // sorted so the first sorted name wins on Insert conflicts (validation already
40 // rejects duplicates). Ownership is marked only after a successful Insert.53 // rejects duplicates). Ownership is marked only after a successful Insert.
@@ -57,6 +70,11 @@ func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool
57 sort.Strings(keys)70 sort.Strings(keys)
58 for _, key := range keys {71 for _, key := range keys {
59 seq, ok := trieOwnsBinding(key)72 seq, ok := trieOwnsBinding(key)
73+ if !ok {
74+ // Single chords for actions without a legacy Action (for example
75+ // "f" or "v") can only be dispatched through the sequence trie.
76+ seq, ok = trieOnlyActionBinding(action, key)
77+ }
60 if !ok {78 if !ok {
61 continue79 continue
62 }80 }
M internal/ui/input/vimkeys_dispatch_test.go +6 -0
@@ -161,6 +161,9 @@ func TestBuildVimModeTrie(t *testing.T) {
161 "yank-section": {Keys: []string{"yah"}},161 "yank-section": {Keys: []string{"yah"}},
162 "outline": {Keys: []string{"gO"}},162 "outline": {Keys: []string{"gO"}},
163 "half-page-down": {Keys: []string{"<C-d>"}},163 "half-page-down": {Keys: []string{"<C-d>"}},
164+ "hint-follow": {Keys: []string{"f"}},
165+ "hint-follow-new": {Keys: []string{"F"}},
166+ "visual": {Keys: []string{"v"}},
164 "confirm": {Keys: []string{"enter"}},167 "confirm": {Keys: []string{"enter"}},
165 "cancel": {Keys: []string{"escape"}},168 "cancel": {Keys: []string{"escape"}},
166 },169 },
@@ -177,6 +180,9 @@ func TestBuildVimModeTrie(t *testing.T) {
177 "yah": true,180 "yah": true,
178 "gO": true,181 "gO": true,
179 "<C-d>": true,182 "<C-d>": true,
183+ "f": true,
184+ "F": true,
185+ "v": true,
180 }186 }
181 for key, want := range wantOwned {187 for key, want := range wantOwned {
182 if owned[key] != want {188 if owned[key] != want {
M internal/ui/mode_frame.go +57 -19
@@ -26,22 +26,25 @@ const (
26 // modeFrame owns the per-window mode border and its non-interactive legend.26 // modeFrame owns the per-window mode border and its non-interactive legend.
27 // GTK access is restricted to the main thread, as with the rest of the UI.27 // GTK access is restricted to the main thread, as with the rest of the UI.
28 type modeFrame struct {28 type modeFrame struct {
29- mode input.Mode29+ mode input.Mode
30- tabID entity.TabID30+ tabID entity.TabID
31- target layout.Widget31+ target layout.Widget
32- frameClass string32+ frameClass string
33- root *gtk.Box33+ root *gtk.Box
34- border *gtk.Box34+ border *gtk.Box
35- borderStyle cssClassTarget // border's CSS classes; a seam for tests35+ borderStyle cssClassTarget // border's CSS classes; a seam for tests
36- borderRect func() (x, y, width, height int, ok bool)36+ borderRect func() (x, y, width, height int, ok bool)
37- panel *gtk.Box37+ panel *gtk.Box
38- content *gtk.FlowBox38+ content *gtk.FlowBox
39- scroller *gtk.ScrolledWindow39+ scroller *gtk.ScrolledWindow
40- heading *gtk.Label40+ heading *gtk.Label
41- rows map[string][]*gtk.Widget41+ rows map[string][]*gtk.Widget
42- keycaps map[string][]*gtk.Widget42+ keycaps map[string][]*gtk.Widget
43- pending string43+ pending string
44- visible bool44+ visible bool
45+ // suspended hides the legend while an in-page interaction (link hints,
46+ // visual selection) owns the keys; the border keeps tracking its target.
47+ suspended bool
45 style string48 style string
46 modeClass string49 modeClass string
47 config entity.WorkspaceStylingConfig50 config entity.WorkspaceStylingConfig
@@ -330,6 +333,7 @@ func (f *modeFrame) setMode(
330 f.mode, f.config = mode, cfg333 f.mode, f.config = mode, cfg
331 f.pending = ""334 f.pending = ""
332 f.pulseCycle = false335 f.pulseCycle = false
336+ f.suspended = false
333 if linger {337 if linger {
334 f.lingering = true338 f.lingering = true
335 f.visible = false339 f.visible = false
@@ -431,6 +435,27 @@ func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float6
431 return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)435 return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
432 }436 }
433 437
438+// setSuspended hides the legend while suspended is true and brings it back
439+// otherwise, without touching the mode, its border, or a pending show timer.
440+// Used while an in-page interaction owns the keys, when the legend would list
441+// bindings that do not apply.
442+func (f *modeFrame) setSuspended(suspended bool) {
443+ if f == nil || f.suspended == suspended {
444+ return
445+ }
446+ f.suspended = suspended
447+ if f.root == nil || f.lingering {
448+ return
449+ }
450+ if suspended {
451+ f.clearLegend()
452+ return
453+ }
454+ if f.visible {
455+ f.refreshGeometry()
456+ }
457+}
458+
434 func (f *modeFrame) dismissLinger() {459 func (f *modeFrame) dismissLinger() {
435 if f == nil || !f.lingering {460 if f == nil || !f.lingering {
436 return461 return
@@ -554,7 +579,7 @@ func (f *modeFrame) refreshGeometry() {
554 }579 }
555 x, bottom := int(out.X), int(out.Y)+height580 x, bottom := int(out.X), int(out.Y)+height
556 f.placeBorder(x, int(out.Y), width, height)581 f.placeBorder(x, int(out.Y), width, height)
557- if !f.visible {582+ if !f.visible || f.suspended {
558 f.clearLegend()583 f.clearLegend()
559 return584 return
560 }585 }
@@ -618,6 +643,19 @@ func (f *modeFrame) sizeLegendColumns(columns uint, width int) {
618 }643 }
619 }644 }
620 645
646+// vimLegendGroup returns the legend group of a Vim Mode action, or "" when the
647+// action belongs to a generic group.
648+func vimLegendGroup(name string) string {
649+ switch {
650+ case strings.HasPrefix(name, "hint-") || strings.HasPrefix(name, "yank-") || name == "visual":
651+ return "SELECT"
652+ case strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline":
653+ return "JUMP"
654+ default:
655+ return ""
656+ }
657+}
658+
621 func modeLegendGroup(mode input.Mode, name string) string {659 func modeLegendGroup(mode input.Mode, name string) string {
622 switch {660 switch {
623 case name == "confirm" || name == "cancel":661 case name == "confirm" || name == "cancel":
@@ -632,8 +670,8 @@ func modeLegendGroup(mode input.Mode, name string) string {
632 return "RESIZE"670 return "RESIZE"
633 case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):671 case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
634 return "SCROLL"672 return "SCROLL"
635- case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):673+ case mode == input.ModeVim && vimLegendGroup(name) != "":
636- return "JUMP"674+ return vimLegendGroup(name)
637 case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"):675 case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"):
638 return "SWITCH"676 return "SWITCH"
639 default:677 default:
M internal/ui/mode_frame_legend.go +1 -1
@@ -49,7 +49,7 @@ func (f *modeFrame) build(actions map[string]entity.ActionBinding) {
49 group := modeLegendGroup(f.mode, name)49 group := modeLegendGroup(f.mode, name)
50 groups[group] = append(groups[group], name)50 groups[group] = append(groups[group], name)
51 }51 }
52- order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "MODES", "EXIT"}52+ order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "SELECT", "MANAGE", "MODES", "EXIT"}
53 index := 053 index := 0
54 for _, groupName := range order {54 for _, groupName := range order {
55 names := groups[groupName]55 names := groups[groupName]
M internal/ui/mode_frame_test.go +3 -0
@@ -109,6 +109,9 @@ func TestModeLegendActionsUseActiveConfiguration(t *testing.T) {
109 require.Equal(t, "→", displayLegendKey("arrowright"))109 require.Equal(t, "→", displayLegendKey("arrowright"))
110 require.Equal(t, "SPLIT", modeLegendGroup(input.ModePane, "split-right"))110 require.Equal(t, "SPLIT", modeLegendGroup(input.ModePane, "split-right"))
111 require.Equal(t, "JUMP", modeLegendGroup(input.ModeVim, "heading-next"))111 require.Equal(t, "JUMP", modeLegendGroup(input.ModeVim, "heading-next"))
112+ require.Equal(t, "SELECT", modeLegendGroup(input.ModeVim, "hint-follow-new"))
113+ require.Equal(t, "SELECT", modeLegendGroup(input.ModeVim, "yank-code"))
114+ require.Equal(t, "SELECT", modeLegendGroup(input.ModeVim, "visual"))
112 }115 }
113 116
114 func TestModeFramePendingSequenceFiltersBindings(t *testing.T) {117 func TestModeFramePendingSequenceFiltersBindings(t *testing.T) {
M internal/ui/vim_mode.go +26 -10
@@ -117,6 +117,9 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
117 a.enableAccessibilityForVimMode(ctx, bw)117 a.enableAccessibilityForVimMode(ctx, bw)
118 }118 }
119 if from == input.ModeVim && to != input.ModeVim {119 if from == input.ModeVim && to != input.ModeVim {
120+ // Runs under the modal lock: the window is leaving Vim Mode, so do not
121+ // read the mode back.
122+ a.endVimPageInteraction(ctx, bw, false)
120 a.clearVimNavigationHighlight(ctx, bw)123 a.clearVimNavigationHighlight(ctx, bw)
121 }124 }
122 125
@@ -142,6 +145,8 @@ func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWin
142 if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID {145 if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID {
143 return146 return
144 }147 }
148+ // A page interaction belongs to the previous owner's WebView.
149+ a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
145 150
146 // Check if this window is actually in vim mode.151 // Check if this window is actually in vim mode.
147 if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {152 if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {
@@ -313,6 +318,11 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
313 if a == nil || bw == nil {318 if a == nil || bw == nil {
314 return319 return
315 }320 }
321+ if bw.vimPageInteractionWebView != nil {
322+ // The page owns the keys: the indicator shows the interaction's
323+ // sub-mode, and the legend is suspended. Nothing to repaint.
324+ return
325+ }
316 if bw.modeFrame != nil {326 if bw.modeFrame != nil {
317 cfg := a.runtimeConfigSnapshot().UI.Workspace327 cfg := a.runtimeConfigSnapshot().UI.Workspace
318 bw.modeFrame.setPending(pending, cfg.VimMode.Actions)328 bw.modeFrame.setPending(pending, cfg.VimMode.Actions)
@@ -320,25 +330,31 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
320 if bw.modeToaster == nil {330 if bw.modeToaster == nil {
321 return331 return
322 }332 }
323- log := logging.FromContext(ctx)333+
334+ a.showVimModeToast(ctx, bw, pending)
335+ logging.FromContext(ctx).Debug().
336+ Str("window_id", bw.id).
337+ Str("pending", pending).
338+ Msg("vim mode pending toaster updated")
339+}
340+
341+// showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the
342+// per-window mode toaster, honoring the indicator toggle.
343+func (a *App) showVimModeToast(ctx context.Context, bw *browserWindow, detail string) {
344+ if bw == nil || bw.modeToaster == nil {
345+ return
346+ }
324 if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {347 if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
325 bw.modeToaster.Hide()348 bw.modeToaster.Hide()
326- log.Debug().Str("window_id", bw.id).Msg("vim mode pending toaster hidden (disabled)")
327 return349 return
328 }350 }
329-
330 text := input.ModeVim.DisplayName()351 text := input.ModeVim.DisplayName()
331- if pending != "" {352+ if detail != "" {
332- text = text + " · " + pending353+ text = text + " · " + detail
333 }354 }
334 bw.modeToaster.Show(ctx, text, component.ToastInfo,355 bw.modeToaster.Show(ctx, text, component.ToastInfo,
335 component.WithDuration(0),356 component.WithDuration(0),
336 component.WithPosition(component.ToastPositionBottomLeft),357 component.WithPosition(component.ToastPositionBottomLeft),
337 component.WithModeClass(getModeToastClass(input.ModeVim)),358 component.WithModeClass(getModeToastClass(input.ModeVim)),
338 )359 )
339- log.Debug().
340- Str("window_id", bw.id).
341- Str("pending", pending).
342- Str("toast_text", text).
343- Msg("vim mode pending toaster updated")
344 }360 }
A internal/ui/vim_page.go +156 -0
@@ -0,0 +1,156 @@
1+package ui
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// captureVimPageKeys routes this window's Vim Mode keys to the in-page
13+// interaction running in wv until the page reports its end. The outcome names
14+// the interaction and the sub-mode it starts in.
15+func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, outcome dto.VimNavigationOutcome) {
16+ if bw == nil || bw.keyboardHandler == nil || wv == nil {
17+ return
18+ }
19+ bw.vimPageInteractionWebView = wv
20+ bw.vimPageInteractionKind = outcome.PageKind
21+ label, _ := vimPageLabel(outcome.PageKind, outcome.PageMode)
22+ // Capture first: it resets the pending sequence, and the toast and legend
23+ // below must reflect the sub-mode, not a cleared sequence.
24+ bw.keyboardHandler.SetPageKeyCapture(func(key string) {
25+ if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {
26+ logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")
27+ a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
28+ }
29+ })
30+ a.showVimModeToast(ctx, bw, label)
31+ if bw.modeFrame != nil {
32+ bw.modeFrame.setSuspended(true)
33+ }
34+ logging.FromContext(ctx).Debug().
35+ Str("window_id", bw.id).
36+ Str("sub_mode", label).
37+ Msg("vim page interaction capturing keys")
38+}
39+
40+// releaseVimPageKeys returns this window's keys to Vim Mode bindings and
41+// restores the plain indicator and legend when stillVim reports the window
42+// remains in Vim Mode. The caller supplies it because the mode-change callback
43+// runs under the modal lock, where reading the mode back would deadlock.
44+// It reports the WebView that owned the interaction, if any.
45+func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string, stillVim bool) port.WebView {
46+ wv := bw.vimPageInteractionWebView
47+ bw.vimPageInteractionWebView = nil
48+ bw.vimPageInteractionKind = 0
49+ if bw.keyboardHandler != nil {
50+ bw.keyboardHandler.ClearPageKeyCapture()
51+ }
52+ if wv == nil {
53+ return nil
54+ }
55+ if stillVim {
56+ a.showVimModeToast(ctx, bw, "")
57+ if bw.modeFrame != nil {
58+ bw.modeFrame.setSuspended(false)
59+ }
60+ }
61+ logging.FromContext(ctx).Debug().
62+ Str("window_id", bw.id).
63+ Str("reason", reason).
64+ Msg("vim page interaction released keys")
65+ return wv
66+}
67+
68+// vimPageInteractionOwner returns the window whose key capture belongs to the
69+// interaction running in paneID's WebView, or nil when that pane owns none.
70+func (a *App) vimPageInteractionOwner(paneID entity.PaneID) *browserWindow {
71+ bw := a.browserWindowForAnyPane(paneID)
72+ if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {
73+ return nil
74+ }
75+ if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {
76+ return nil
77+ }
78+ return bw
79+}
80+
81+// handleVimPageInteractionEnded releases key capture when the page reports
82+// that its hint or visual interaction finished.
83+func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) {
84+ bw := a.vimPageInteractionOwner(paneID)
85+ if bw == nil {
86+ return
87+ }
88+ a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw))
89+}
90+
91+// handleVimPageModeChanged repaints the indicator when the page moves its
92+// interaction to another sub-mode (hints, caret, visual). Key capture and the
93+// legend suspension stay as they are.
94+func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) {
95+ if bw := a.vimPageInteractionOwner(paneID); bw != nil {
96+ a.showVimPageMode(ctx, bw, mode)
97+ }
98+}
99+
100+// showVimPageMode paints the sub-mode label for the interaction owned by bw.
101+func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) {
102+ if bw.vimPageInteractionWebView == nil {
103+ return
104+ }
105+ if label, ok := vimPageLabel(bw.vimPageInteractionKind, mode); ok {
106+ a.showVimModeToast(ctx, bw, label)
107+ }
108+}
109+
110+// vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator.
111+func vimPageModeLabel(mode dto.VimPageMode) (string, bool) {
112+ switch mode {
113+ case dto.VimPageModeHints:
114+ return "HINTS", true
115+ case dto.VimPageModeCaret:
116+ return "CARET", true
117+ case dto.VimPageModeVisual:
118+ return "VISUAL", true
119+ case dto.VimPageModeVisualLine:
120+ return "VISUAL LINE", true
121+ default:
122+ return "", false
123+ }
124+}
125+
126+// vimPageLabel is the single source of the indicator label: the sub-mode name,
127+// plus the target of link hints that do more than follow (new pane, yank URL).
128+func vimPageLabel(kind dto.VimPageInteractionKind, mode dto.VimPageMode) (string, bool) {
129+ label, ok := vimPageModeLabel(mode)
130+ if !ok || mode != dto.VimPageModeHints {
131+ return label, ok
132+ }
133+ switch kind {
134+ case dto.VimPageHintFollowNew:
135+ return label + " · NEW PANE", true
136+ case dto.VimPageHintYankURL:
137+ return label + " · YANK URL", true
138+ default:
139+ return label, true
140+ }
141+}
142+
143+// endVimPageInteraction cancels any in-page interaction owned by this window.
144+// stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys).
145+func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) {
146+ if bw == nil {
147+ return
148+ }
149+ wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim)
150+ if wv == nil {
151+ return
152+ }
153+ if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil {
154+ logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction")
155+ }
156+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close