Merge pull request 'feat(vim): add link hints, visual selection, and text-object yanks' (#1) from feat/vim-hints-visual-yank into main
37 files · +4380 -66
M .github/workflows/ci.yml +2 -1
| @@ -106,7 +106,7 @@ jobs: | |||||
| 106 | - name: Install dependencies | 106 | - name: Install dependencies | ||
| 107 | run: | | 107 | run: | | ||
| 108 | for attempt in 1 2 3; do | 108 | for attempt in 1 2 3; do | ||
| 109 | - | if pacman -Syu --noconfirm && pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gtk4-layer-shell libadwaita gobject-introspection base-devel brotli dbus; then | 109 | + | 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 0 | 110 | exit 0 | ||
| 111 | fi | 111 | fi | ||
| 112 | if [ "$attempt" -eq 3 ]; then | 112 | if [ "$attempt" -eq 3 ]; then | ||
| @@ -141,6 +141,7 @@ jobs: | |||||
| 141 | GDK_BACKEND: wayland | 141 | GDK_BACKEND: wayland | ||
| 142 | WAYLAND_DISPLAY: wayland-0 | 142 | WAYLAND_DISPLAY: wayland-0 | ||
| 143 | DUMBER_REQUIRE_SYSTEMVIEWS_WASM: 1 | 143 | 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 constraints | 146 | - name: Verify purego-only constraints | ||
| 146 | run: make verify-purego | 147 | 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 _c | 3725 | 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 for | 160 | // 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-like | 525 | // PageFocusNavigator is an optional WebView capability for keeping native-like | ||
| 506 | // focus traversal inside the active page when the host window would otherwise | 526 | // 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 semantic | 158 | // 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.handlePopupBridgeClose | 323 | schemeHandler.onPopupClose = eng.handlePopupBridgeClose | ||
| 324 | schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate | 324 | schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate | ||
| 325 | schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage | 325 | schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage | ||
| 326 | + | schemeHandler.onVimPage = eng.handleVimPageBridge | |||
| 326 | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | 327 | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | ||
| 327 | return messageRouter, schemeHandler, nil | 328 | 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 active | 103 | // 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), true | 270 | 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), true | 272 | 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, false | 276 | 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 GTK | 203 | // 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() bool | 206 | + | // gtkDispatch replaces the async GTK idle hop in tests. |
| 207 | - | gtkSyncTimeout time.Duration | 207 | + | gtkDispatch func(func()) |
| 208 | + | gtkSyncIsOwner func() bool | |||
| 209 | + | gtkSyncTimeout time.Duration | |||
| 208 | 210 | ||||
| 209 | // scrollCancelSeam overrides the adapter for scroll cancellation in | 211 | // 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 updated | 264 | // (for example https://dumber.invalid/history). The two forms are updated | ||
| 263 | // together through setCommittedURLsLocked; never compare one form against | 265 | // together through setCommittedURLsLocked; never compare one form against | ||
| 264 | // the other. | 266 | // the other. | ||
| 265 | - | uri string | 267 | + | uri string |
| 266 | - | committedURL string | 268 | + | committedURL string |
| 267 | - | title string | 269 | + | title string |
| 268 | - | progress float64 | 270 | + | progress float64 |
| 269 | - | canGoBack bool | 271 | + | canGoBack bool |
| 270 | - | canGoFwd bool | 272 | + | canGoFwd bool |
| 271 | - | isLoading bool | 273 | + | isLoading bool |
| 272 | - | selectedText string | 274 | + | selectedText string |
| 273 | - | focusedEditable bool | 275 | + | focusedEditable bool |
| 274 | - | inputAttached bool | 276 | + | inputAttached bool |
| 275 | - | bridgeNonce string | 277 | + | 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 stoppableTimer | 288 | selectionDebounceTimer stoppableTimer | ||
| 277 | selectionDebounceSeq uint64 | 289 | selectionDebounceSeq uint64 | ||
| 278 | selectionDebounceDelay *time.Duration | 290 | selectionDebounceDelay *time.Duration | ||
| @@ -1353,6 +1365,9 @@ func (wv *WebView) Destroy() { | |||||
| 1353 | wv.nativePopupFallbackStarted = false | 1365 | wv.nativePopupFallbackStarted = false | ||
| 1354 | wv.popupOpenerBridgeParent = nil | 1366 | 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 | return | 2634 | 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 = 0 | 542 | 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 loop | 1314 | 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.VimMode | 74 | 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 | return | 2222 | 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 | return | 2230 | 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.WebView | 61 | 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 = nil | 128 | bw.vimNavigationHighlightedWebViews = nil | ||
| 123 | bw.vimNavigationHighlightedWebViewIDs = nil | 129 | 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 configured | 193 | // 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 = fn | 282 | 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.Time | 121 | vimScrollNow func() time.Time | ||
| 122 | 122 | ||||
| 123 | seq vimModeSequenceState | 123 | 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 true | 421 | 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, false | 35 | 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 are | 51 | // buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are | ||
| 39 | // sorted so the first sorted name wins on Insert conflicts (validation already | 52 | // 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 | continue | 79 | 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.Mode | 29 | + | mode input.Mode |
| 30 | - | tabID entity.TabID | 30 | + | tabID entity.TabID |
| 31 | - | target layout.Widget | 31 | + | target layout.Widget |
| 32 | - | frameClass string | 32 | + | frameClass string |
| 33 | - | root *gtk.Box | 33 | + | root *gtk.Box |
| 34 | - | border *gtk.Box | 34 | + | border *gtk.Box |
| 35 | - | borderStyle cssClassTarget // border's CSS classes; a seam for tests | 35 | + | 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.Box | 37 | + | panel *gtk.Box |
| 38 | - | content *gtk.FlowBox | 38 | + | content *gtk.FlowBox |
| 39 | - | scroller *gtk.ScrolledWindow | 39 | + | scroller *gtk.ScrolledWindow |
| 40 | - | heading *gtk.Label | 40 | + | heading *gtk.Label |
| 41 | - | rows map[string][]*gtk.Widget | 41 | + | rows map[string][]*gtk.Widget |
| 42 | - | keycaps map[string][]*gtk.Widget | 42 | + | keycaps map[string][]*gtk.Widget |
| 43 | - | pending string | 43 | + | pending string |
| 44 | - | visible bool | 44 | + | 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 string | 48 | style string | ||
| 46 | modeClass string | 49 | modeClass string | ||
| 47 | config entity.WorkspaceStylingConfig | 50 | config entity.WorkspaceStylingConfig | ||
| @@ -330,6 +333,7 @@ func (f *modeFrame) setMode( | |||||
| 330 | f.mode, f.config = mode, cfg | 333 | f.mode, f.config = mode, cfg | ||
| 331 | f.pending = "" | 334 | f.pending = "" | ||
| 332 | f.pulseCycle = false | 335 | f.pulseCycle = false | ||
| 336 | + | f.suspended = false | |||
| 333 | if linger { | 337 | if linger { | ||
| 334 | f.lingering = true | 338 | f.lingering = true | ||
| 335 | f.visible = false | 339 | 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 | return | 461 | return | ||
| @@ -554,7 +579,7 @@ func (f *modeFrame) refreshGeometry() { | |||||
| 554 | } | 579 | } | ||
| 555 | x, bottom := int(out.X), int(out.Y)+height | 580 | 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 | return | 584 | 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 := 0 | 53 | 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 | return | 146 | 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 | return | 319 | 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.Workspace | 327 | 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 | return | 331 | 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 | return | 349 | return | ||
| 328 | } | 350 | } | ||
| 329 | - | ||||
| 330 | text := input.ModeVim.DisplayName() | 351 | text := input.ModeVim.DisplayName() | ||
| 331 | - | if pending != "" { | 352 | + | if detail != "" { |
| 332 | - | text = text + " · " + pending | 353 | + | 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 | + | } | |||