Merge pull request #364 from bnema/feat/page-mode
feat(page-mode): add keyboard page navigation
68 files · +6694 -196
M .mockery.yaml +1 -0
| @@ -59,6 +59,7 @@ packages: | |||||
| 59 | SessionSpawner: {} | 59 | SessionSpawner: {} | ||
| 60 | WindowStateProvider: {} | 60 | WindowStateProvider: {} | ||
| 61 | WebView: {} | 61 | WebView: {} | ||
| 62 | + | PageScrollable: {} | |||
| 62 | WebViewFactory: {} | 63 | WebViewFactory: {} | ||
| 63 | WebViewPool: {} | 64 | WebViewPool: {} | ||
| 64 | MemoryPressureApplier: {} | 65 | MemoryPressureApplier: {} | ||
M README.md +13 -2
| @@ -24,7 +24,7 @@ Dumber uses three layout levels: | |||||
| 24 | - **Workspaces** hold a layout of panes. | 24 | - **Workspaces** hold a layout of panes. | ||
| 25 | - **Panes** display web pages and can be split, stacked, moved, resized, or closed. | 25 | - **Panes** display web pages and can be split, stacked, moved, resized, or closed. | ||
| 26 | 26 | ||||
| 27 | - | Most browser-management actions are exposed through modal keybindings. Enter a mode, run one or more commands, then leave the mode. The default modes cover pane management, tabs, resizing, and session commands. | 27 | + | Most browser-management actions are exposed through modal keybindings. Enter a mode, run one or more commands, then leave the mode. The default modes cover pane management, tabs, page scrolling, resizing, and session commands. |
| 28 | 28 | ||||
| 29 | The browser chrome stays out of the way by default. Open the omnibox when you want to navigate; otherwise the pane is just the page or web app you are using. This makes Dumber work well as a side pane next to an editor, terminal, or another desktop application. | 29 | The browser chrome stays out of the way by default. Open the omnibox when you want to navigate; otherwise the pane is just the page or web app you are using. This makes Dumber work well as a side pane next to an editor, terminal, or another desktop application. | ||
| 30 | 30 | ||||
| @@ -46,7 +46,7 @@ The browser chrome stays out of the way by default. Open the omnibox when you wa | |||||
| 46 | 46 | ||||
| 47 | ### Keyboard workflow | 47 | ### Keyboard workflow | ||
| 48 | 48 | ||||
| 49 | - | - Pane, tab, resize, and session modes | 49 | + | - Pane, tab, page, resize, and session modes |
| 50 | - Vim/Zellij-style navigation patterns | 50 | - Vim/Zellij-style navigation patterns | ||
| 51 | - Search bangs such as `!g`, `!gi`, and `!ddg` | 51 | - Search bangs such as `!g`, `!gi`, and `!ddg` | ||
| 52 | - Launcher integration for `rofi`, `fuzzel`, and `dmenu` | 52 | - Launcher integration for `rofi`, `fuzzel`, and `dmenu` | ||
| @@ -99,6 +99,7 @@ Dumber uses modal keybindings for browser management. | |||||
| 99 | |------|-------------|----------| | 99 | |------|-------------|----------| | ||
| 100 | | Pane | `Ctrl+P` | Split, stack, close, and move panes | | 100 | | Pane | `Ctrl+P` | Split, stack, close, and move panes | | ||
| 101 | | Tab | `Ctrl+T` | Create, close, switch, and rename tabs | | 101 | | Tab | `Ctrl+T` | Create, close, switch, and rename tabs | | ||
| 102 | + | | Page | `Ctrl+Y` | Scroll the active webpage with `h/j/k/l` and `Shift+J/K`; arrow keys stay native and other app shortcuts wait until exit | | |||
| 102 | | Resize | `Ctrl+N` | Resize panes with `hjkl` or arrow keys | | 103 | | Resize | `Ctrl+N` | Resize panes with `hjkl` or arrow keys | | ||
| 103 | | Session | `Ctrl+O` | Snapshot, restore, and browse sessions | | 104 | | Session | `Ctrl+O` | Snapshot, restore, and browse sessions | | ||
| 104 | 105 | ||||
| @@ -166,6 +167,16 @@ WebKit can be selected explicitly: | |||||
| 166 | type = "webkit" | 167 | type = "webkit" | ||
| 167 | ``` | 168 | ``` | ||
| 168 | 169 | ||||
| 170 | + | ### Page scroll execution | |||
| 171 | + | ||||
| 172 | + | Page Mode scroll commands are routed through `port.PageScrollable`. CEF and | |||
| 173 | + | WebKit use the shared `BuildScrollByJS` target resolver when the page is ready: | |||
| 174 | + | it starts under the viewport center, walks through ancestors that can move in | |||
| 175 | + | the requested direction, and hands scrolling to the document when a nested | |||
| 176 | + | container reaches its boundary. The application repeater owns held-key cadence; | |||
| 177 | + | each engine executes one immediate scroll step per tick. Cross-origin frame | |||
| 178 | + | contents remain best-effort. | |||
| 179 | + | ||||
| 169 | ### Rendering notes | 180 | ### Rendering notes | ||
| 170 | 181 | ||||
| 171 | CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. | 182 | CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. | ||
M docs/config/index.md +57 -4
| @@ -398,6 +398,58 @@ cancel = ["escape"] | |||||
| 398 | 398 | ||||
| 399 | > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation. | 399 | > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation. | ||
| 400 | 400 | ||||
| 401 | + | ### Page Mode | |||
| 402 | + | ||||
| 403 | + | | Key | Type | Default | Description | | |||
| 404 | + | |-----|------|---------|-------------| | |||
| 405 | + | | `workspace.page_mode.activation_shortcut` | string | `"ctrl+y"` | Page mode activation key | | |||
| 406 | + | | `workspace.page_mode.timeout_ms` | int | `0` | Page mode timeout in milliseconds (`0` disables auto-timeout) | | |||
| 407 | + | | `workspace.page_mode.actions` | map | See below | Action→binding mappings | | |||
| 408 | + | ||||
| 409 | + | **Default page mode actions:** | |||
| 410 | + | ||||
| 411 | + | ```toml | |||
| 412 | + | [workspace.page_mode.actions.page-scroll-left] | |||
| 413 | + | keys = ["h"] | |||
| 414 | + | desc = "Scroll page left" | |||
| 415 | + | ||||
| 416 | + | [workspace.page_mode.actions.page-scroll-down] | |||
| 417 | + | keys = ["j"] | |||
| 418 | + | desc = "Scroll page down" | |||
| 419 | + | ||||
| 420 | + | [workspace.page_mode.actions.page-scroll-up] | |||
| 421 | + | keys = ["k"] | |||
| 422 | + | desc = "Scroll page up" | |||
| 423 | + | ||||
| 424 | + | [workspace.page_mode.actions.page-scroll-right] | |||
| 425 | + | keys = ["l"] | |||
| 426 | + | desc = "Scroll page right" | |||
| 427 | + | ||||
| 428 | + | [workspace.page_mode.actions.page-scroll-down-fast] | |||
| 429 | + | keys = ["shift+j"] | |||
| 430 | + | desc = "Scroll page down fast" | |||
| 431 | + | ||||
| 432 | + | [workspace.page_mode.actions.page-scroll-up-fast] | |||
| 433 | + | keys = ["shift+k"] | |||
| 434 | + | desc = "Scroll page up fast" | |||
| 435 | + | ||||
| 436 | + | [workspace.page_mode.actions.confirm] | |||
| 437 | + | keys = ["enter"] | |||
| 438 | + | desc = "Confirm action" | |||
| 439 | + | ||||
| 440 | + | [workspace.page_mode.actions.cancel] | |||
| 441 | + | keys = ["escape"] | |||
| 442 | + | desc = "Cancel/exit mode" | |||
| 443 | + | ``` | |||
| 444 | + | ||||
| 445 | + | Notes: | |||
| 446 | + | - Page Mode scrolls the active webpage inside the active pane only. | |||
| 447 | + | - The default `timeout_ms = 0` means Page Mode stays active until you exit it or focus moves into an editable/browser UI context. | |||
| 448 | + | - Arrow keys still use the browser engine's native page-navigation path while Page Mode is active. | |||
| 449 | + | - Other app-level shortcuts stay suspended until Page Mode exits, except for the Page Mode toggle itself. | |||
| 450 | + | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Page Mode visuals. | |||
| 451 | + | - Scroll execution: CEF and WebKit both use the shared `BuildScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready. | |||
| 452 | + | ||||
| 401 | ### Resize Mode | 453 | ### Resize Mode | ||
| 402 | 454 | ||||
| 403 | | Key | Type | Default | Description | | 455 | | Key | Type | Default | Description | | ||
| @@ -529,20 +581,21 @@ desc = "Open floating pane on GitHub" | |||||
| 529 | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay | | 581 | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay | | ||
| 530 | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color | | 582 | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color | | ||
| 531 | | `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes | | 583 | | `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes | | ||
| 532 | - | | `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster | | 584 | + | | `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue); also reused for Page mode pane-local accent, indicator, and pulse | |
| 533 | | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster | | 585 | | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster | | ||
| 534 | | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster | | 586 | | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster | | ||
| 535 | | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster | | 587 | | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster | | ||
| 536 | - | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active | | 588 | + | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show bottom-left mode toaster when modal modes are active; Page Mode keeps a persistent `PAGE MODE` indicator until exit or toggle off | |
| 537 | - | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | 589 | + | | `workspace.styling.transition_duration` | int | `120` | Border and pulse transition duration (ms) | |
| 538 | 590 | ||||
| 539 | **Example:** | 591 | **Example:** | ||
| 592 | + | ||||
| 540 | ```toml | 593 | ```toml | ||
| 541 | [workspace.styling] | 594 | [workspace.styling] | ||
| 542 | border_width = 1 | 595 | border_width = 1 | ||
| 543 | border_color = "@theme_selected_bg_color" | 596 | border_color = "@theme_selected_bg_color" | ||
| 544 | mode_border_width = 4 | 597 | mode_border_width = 4 | ||
| 545 | - | pane_mode_color = "#4A90E2" # Blue for pane mode | 598 | + | pane_mode_color = "#4A90E2" # Blue for pane mode and Page mode local accent |
| 546 | tab_mode_color = "#FFA500" # Orange for tab mode | 599 | tab_mode_color = "#FFA500" # Orange for tab mode | ||
| 547 | session_mode_color = "#9B59B6" # Purple for session mode | 600 | session_mode_color = "#9B59B6" # Purple for session mode | ||
| 548 | resize_mode_color = "#00D4AA" # Teal for resize mode | 601 | resize_mode_color = "#00D4AA" # Teal for resize mode | ||
M docs/getting-started.md +19 -0
| @@ -16,10 +16,12 @@ Dumber uses modal keybindings inspired by Zellij: | |||||
| 16 | |------|------------|---------| | 16 | |------|------------|---------| | ||
| 17 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | 17 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | ||
| 18 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | 18 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | ||
| 19 | + | | Page Mode | `Ctrl+Y` | Scroll the active webpage with `h/j/k/l` and `Shift+J/K`; arrow keys stay native and other app shortcuts wait until exit | | |||
| 19 | | Resize Mode | `Ctrl+N` | Resize pane splits | | 20 | | Resize Mode | `Ctrl+N` | Resize pane splits | | ||
| 20 | | Session Mode | `Ctrl+O` | Session management | | 21 | | Session Mode | `Ctrl+O` | Session management | | ||
| 21 | 22 | ||||
| 22 | Press `Escape` or `Enter` to exit any mode. | 23 | Press `Escape` or `Enter` to exit any mode. | ||
| 24 | + | Page Mode stays local to the active pane and automatically leaves the mode when focus moves into the omnibox, find bar, overlays, or a page editable. | |||
| 23 | 25 | ||||
| 24 | ## Pane Mode Quick Reference | 26 | ## Pane Mode Quick Reference | ||
| 25 | 27 | ||||
| @@ -28,6 +30,23 @@ Press `Escape` or `Enter` to exit any mode. | |||||
| 28 | 3. `Shift+arrows` to focus adjacent panes | 30 | 3. `Shift+arrows` to focus adjacent panes | ||
| 29 | 4. `X` to close current pane | 31 | 4. `X` to close current pane | ||
| 30 | 32 | ||||
| 33 | + | ## Page Mode Quick Reference | |||
| 34 | + | ||||
| 35 | + | 1. Press `Ctrl+Y` to enter Page Mode | |||
| 36 | + | 2. Use `h`, `j`, `k`, `l` to scroll left, down, up, or right | |||
| 37 | + | 3. Use `Shift+J` / `Shift+K` for faster vertical jumps | |||
| 38 | + | 4. Arrow keys continue to use the browser engine's native page navigation while Page Mode is active | |||
| 39 | + | 5. Other app-level shortcuts stay suspended until you leave Page Mode | |||
| 40 | + | 6. Press `Escape` or `Enter` to leave the mode | |||
| 41 | + | 7. If a page input or editor is already focused, `Ctrl+Y` is passed through instead of stealing the keypress | |||
| 42 | + | ||||
| 43 | + | > **Engine behavior**: CEF and WebKit execute Page Mode scroll steps with the | |||
| 44 | + | > shared `BuildScrollByJS` resolver (viewport-center start, nested-scroller | |||
| 45 | + | > handoff, document fallback). The application repeater owns held-key cadence; | |||
| 46 | + | > each engine runs one immediate step per tick. Cross-origin frames are | |||
| 47 | + | > best-effort. CEF may use native precision-wheel input only before the browser | |||
| 48 | + | > frame is ready. | |||
| 49 | + | ||||
| 31 | ## Omnibox | 50 | ## Omnibox | ||
| 32 | 51 | ||||
| 33 | Press `Ctrl+L` to open the omnibox for: | 52 | Press `Ctrl+L` to open the omnibox for: | ||
M docs/reference/configuration.md +3 -0
| @@ -98,6 +98,9 @@ | |||||
| 98 | | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | | | 98 | | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | | | ||
| 99 | | `workspace.tab_mode.timeout_ms` | int | `3000` | | | 99 | | `workspace.tab_mode.timeout_ms` | int | `3000` | | | ||
| 100 | | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings | | 100 | | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings | | ||
| 101 | + | | `workspace.page_mode.activation_shortcut` | string | `ctrl+y` | | | |||
| 102 | + | | `workspace.page_mode.timeout_ms` | int | `0` | >= 0 | | |||
| 103 | + | | `workspace.page_mode.actions.<action>` | []string | see defaults | page mode key mappings | | |||
| 101 | | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | | | 104 | | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | | | ||
| 102 | | `workspace.resize_mode.timeout_ms` | int | `3000` | | | 105 | | `workspace.resize_mode.timeout_ms` | int | `3000` | | | ||
| 103 | | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings | | 106 | | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings | | ||
M docs/reference/keybindings.md +41 -0
| @@ -8,10 +8,12 @@ Dumber uses modal keybindings inspired by Zellij. Press a mode activation key, t | |||||
| 8 | |------|-----|---------| | 8 | |------|-----|---------| | ||
| 9 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | 9 | | Pane Mode | `Ctrl+P` | Split, close, focus panes | | ||
| 10 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | 10 | | Tab Mode | `Ctrl+T` | Create, close, switch tabs | | ||
| 11 | + | | Page Mode | `Ctrl+Y` | Scroll the active webpage with `h/j/k/l` and `Shift+J/K`; arrow keys stay native and other app shortcuts wait until exit | | |||
| 11 | | Resize Mode | `Ctrl+N` | Resize pane splits | | 12 | | Resize Mode | `Ctrl+N` | Resize pane splits | | ||
| 12 | | Session Mode | `Ctrl+O` | Session management | | 13 | | Session Mode | `Ctrl+O` | Session management | | ||
| 13 | 14 | ||||
| 14 | Press `Escape` or `Enter` to exit any mode. | 15 | Press `Escape` or `Enter` to exit any mode. | ||
| 16 | + | Page Mode activation is passed through when the focused webpage element is already editable, so browser-page editors keep their own `Ctrl+Y` behavior. | |||
| 15 | 17 | ||||
| 16 | Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix. | 18 | Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix. | ||
| 17 | 19 | ||||
| @@ -51,6 +53,23 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke | |||||
| 51 | | Confirm | `Enter` | | 53 | | Confirm | `Enter` | | ||
| 52 | | Cancel | `Escape` | | 54 | | Cancel | `Escape` | | ||
| 53 | 55 | ||||
| 56 | + | ## Page Mode (`Ctrl+Y`) | |||
| 57 | + | ||||
| 58 | + | Page Mode is an explicit page-scrolling mode for the active pane only. It applies a pane-local accent from `workspace.styling.pane_mode_color`, and when `workspace.styling.mode_indicator_toaster_enabled` is true, shows a persistent bottom-left `PAGE MODE` toaster while the mode is active; the toaster hides on exit, when the mode is toggled off, or when the toaster is disabled in config. The mode exits automatically when focus moves into the omnibox, find bar, overlays, or an editable element inside the page. The default `timeout_ms` is `0`, so Page Mode does not auto-time out unless you configure one. Arrow keys continue to flow through the browser engine's native page-navigation path while Page Mode is active, while other app-level shortcuts stay suspended until you leave the mode. | |||
| 59 | + | ||||
| 60 | + | CEF and WebKit execute Page Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort. | |||
| 61 | + | ||||
| 62 | + | | Action | Keys | | |||
| 63 | + | |--------|------| | |||
| 64 | + | | Scroll left | `H` | | |||
| 65 | + | | Scroll down | `J` | | |||
| 66 | + | | Scroll up | `K` | | |||
| 67 | + | | Scroll right | `L` | | |||
| 68 | + | | Scroll down fast | `Shift+J` | | |||
| 69 | + | | Scroll up fast | `Shift+K` | | |||
| 70 | + | | Confirm | `Enter` | | |||
| 71 | + | | Cancel | `Escape` | | |||
| 72 | + | ||||
| 54 | ## Resize Mode (`Ctrl+N`) | 73 | ## Resize Mode (`Ctrl+N`) | ||
| 55 | 74 | ||||
| 56 | | Action | Keys | | 75 | | Action | Keys | | ||
| @@ -118,6 +137,28 @@ All keybindings can be customized in `~/.config/dumber/config.toml`: | |||||
| 118 | split-right = ["arrowright", "r"] | 137 | split-right = ["arrowright", "r"] | ||
| 119 | close-pane = ["x", "q"] | 138 | close-pane = ["x", "q"] | ||
| 120 | 139 | ||||
| 140 | + | [workspace.page_mode] | |||
| 141 | + | activation_shortcut = "ctrl+y" | |||
| 142 | + | timeout_ms = 0 | |||
| 143 | + | ||||
| 144 | + | [workspace.page_mode.actions.page-scroll-left] | |||
| 145 | + | keys = ["h"] | |||
| 146 | + | ||||
| 147 | + | [workspace.page_mode.actions.page-scroll-down] | |||
| 148 | + | keys = ["j"] | |||
| 149 | + | ||||
| 150 | + | [workspace.page_mode.actions.page-scroll-up] | |||
| 151 | + | keys = ["k"] | |||
| 152 | + | ||||
| 153 | + | [workspace.page_mode.actions.page-scroll-right] | |||
| 154 | + | keys = ["l"] | |||
| 155 | + | ||||
| 156 | + | [workspace.page_mode.actions.page-scroll-down-fast] | |||
| 157 | + | keys = ["shift+j"] | |||
| 158 | + | ||||
| 159 | + | [workspace.page_mode.actions.page-scroll-up-fast] | |||
| 160 | + | keys = ["shift+k"] | |||
| 161 | + | ||||
| 121 | [workspace.shortcuts.actions.close-pane] | 162 | [workspace.shortcuts.actions.close-pane] | ||
| 122 | keys = ["ctrl+w"] | 163 | keys = ["ctrl+w"] | ||
| 123 | 164 | ||||
M internal/application/port/mocks/webview.go +84 -0
| @@ -3112,3 +3112,87 @@ func (_c *MockTextInputTargetProvider_TextInputTarget_Call) RunAndReturn(run fun | |||||
| 3112 | _c.Call.Return(run) | 3112 | _c.Call.Return(run) | ||
| 3113 | return _c | 3113 | return _c | ||
| 3114 | } | 3114 | } | ||
| 3115 | + | ||||
| 3116 | + | // NewMockPageScrollable creates a new instance of MockPageScrollable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 3117 | + | // The first argument is typically a *testing.T value. | |||
| 3118 | + | func NewMockPageScrollable(t interface { | |||
| 3119 | + | mock.TestingT | |||
| 3120 | + | Cleanup(func()) | |||
| 3121 | + | }) *MockPageScrollable { | |||
| 3122 | + | mock := &MockPageScrollable{} | |||
| 3123 | + | mock.Mock.Test(t) | |||
| 3124 | + | ||||
| 3125 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 3126 | + | ||||
| 3127 | + | return mock | |||
| 3128 | + | } | |||
| 3129 | + | ||||
| 3130 | + | // MockPageScrollable is an autogenerated mock type for the PageScrollable type | |||
| 3131 | + | type MockPageScrollable struct { | |||
| 3132 | + | mock.Mock | |||
| 3133 | + | } | |||
| 3134 | + | ||||
| 3135 | + | type MockPageScrollable_Expecter struct { | |||
| 3136 | + | mock *mock.Mock | |||
| 3137 | + | } | |||
| 3138 | + | ||||
| 3139 | + | func (_m *MockPageScrollable) EXPECT() *MockPageScrollable_Expecter { | |||
| 3140 | + | return &MockPageScrollable_Expecter{mock: &_m.Mock} | |||
| 3141 | + | } | |||
| 3142 | + | ||||
| 3143 | + | // ScrollPage provides a mock function for the type MockPageScrollable | |||
| 3144 | + | func (_mock *MockPageScrollable) ScrollPage(ctx context.Context, request port.PageScrollRequest) error { | |||
| 3145 | + | ret := _mock.Called(ctx, request) | |||
| 3146 | + | ||||
| 3147 | + | if len(ret) == 0 { | |||
| 3148 | + | panic("no return value specified for ScrollPage") | |||
| 3149 | + | } | |||
| 3150 | + | ||||
| 3151 | + | var r0 error | |||
| 3152 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.PageScrollRequest) error); ok { | |||
| 3153 | + | r0 = returnFunc(ctx, request) | |||
| 3154 | + | } else { | |||
| 3155 | + | r0 = ret.Error(0) | |||
| 3156 | + | } | |||
| 3157 | + | return r0 | |||
| 3158 | + | } | |||
| 3159 | + | ||||
| 3160 | + | // MockPageScrollable_ScrollPage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ScrollPage' | |||
| 3161 | + | type MockPageScrollable_ScrollPage_Call struct { | |||
| 3162 | + | *mock.Call | |||
| 3163 | + | } | |||
| 3164 | + | ||||
| 3165 | + | // ScrollPage is a helper method to define mock.On call | |||
| 3166 | + | // - ctx context.Context | |||
| 3167 | + | // - request port.PageScrollRequest | |||
| 3168 | + | func (_e *MockPageScrollable_Expecter) ScrollPage(ctx any, request any) *MockPageScrollable_ScrollPage_Call { | |||
| 3169 | + | return &MockPageScrollable_ScrollPage_Call{Call: _e.mock.On("ScrollPage", ctx, request)} | |||
| 3170 | + | } | |||
| 3171 | + | ||||
| 3172 | + | func (_c *MockPageScrollable_ScrollPage_Call) Run(run func(ctx context.Context, request port.PageScrollRequest)) *MockPageScrollable_ScrollPage_Call { | |||
| 3173 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 3174 | + | var arg0 context.Context | |||
| 3175 | + | if args[0] != nil { | |||
| 3176 | + | arg0 = args[0].(context.Context) | |||
| 3177 | + | } | |||
| 3178 | + | var arg1 port.PageScrollRequest | |||
| 3179 | + | if args[1] != nil { | |||
| 3180 | + | arg1 = args[1].(port.PageScrollRequest) | |||
| 3181 | + | } | |||
| 3182 | + | run( | |||
| 3183 | + | arg0, | |||
| 3184 | + | arg1, | |||
| 3185 | + | ) | |||
| 3186 | + | }) | |||
| 3187 | + | return _c | |||
| 3188 | + | } | |||
| 3189 | + | ||||
| 3190 | + | func (_c *MockPageScrollable_ScrollPage_Call) Return(err error) *MockPageScrollable_ScrollPage_Call { | |||
| 3191 | + | _c.Call.Return(err) | |||
| 3192 | + | return _c | |||
| 3193 | + | } | |||
| 3194 | + | ||||
| 3195 | + | func (_c *MockPageScrollable_ScrollPage_Call) RunAndReturn(run func(ctx context.Context, request port.PageScrollRequest) error) *MockPageScrollable_ScrollPage_Call { | |||
| 3196 | + | _c.Call.Return(run) | |||
| 3197 | + | return _c | |||
| 3198 | + | } | |||
M internal/application/port/webview.go +47 -0
| @@ -142,6 +142,10 @@ type WebViewCallbacks struct { | |||||
| 142 | // OnTouchpadNavigationGesture is called while a two-finger touchpad history | 142 | // OnTouchpadNavigationGesture is called while a two-finger touchpad history | ||
| 143 | // navigation gesture is progressing or finishing. | 143 | // navigation gesture is progressing or finishing. | ||
| 144 | OnTouchpadNavigationGesture func(gesture entity.TouchpadNavigationGesture) | 144 | OnTouchpadNavigationGesture func(gesture entity.TouchpadNavigationGesture) | ||
| 145 | + | ||||
| 146 | + | // OnEditableFocusChanged is called when the page focus enters or leaves an | |||
| 147 | + | // editable target such as input, textarea, or contenteditable content. | |||
| 148 | + | OnEditableFocusChanged func(editable bool) | |||
| 145 | } | 149 | } | ||
| 146 | 150 | ||||
| 147 | // FindOptions configures search behavior. | 151 | // FindOptions configures search behavior. | ||
| @@ -390,3 +394,46 @@ type OAuthCallbackCapable interface { | |||||
| 390 | type TextInputTargetProvider interface { | 394 | type TextInputTargetProvider interface { | ||
| 391 | TextInputTarget() TextInputTarget | 395 | TextInputTarget() TextInputTarget | ||
| 392 | } | 396 | } | ||
| 397 | + | ||||
| 398 | + | // PageScrollCommand identifies a semantic page-scroll request at the | |||
| 399 | + | // application boundary without depending on usecase package enums. | |||
| 400 | + | type PageScrollCommand int | |||
| 401 | + | ||||
| 402 | + | const ( | |||
| 403 | + | PageScrollCommandLeft PageScrollCommand = iota | |||
| 404 | + | PageScrollCommandRight | |||
| 405 | + | PageScrollCommandUp | |||
| 406 | + | PageScrollCommandDown | |||
| 407 | + | PageScrollCommandUpFast | |||
| 408 | + | PageScrollCommandDownFast | |||
| 409 | + | ) | |||
| 410 | + | ||||
| 411 | + | // PageScrollRequest carries a semantic page-scroll command identity and its | |||
| 412 | + | // fallback pixel deltas. Engines own the execution strategy and may use the | |||
| 413 | + | // fallback delta when no native mechanism exists for the command. | |||
| 414 | + | type PageScrollRequest struct { | |||
| 415 | + | Command PageScrollCommand | |||
| 416 | + | FallbackDX int | |||
| 417 | + | FallbackDY int | |||
| 418 | + | // Continuous distinguishes autonomous held-key steps from the guaranteed | |||
| 419 | + | // tap step so adapters can cancel queued held work without dropping taps. | |||
| 420 | + | Continuous bool | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | // PageScrollable is an optional capability for WebViews that support semantic | |||
| 424 | + | // page scrolling. Page scrolling is used by keyboard-driven navigation modes | |||
| 425 | + | // such as Page mode. | |||
| 426 | + | // | |||
| 427 | + | // Engines own the execution strategy. When a native mechanism exists for the | |||
| 428 | + | // requested command, the engine may ignore the fallback delta. Otherwise it | |||
| 429 | + | // should use the fallback delta as a JavaScript-driven scroll amount. | |||
| 430 | + | type PageScrollable interface { | |||
| 431 | + | ScrollPage(ctx context.Context, request PageScrollRequest) error | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | // PageScrollCanceler is an optional capability for adapters that queue held | |||
| 435 | + | // page-scroll work. Implementations discard unconsumed continuous deltas while | |||
| 436 | + | // preserving any guaranteed tap already accepted. | |||
| 437 | + | type PageScrollCanceler interface { | |||
| 438 | + | CancelPageScroll(ctx context.Context) | |||
| 439 | + | } | |||
A internal/application/usecase/page_mode_policy.go +73 -0
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | // PageModePolicyTransition describes what the UI should do after a Page mode | |||
| 4 | + | // policy evaluation. | |||
| 5 | + | type PageModePolicyTransition string | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | PageModePolicyTransitionStay PageModePolicyTransition = "stay" | |||
| 9 | + | PageModePolicyTransitionExit PageModePolicyTransition = "exit" | |||
| 10 | + | PageModePolicyTransitionBlockActivation PageModePolicyTransition = "block_activation" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // PageModePolicyTrigger identifies the focus or activation event being | |||
| 14 | + | // evaluated. | |||
| 15 | + | type PageModePolicyTrigger string | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | PageModePolicyTriggerActivationAttempt PageModePolicyTrigger = "activation_attempt" | |||
| 19 | + | PageModePolicyTriggerOmniboxFocus PageModePolicyTrigger = "omnibox_focus" | |||
| 20 | + | PageModePolicyTriggerFindBarFocus PageModePolicyTrigger = "find_bar_focus" | |||
| 21 | + | PageModePolicyTriggerOverlayFocus PageModePolicyTrigger = "overlay_focus" | |||
| 22 | + | PageModePolicyTriggerPageEditableFocusChanged PageModePolicyTrigger = "page_editable_focus_changed" | |||
| 23 | + | PageModePolicyTriggerContextChanged PageModePolicyTrigger = "context_changed" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // PageModePolicyInput captures the current Page mode state and the event being | |||
| 27 | + | // evaluated. | |||
| 28 | + | type PageModePolicyInput struct { | |||
| 29 | + | Trigger PageModePolicyTrigger | |||
| 30 | + | PageModeActive bool | |||
| 31 | + | PageEditableFocused bool | |||
| 32 | + | EventInActiveContext bool | |||
| 33 | + | PreserveOnContextChange bool | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // PageModePolicyUseCase evaluates whether Page mode should stay active, exit, | |||
| 37 | + | // or be blocked from activating. | |||
| 38 | + | type PageModePolicyUseCase struct{} | |||
| 39 | + | ||||
| 40 | + | // NewPageModePolicyUseCase creates a PageModePolicyUseCase. | |||
| 41 | + | func NewPageModePolicyUseCase() *PageModePolicyUseCase { | |||
| 42 | + | return &PageModePolicyUseCase{} | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Evaluate decides the Page mode transition for the provided event. | |||
| 46 | + | func (*PageModePolicyUseCase) Evaluate(input PageModePolicyInput) PageModePolicyTransition { | |||
| 47 | + | switch input.Trigger { | |||
| 48 | + | case PageModePolicyTriggerActivationAttempt: | |||
| 49 | + | if input.PageEditableFocused { | |||
| 50 | + | return PageModePolicyTransitionBlockActivation | |||
| 51 | + | } | |||
| 52 | + | return PageModePolicyTransitionStay | |||
| 53 | + | case PageModePolicyTriggerOmniboxFocus, | |||
| 54 | + | PageModePolicyTriggerFindBarFocus, | |||
| 55 | + | PageModePolicyTriggerOverlayFocus: | |||
| 56 | + | if input.PageModeActive { | |||
| 57 | + | return PageModePolicyTransitionExit | |||
| 58 | + | } | |||
| 59 | + | return PageModePolicyTransitionStay | |||
| 60 | + | case PageModePolicyTriggerPageEditableFocusChanged: | |||
| 61 | + | if input.PageModeActive && input.PageEditableFocused && input.EventInActiveContext { | |||
| 62 | + | return PageModePolicyTransitionExit | |||
| 63 | + | } | |||
| 64 | + | return PageModePolicyTransitionStay | |||
| 65 | + | case PageModePolicyTriggerContextChanged: | |||
| 66 | + | if input.PageModeActive && !input.PreserveOnContextChange { | |||
| 67 | + | return PageModePolicyTransitionExit | |||
| 68 | + | } | |||
| 69 | + | return PageModePolicyTransitionStay | |||
| 70 | + | default: | |||
| 71 | + | return PageModePolicyTransitionStay | |||
| 72 | + | } | |||
| 73 | + | } | |||
A internal/application/usecase/page_mode_policy_test.go +109 -0
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestPageModePolicyEvaluate(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | input PageModePolicyInput | |||
| 13 | + | want PageModePolicyTransition | |||
| 14 | + | }{ | |||
| 15 | + | { | |||
| 16 | + | name: "activation blocked while page editable focused", | |||
| 17 | + | input: PageModePolicyInput{ | |||
| 18 | + | Trigger: PageModePolicyTriggerActivationAttempt, | |||
| 19 | + | PageEditableFocused: true, | |||
| 20 | + | }, | |||
| 21 | + | want: PageModePolicyTransitionBlockActivation, | |||
| 22 | + | }, | |||
| 23 | + | { | |||
| 24 | + | name: "activation stays allowed when page is not editable", | |||
| 25 | + | input: PageModePolicyInput{ | |||
| 26 | + | Trigger: PageModePolicyTriggerActivationAttempt, | |||
| 27 | + | PageEditableFocused: false, | |||
| 28 | + | }, | |||
| 29 | + | want: PageModePolicyTransitionStay, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "active page editable focus exits page mode", | |||
| 33 | + | input: PageModePolicyInput{ | |||
| 34 | + | Trigger: PageModePolicyTriggerPageEditableFocusChanged, | |||
| 35 | + | PageModeActive: true, | |||
| 36 | + | PageEditableFocused: true, | |||
| 37 | + | EventInActiveContext: true, | |||
| 38 | + | }, | |||
| 39 | + | want: PageModePolicyTransitionExit, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "background page editable focus does not exit page mode", | |||
| 43 | + | input: PageModePolicyInput{ | |||
| 44 | + | Trigger: PageModePolicyTriggerPageEditableFocusChanged, | |||
| 45 | + | PageModeActive: true, | |||
| 46 | + | PageEditableFocused: true, | |||
| 47 | + | EventInActiveContext: false, | |||
| 48 | + | }, | |||
| 49 | + | want: PageModePolicyTransitionStay, | |||
| 50 | + | }, | |||
| 51 | + | { | |||
| 52 | + | name: "overlay focus exits page mode", | |||
| 53 | + | input: PageModePolicyInput{ | |||
| 54 | + | Trigger: PageModePolicyTriggerOverlayFocus, | |||
| 55 | + | PageModeActive: true, | |||
| 56 | + | }, | |||
| 57 | + | want: PageModePolicyTransitionExit, | |||
| 58 | + | }, | |||
| 59 | + | { | |||
| 60 | + | name: "omnibox focus exits page mode", | |||
| 61 | + | input: PageModePolicyInput{ | |||
| 62 | + | Trigger: PageModePolicyTriggerOmniboxFocus, | |||
| 63 | + | PageModeActive: true, | |||
| 64 | + | }, | |||
| 65 | + | want: PageModePolicyTransitionExit, | |||
| 66 | + | }, | |||
| 67 | + | { | |||
| 68 | + | name: "find bar focus exits page mode", | |||
| 69 | + | input: PageModePolicyInput{ | |||
| 70 | + | Trigger: PageModePolicyTriggerFindBarFocus, | |||
| 71 | + | PageModeActive: true, | |||
| 72 | + | }, | |||
| 73 | + | want: PageModePolicyTransitionExit, | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "context change preserves page mode when scoped context stays valid", | |||
| 77 | + | input: PageModePolicyInput{ | |||
| 78 | + | Trigger: PageModePolicyTriggerContextChanged, | |||
| 79 | + | PageModeActive: true, | |||
| 80 | + | PreserveOnContextChange: true, | |||
| 81 | + | }, | |||
| 82 | + | want: PageModePolicyTransitionStay, | |||
| 83 | + | }, | |||
| 84 | + | { | |||
| 85 | + | name: "context change exits page mode when scoped context is lost", | |||
| 86 | + | input: PageModePolicyInput{ | |||
| 87 | + | Trigger: PageModePolicyTriggerContextChanged, | |||
| 88 | + | PageModeActive: true, | |||
| 89 | + | PreserveOnContextChange: false, | |||
| 90 | + | }, | |||
| 91 | + | want: PageModePolicyTransitionExit, | |||
| 92 | + | }, | |||
| 93 | + | { | |||
| 94 | + | name: "non page mode focus change stays normal", | |||
| 95 | + | input: PageModePolicyInput{ | |||
| 96 | + | Trigger: PageModePolicyTriggerOverlayFocus, | |||
| 97 | + | PageModeActive: false, | |||
| 98 | + | }, | |||
| 99 | + | want: PageModePolicyTransitionStay, | |||
| 100 | + | }, | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | uc := NewPageModePolicyUseCase() | |||
| 104 | + | for _, tt := range tests { | |||
| 105 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 106 | + | assert.Equal(t, tt.want, uc.Evaluate(tt.input)) | |||
| 107 | + | }) | |||
| 108 | + | } | |||
| 109 | + | } | |||
A internal/application/usecase/page_scroll.go +150 -0
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | // Package usecase contains application use cases that orchestrate domain logic. | |||
| 2 | + | package usecase | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // PageScrollCommand represents a semantic page scroll action. | |||
| 13 | + | // Each command maps to a fixed pixel delta defined in this package. | |||
| 14 | + | type PageScrollCommand int | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | PageScrollLeft PageScrollCommand = iota // scroll left by small horizontal amount | |||
| 18 | + | PageScrollRight // scroll right by small horizontal amount | |||
| 19 | + | PageScrollUp // scroll up by small vertical amount | |||
| 20 | + | PageScrollDown // scroll down by small vertical amount | |||
| 21 | + | PageScrollUpFast // scroll up by fast vertical amount | |||
| 22 | + | PageScrollDownFast // scroll down by fast vertical amount | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // Internal fixed pixel defaults for page scroll commands. | |||
| 26 | + | // These are application-layer constants, not user-configurable. | |||
| 27 | + | const ( | |||
| 28 | + | scrollSmallHorizontal = 80 | |||
| 29 | + | scrollSmallVertical = 80 | |||
| 30 | + | scrollFastVertical = 320 | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // PageScrollUseCase handles semantic page scrolling commands. | |||
| 34 | + | // It maps semantic scroll actions (left, down, up-fast, etc.) to pixel deltas | |||
| 35 | + | // and delegates to the WebView's scroll capability. No UI logic or | |||
| 36 | + | // engine-specific JavaScript exists in this layer. | |||
| 37 | + | type PageScrollUseCase struct{} | |||
| 38 | + | ||||
| 39 | + | // NewPageScrollUseCase creates a new PageScrollUseCase. | |||
| 40 | + | func NewPageScrollUseCase() *PageScrollUseCase { | |||
| 41 | + | return &PageScrollUseCase{} | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Scroll applies a semantic scroll command to a WebView. | |||
| 45 | + | // It returns an error if the WebView does not support semantic page scrolling | |||
| 46 | + | // (i.e., does not implement port.PageScrollable). | |||
| 47 | + | func (uc *PageScrollUseCase) Scroll(ctx context.Context, wv port.WebView, cmd PageScrollCommand) error { | |||
| 48 | + | return uc.scroll(ctx, wv, cmd, false) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // ScrollContinuous applies one autonomous held-key step. The pixel policy is | |||
| 52 | + | // identical to Scroll; only lifecycle metadata differs for adapter coalescing. | |||
| 53 | + | func (uc *PageScrollUseCase) ScrollContinuous(ctx context.Context, wv port.WebView, cmd PageScrollCommand) error { | |||
| 54 | + | return uc.scroll(ctx, wv, cmd, true) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Stop cancels queued continuous work when the adapter supports cancellation. | |||
| 58 | + | // Adapters such as WebKit that execute each step immediately need no special | |||
| 59 | + | // capability and therefore treat this as a no-op. | |||
| 60 | + | func (*PageScrollUseCase) Stop(ctx context.Context, wv port.WebView) error { | |||
| 61 | + | if wv == nil { | |||
| 62 | + | return errors.New("page scroll: nil webview") | |||
| 63 | + | } | |||
| 64 | + | if canceler, ok := wv.(port.PageScrollCanceler); ok { | |||
| 65 | + | canceler.CancelPageScroll(ctx) | |||
| 66 | + | } | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (*PageScrollUseCase) scroll(ctx context.Context, wv port.WebView, cmd PageScrollCommand, continuous bool) error { | |||
| 71 | + | if wv == nil { | |||
| 72 | + | return errors.New("page scroll: nil webview") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | scroller, ok := wv.(port.PageScrollable) | |||
| 76 | + | if !ok { | |||
| 77 | + | return errors.New("page scroll: webview does not support page scrolling") | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | dx, dy := scrollDelta(cmd) | |||
| 81 | + | req := port.PageScrollRequest{ | |||
| 82 | + | Command: toPortPageScrollCommand(cmd), | |||
| 83 | + | FallbackDX: dx, | |||
| 84 | + | FallbackDY: dy, | |||
| 85 | + | Continuous: continuous, | |||
| 86 | + | } | |||
| 87 | + | if err := scroller.ScrollPage(ctx, req); err != nil { | |||
| 88 | + | return fmt.Errorf("page scroll: %w", err) | |||
| 89 | + | } | |||
| 90 | + | return nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func toPortPageScrollCommand(cmd PageScrollCommand) port.PageScrollCommand { | |||
| 94 | + | switch cmd { | |||
| 95 | + | case PageScrollLeft: | |||
| 96 | + | return port.PageScrollCommandLeft | |||
| 97 | + | case PageScrollRight: | |||
| 98 | + | return port.PageScrollCommandRight | |||
| 99 | + | case PageScrollUp: | |||
| 100 | + | return port.PageScrollCommandUp | |||
| 101 | + | case PageScrollDown: | |||
| 102 | + | return port.PageScrollCommandDown | |||
| 103 | + | case PageScrollUpFast: | |||
| 104 | + | return port.PageScrollCommandUpFast | |||
| 105 | + | case PageScrollDownFast: | |||
| 106 | + | return port.PageScrollCommandDownFast | |||
| 107 | + | default: | |||
| 108 | + | return port.PageScrollCommand(-1) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // scrollDelta returns the pixel delta for a PageScrollCommand. | |||
| 113 | + | func scrollDelta(cmd PageScrollCommand) (dx, dy int) { | |||
| 114 | + | switch cmd { | |||
| 115 | + | case PageScrollLeft: | |||
| 116 | + | return -scrollSmallHorizontal, 0 | |||
| 117 | + | case PageScrollRight: | |||
| 118 | + | return scrollSmallHorizontal, 0 | |||
| 119 | + | case PageScrollUp: | |||
| 120 | + | return 0, -scrollSmallVertical | |||
| 121 | + | case PageScrollDown: | |||
| 122 | + | return 0, scrollSmallVertical | |||
| 123 | + | case PageScrollUpFast: | |||
| 124 | + | return 0, -scrollFastVertical | |||
| 125 | + | case PageScrollDownFast: | |||
| 126 | + | return 0, scrollFastVertical | |||
| 127 | + | default: | |||
| 128 | + | return 0, 0 | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // String returns a human-readable name for the scroll command. | |||
| 133 | + | func (cmd PageScrollCommand) String() string { | |||
| 134 | + | switch cmd { | |||
| 135 | + | case PageScrollLeft: | |||
| 136 | + | return "left" | |||
| 137 | + | case PageScrollRight: | |||
| 138 | + | return "right" | |||
| 139 | + | case PageScrollUp: | |||
| 140 | + | return "up" | |||
| 141 | + | case PageScrollDown: | |||
| 142 | + | return "down" | |||
| 143 | + | case PageScrollUpFast: | |||
| 144 | + | return "up-fast" | |||
| 145 | + | case PageScrollDownFast: | |||
| 146 | + | return "down-fast" | |||
| 147 | + | default: | |||
| 148 | + | return "unknown" | |||
| 149 | + | } | |||
| 150 | + | } | |||
A internal/application/usecase/page_scroll_test.go +320 -0
| @@ -0,0 +1,320 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestPageScroll_SmallVerticalDown(t *testing.T) { | |||
| 12 | + | wv := newScrollableStub(nil) | |||
| 13 | + | uc := NewPageScrollUseCase() | |||
| 14 | + | ||||
| 15 | + | err := uc.Scroll(context.Background(), wv, PageScrollDown) | |||
| 16 | + | if err != nil { | |||
| 17 | + | t.Fatalf("Scroll() unexpected error: %v", err) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | if wv.lastRequest.Command != port.PageScrollCommandDown { | |||
| 21 | + | t.Fatalf("ScrollPage request.Command = %d, want %d", wv.lastRequest.Command, port.PageScrollCommandDown) | |||
| 22 | + | } | |||
| 23 | + | if wv.lastRequest.FallbackDX != 0 || wv.lastRequest.FallbackDY != 80 { | |||
| 24 | + | t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (0, 80)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestPageScroll_FastVerticalUp(t *testing.T) { | |||
| 29 | + | wv := newScrollableStub(nil) | |||
| 30 | + | uc := NewPageScrollUseCase() | |||
| 31 | + | ||||
| 32 | + | err := uc.Scroll(context.Background(), wv, PageScrollUpFast) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatalf("Scroll() unexpected error: %v", err) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | if wv.lastRequest.Command != port.PageScrollCommandUpFast { | |||
| 38 | + | t.Fatalf("ScrollPage request.Command = %d, want %d", wv.lastRequest.Command, port.PageScrollCommandUpFast) | |||
| 39 | + | } | |||
| 40 | + | if wv.lastRequest.FallbackDX != 0 || wv.lastRequest.FallbackDY != -320 { | |||
| 41 | + | t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (0, -320)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestPageScroll_HorizontalDirectionMapping(t *testing.T) { | |||
| 46 | + | t.Run("left", func(t *testing.T) { | |||
| 47 | + | wv := newScrollableStub(nil) | |||
| 48 | + | uc := NewPageScrollUseCase() | |||
| 49 | + | ||||
| 50 | + | if err := uc.Scroll(context.Background(), wv, PageScrollLeft); err != nil { | |||
| 51 | + | t.Fatalf("Scroll() unexpected error: %v", err) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | if wv.lastRequest.Command != port.PageScrollCommandLeft { | |||
| 55 | + | t.Fatalf("ScrollPage request.Command = %d, want %d", wv.lastRequest.Command, port.PageScrollCommandLeft) | |||
| 56 | + | } | |||
| 57 | + | if wv.lastRequest.FallbackDX != -80 || wv.lastRequest.FallbackDY != 0 { | |||
| 58 | + | t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (-80, 0)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY) | |||
| 59 | + | } | |||
| 60 | + | }) | |||
| 61 | + | ||||
| 62 | + | t.Run("right", func(t *testing.T) { | |||
| 63 | + | wv := newScrollableStub(nil) | |||
| 64 | + | uc := NewPageScrollUseCase() | |||
| 65 | + | if err := uc.Scroll(context.Background(), wv, PageScrollRight); err != nil { | |||
| 66 | + | t.Fatalf("Scroll() unexpected error: %v", err) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | if wv.lastRequest.Command != port.PageScrollCommandRight { | |||
| 70 | + | t.Fatalf("ScrollPage request.Command = %d, want %d", wv.lastRequest.Command, port.PageScrollCommandRight) | |||
| 71 | + | } | |||
| 72 | + | if wv.lastRequest.FallbackDX != 80 || wv.lastRequest.FallbackDY != 0 { | |||
| 73 | + | t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (80, 0)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY) | |||
| 74 | + | } | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestPageScroll_UnsupportedWebView(t *testing.T) { | |||
| 79 | + | wv := &nonScrollableStub{} | |||
| 80 | + | uc := NewPageScrollUseCase() | |||
| 81 | + | ||||
| 82 | + | err := uc.Scroll(context.Background(), wv, PageScrollDown) | |||
| 83 | + | if err == nil { | |||
| 84 | + | t.Fatal("Scroll() expected error for unsupported webview, got nil") | |||
| 85 | + | } | |||
| 86 | + | if err.Error() != "page scroll: webview does not support page scrolling" { | |||
| 87 | + | t.Fatalf("Scroll() error = %q, want %q", err.Error(), "page scroll: webview does not support page scrolling") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestPageScroll_UnsupportedWebView_AllCommandsStable(t *testing.T) { | |||
| 92 | + | wv := &nonScrollableStub{} | |||
| 93 | + | uc := NewPageScrollUseCase() | |||
| 94 | + | ||||
| 95 | + | cmds := []PageScrollCommand{ | |||
| 96 | + | PageScrollLeft, | |||
| 97 | + | PageScrollRight, | |||
| 98 | + | PageScrollUp, | |||
| 99 | + | PageScrollDown, | |||
| 100 | + | PageScrollUpFast, | |||
| 101 | + | PageScrollDownFast, | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | for _, cmd := range cmds { | |||
| 105 | + | err := uc.Scroll(context.Background(), wv, cmd) | |||
| 106 | + | if err == nil { | |||
| 107 | + | t.Fatalf("Scroll(command=%s) expected error for unsupported webview, got nil", cmd) | |||
| 108 | + | } | |||
| 109 | + | if err.Error() != "page scroll: webview does not support page scrolling" { | |||
| 110 | + | t.Fatalf("Scroll(command=%s) error = %q, want %q", cmd, err.Error(), "page scroll: webview does not support page scrolling") | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestPageScroll_NilWebView_StableFailure(t *testing.T) { | |||
| 116 | + | uc := NewPageScrollUseCase() | |||
| 117 | + | ||||
| 118 | + | err := uc.Scroll(context.Background(), nil, PageScrollDown) | |||
| 119 | + | if err == nil { | |||
| 120 | + | t.Fatal("Scroll() expected error for nil webview, got nil") | |||
| 121 | + | } | |||
| 122 | + | if err.Error() != "page scroll: nil webview" { | |||
| 123 | + | t.Fatalf("Scroll() error = %q, want %q", err.Error(), "page scroll: nil webview") | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestPageScroll_PropagatesScrollError(t *testing.T) { | |||
| 128 | + | wantErr := errors.New("engine scroll failure") | |||
| 129 | + | wv := newScrollableStub(wantErr) | |||
| 130 | + | uc := NewPageScrollUseCase() | |||
| 131 | + | ||||
| 132 | + | err := uc.Scroll(context.Background(), wv, PageScrollDown) | |||
| 133 | + | if err == nil { | |||
| 134 | + | t.Fatal("Scroll() expected error, got nil") | |||
| 135 | + | } | |||
| 136 | + | if !errors.Is(err, wantErr) { | |||
| 137 | + | t.Fatalf("Scroll() error = %v, want %v", err, wantErr) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestPageScroll_TapDistancesRemainFixed(t *testing.T) { | |||
| 142 | + | tests := []struct { | |||
| 143 | + | name string | |||
| 144 | + | cmd PageScrollCommand | |||
| 145 | + | dx int | |||
| 146 | + | dy int | |||
| 147 | + | }{ | |||
| 148 | + | {"left 80 px", PageScrollLeft, -80, 0}, | |||
| 149 | + | {"right 80 px", PageScrollRight, 80, 0}, | |||
| 150 | + | {"up 80 px", PageScrollUp, 0, -80}, | |||
| 151 | + | {"down 80 px", PageScrollDown, 0, 80}, | |||
| 152 | + | {"up fast 320 px", PageScrollUpFast, 0, -320}, | |||
| 153 | + | {"down fast 320 px", PageScrollDownFast, 0, 320}, | |||
| 154 | + | } | |||
| 155 | + | for _, tt := range tests { | |||
| 156 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 157 | + | wv := newScrollableStub(nil) | |||
| 158 | + | if err := NewPageScrollUseCase().Scroll(context.Background(), wv, tt.cmd); err != nil { | |||
| 159 | + | t.Fatal(err) | |||
| 160 | + | } | |||
| 161 | + | if wv.lastRequest.FallbackDX != tt.dx || wv.lastRequest.FallbackDY != tt.dy { | |||
| 162 | + | t.Fatalf("tap delta=(%d,%d), want (%d,%d)", wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY, tt.dx, tt.dy) | |||
| 163 | + | } | |||
| 164 | + | if wv.lastRequest.Continuous { | |||
| 165 | + | t.Fatal("tap request must not be marked continuous") | |||
| 166 | + | } | |||
| 167 | + | }) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestPageScroll_ScrollContinuousMarksRequest(t *testing.T) { | |||
| 172 | + | wv := newScrollableStub(nil) | |||
| 173 | + | if err := NewPageScrollUseCase().ScrollContinuous(context.Background(), wv, PageScrollDown); err != nil { | |||
| 174 | + | t.Fatal(err) | |||
| 175 | + | } | |||
| 176 | + | if !wv.lastRequest.Continuous || wv.lastRequest.FallbackDY != 80 { | |||
| 177 | + | t.Fatalf("continuous request = %+v, want continuous 80 px", wv.lastRequest) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestPageScroll_StopCancelsOptionalCapability(t *testing.T) { | |||
| 182 | + | wv := &cancelableScrollableStub{scrollableStub: newScrollableStub(nil)} | |||
| 183 | + | if err := NewPageScrollUseCase().Stop(context.Background(), wv); err != nil { | |||
| 184 | + | t.Fatal(err) | |||
| 185 | + | } | |||
| 186 | + | if wv.cancelCalls != 1 { | |||
| 187 | + | t.Fatalf("cancel calls=%d, want 1", wv.cancelCalls) | |||
| 188 | + | } | |||
| 189 | + | if err := NewPageScrollUseCase().Stop(context.Background(), newScrollableStub(nil)); err != nil { | |||
| 190 | + | t.Fatalf("non-cancelable WebKit-style adapter should be a no-op: %v", err) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func TestPageScroll_DeltaMethod(t *testing.T) { | |||
| 195 | + | tests := []struct { | |||
| 196 | + | cmd PageScrollCommand | |||
| 197 | + | wantDx int | |||
| 198 | + | wantDy int | |||
| 199 | + | wantName string | |||
| 200 | + | }{ | |||
| 201 | + | {PageScrollLeft, -80, 0, "left"}, | |||
| 202 | + | {PageScrollRight, 80, 0, "right"}, | |||
| 203 | + | {PageScrollUp, 0, -80, "up"}, | |||
| 204 | + | {PageScrollDown, 0, 80, "down"}, | |||
| 205 | + | {PageScrollUpFast, 0, -320, "up-fast"}, | |||
| 206 | + | {PageScrollDownFast, 0, 320, "down-fast"}, | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | for _, tt := range tests { | |||
| 210 | + | t.Run(tt.wantName, func(t *testing.T) { | |||
| 211 | + | dx, dy := scrollDelta(tt.cmd) | |||
| 212 | + | if dx != tt.wantDx || dy != tt.wantDy { | |||
| 213 | + | t.Errorf("scrollDelta(%v) = (%d, %d), want (%d, %d)", tt.cmd, dx, dy, tt.wantDx, tt.wantDy) | |||
| 214 | + | } | |||
| 215 | + | if got := tt.cmd.String(); got != tt.wantName { | |||
| 216 | + | t.Errorf("PageScrollCommand.String() = %q, want %q", got, tt.wantName) | |||
| 217 | + | } | |||
| 218 | + | }) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func TestPageScroll_CommandIdentityPropagation(t *testing.T) { | |||
| 223 | + | // Prove that PageScrollDownFast forwards command identity separately | |||
| 224 | + | // from fallback delta (dy=320). | |||
| 225 | + | wv := newScrollableStub(nil) | |||
| 226 | + | uc := NewPageScrollUseCase() | |||
| 227 | + | ||||
| 228 | + | err := uc.Scroll(context.Background(), wv, PageScrollDownFast) | |||
| 229 | + | if err != nil { | |||
| 230 | + | t.Fatalf("Scroll() unexpected error: %v", err) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | if wv.lastRequest.Command != port.PageScrollCommandDownFast { | |||
| 234 | + | t.Fatalf("ScrollPage request.Command = %d, want %d (PageScrollDownFast)", | |||
| 235 | + | wv.lastRequest.Command, port.PageScrollCommandDownFast) | |||
| 236 | + | } | |||
| 237 | + | if int(wv.lastRequest.Command) == wv.lastRequest.FallbackDY { | |||
| 238 | + | t.Fatal("command identity must NOT be confused with fallback delta: Command and FallbackDY should differ") | |||
| 239 | + | } | |||
| 240 | + | if wv.lastRequest.FallbackDX != 0 || wv.lastRequest.FallbackDY != 320 { | |||
| 241 | + | t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (0, 320)", | |||
| 242 | + | wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func TestPageScroll_UnknownCommand_ReturnsNoOp(t *testing.T) { | |||
| 247 | + | dx, dy := scrollDelta(PageScrollCommand(999)) | |||
| 248 | + | if dx != 0 || dy != 0 { | |||
| 249 | + | t.Errorf("scrollDelta(unknown) = (%d, %d), want (0, 0)", dx, dy) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | got := PageScrollCommand(999).String() | |||
| 253 | + | if got != "unknown" { | |||
| 254 | + | t.Errorf("PageScrollCommand(999).String() = %q, want %q", got, "unknown") | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // --- test helpers --- | |||
| 259 | + | ||||
| 260 | + | // scrollableStub satisfies both port.WebView (via embedded nonScrollableStub) | |||
| 261 | + | // and port.PageScrollable (via ScrollPage). | |||
| 262 | + | type scrollableStub struct { | |||
| 263 | + | *nonScrollableStub | |||
| 264 | + | lastRequest port.PageScrollRequest | |||
| 265 | + | scrollErr error | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | type cancelableScrollableStub struct { | |||
| 269 | + | *scrollableStub | |||
| 270 | + | cancelCalls int | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func (s *cancelableScrollableStub) CancelPageScroll(context.Context) { | |||
| 274 | + | s.cancelCalls++ | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func newScrollableStub(scrollErr error) *scrollableStub { | |||
| 278 | + | return &scrollableStub{ | |||
| 279 | + | nonScrollableStub: &nonScrollableStub{}, | |||
| 280 | + | scrollErr: scrollErr, | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // ScrollPage implements port.PageScrollable. | |||
| 285 | + | func (s *scrollableStub) ScrollPage(_ context.Context, req port.PageScrollRequest) error { | |||
| 286 | + | s.lastRequest = req | |||
| 287 | + | return s.scrollErr | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // nonScrollableStub satisfies port.WebView but NOT port.PageScrollable. | |||
| 291 | + | type nonScrollableStub struct{} | |||
| 292 | + | ||||
| 293 | + | func (*nonScrollableStub) ID() port.WebViewID { return 0 } | |||
| 294 | + | func (*nonScrollableStub) LoadURI(_ context.Context, _ string) error { return nil } | |||
| 295 | + | func (*nonScrollableStub) LoadHTML(_ context.Context, _, _ string) error { return nil } | |||
| 296 | + | func (*nonScrollableStub) Reload(_ context.Context) error { return nil } | |||
| 297 | + | func (*nonScrollableStub) ReloadBypassCache(_ context.Context) error { return nil } | |||
| 298 | + | func (*nonScrollableStub) Stop(_ context.Context) error { return nil } | |||
| 299 | + | func (*nonScrollableStub) GoBack(_ context.Context) error { return nil } | |||
| 300 | + | func (*nonScrollableStub) GoForward(_ context.Context) error { return nil } | |||
| 301 | + | func (*nonScrollableStub) State() port.WebViewState { return port.WebViewState{} } | |||
| 302 | + | func (*nonScrollableStub) URI() string { return "" } | |||
| 303 | + | func (*nonScrollableStub) Title() string { return "" } | |||
| 304 | + | func (*nonScrollableStub) IsLoading() bool { return false } | |||
| 305 | + | func (*nonScrollableStub) EstimatedProgress() float64 { return 0 } | |||
| 306 | + | func (*nonScrollableStub) CanGoBack() bool { return false } | |||
| 307 | + | func (*nonScrollableStub) CanGoForward() bool { return false } | |||
| 308 | + | func (*nonScrollableStub) SetZoomLevel(_ context.Context, _ float64) error { return nil } | |||
| 309 | + | func (*nonScrollableStub) GetZoomLevel() float64 { return 1.0 } | |||
| 310 | + | func (*nonScrollableStub) GetFindController() port.FindController { return nil } | |||
| 311 | + | func (*nonScrollableStub) SetCallbacks(_ *port.WebViewCallbacks) {} | |||
| 312 | + | func (*nonScrollableStub) RunJavaScript(_ context.Context, _ string) {} | |||
| 313 | + | func (*nonScrollableStub) SetBackgroundColor(_, _, _, _ float64) {} | |||
| 314 | + | func (*nonScrollableStub) ResetBackgroundToDefault() {} | |||
| 315 | + | func (*nonScrollableStub) Favicon() port.Texture { return nil } | |||
| 316 | + | func (*nonScrollableStub) Generation() uint64 { return 0 } | |||
| 317 | + | func (*nonScrollableStub) IsFullscreen() bool { return false } | |||
| 318 | + | func (*nonScrollableStub) IsPlayingAudio() bool { return false } | |||
| 319 | + | func (*nonScrollableStub) IsDestroyed() bool { return false } | |||
| 320 | + | func (*nonScrollableStub) Destroy() {} | |||
M internal/bootstrap/runtime_config.go +1 -0
| @@ -140,6 +140,7 @@ func cloneRuntimeSearchShortcuts(in map[string]entity.RuntimeSearchShortcut) map | |||||
| 140 | func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig { | 140 | func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig { | ||
| 141 | in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions) | 141 | in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions) | ||
| 142 | in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions) | 142 | in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions) | ||
| 143 | + | in.PageMode.Actions = cloneActionBindings(in.PageMode.Actions) | |||
| 143 | in.ResizeMode.Actions = cloneActionBindings(in.ResizeMode.Actions) | 144 | in.ResizeMode.Actions = cloneActionBindings(in.ResizeMode.Actions) | ||
| 144 | in.Shortcuts.Actions = cloneActionBindings(in.Shortcuts.Actions) | 145 | in.Shortcuts.Actions = cloneActionBindings(in.Shortcuts.Actions) | ||
| 145 | in.FloatingPane.Profiles = cloneFloatingPaneProfiles(in.FloatingPane.Profiles) | 146 | in.FloatingPane.Profiles = cloneFloatingPaneProfiles(in.FloatingPane.Profiles) | ||
M internal/bootstrap/runtime_config_test.go +12 -0
| @@ -258,6 +258,9 @@ func runtimeConfigWithNestedMutableFields() *config.Config { | |||||
| 258 | cfg.Workspace.TabMode.Actions = map[string]entity.ActionBinding{ | 258 | cfg.Workspace.TabMode.Actions = map[string]entity.ActionBinding{ | ||
| 259 | "next-tab": {Keys: []string{"l"}, Desc: "Next tab"}, | 259 | "next-tab": {Keys: []string{"l"}, Desc: "Next tab"}, | ||
| 260 | } | 260 | } | ||
| 261 | + | cfg.Workspace.PageMode.Actions = map[string]entity.ActionBinding{ | |||
| 262 | + | "page-scroll-down": {Keys: []string{"j"}, Desc: "Scroll down"}, | |||
| 263 | + | } | |||
| 261 | cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{ | 264 | cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{ | ||
| 262 | "grow-right": {Keys: []string{"right"}, Desc: "Grow right"}, | 265 | "grow-right": {Keys: []string{"right"}, Desc: "Grow right"}, | ||
| 263 | } | 266 | } | ||
| @@ -280,6 +283,9 @@ func mutateNestedRuntimeConfigSnapshot(snapshot entity.RuntimeConfigSnapshot) { | |||||
| 280 | mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab") | 283 | mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab") | ||
| 281 | snapshot.UI.Workspace.TabMode.Actions["added-tab"] = entity.ActionBinding{Keys: []string{"added"}} | 284 | snapshot.UI.Workspace.TabMode.Actions["added-tab"] = entity.ActionBinding{Keys: []string{"added"}} | ||
| 282 | 285 | ||||
| 286 | + | mutateActionBinding(snapshot.UI.Workspace.PageMode.Actions, "page-scroll-down", "mutated-page") | |||
| 287 | + | snapshot.UI.Workspace.PageMode.Actions["added-page"] = entity.ActionBinding{Keys: []string{"added"}} | |||
| 288 | + | ||||
| 283 | mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize") | 289 | mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize") | ||
| 284 | snapshot.UI.Workspace.ResizeMode.Actions["added-resize"] = entity.ActionBinding{Keys: []string{"added"}} | 290 | snapshot.UI.Workspace.ResizeMode.Actions["added-resize"] = entity.ActionBinding{Keys: []string{"added"}} | ||
| 285 | 291 | ||||
| @@ -310,6 +316,9 @@ func assertNestedRuntimeConfigSnapshotUnchanged(t *testing.T, snapshot entity.Ru | |||||
| 310 | assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l") | 316 | assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l") | ||
| 311 | assertMapEntryAbsent(t, snapshot.UI.Workspace.TabMode.Actions, "added-tab") | 317 | assertMapEntryAbsent(t, snapshot.UI.Workspace.TabMode.Actions, "added-tab") | ||
| 312 | 318 | ||||
| 319 | + | assertActionBinding(t, snapshot.UI.Workspace.PageMode.Actions, "page-scroll-down", "j") | |||
| 320 | + | assertMapEntryAbsent(t, snapshot.UI.Workspace.PageMode.Actions, "added-page") | |||
| 321 | + | ||||
| 313 | assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right") | 322 | assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right") | ||
| 314 | assertMapEntryAbsent(t, snapshot.UI.Workspace.ResizeMode.Actions, "added-resize") | 323 | assertMapEntryAbsent(t, snapshot.UI.Workspace.ResizeMode.Actions, "added-resize") | ||
| 315 | 324 | ||||
| @@ -357,6 +366,9 @@ func assertNestedSourceConfigUnchanged(t *testing.T, cfg *config.Config) { | |||||
| 357 | assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l") | 366 | assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l") | ||
| 358 | assertMapEntryAbsent(t, cfg.Workspace.TabMode.Actions, "added-tab") | 367 | assertMapEntryAbsent(t, cfg.Workspace.TabMode.Actions, "added-tab") | ||
| 359 | 368 | ||||
| 369 | + | assertActionBinding(t, cfg.Workspace.PageMode.Actions, "page-scroll-down", "j") | |||
| 370 | + | assertMapEntryAbsent(t, cfg.Workspace.PageMode.Actions, "added-page") | |||
| 371 | + | ||||
| 360 | assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right") | 372 | assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right") | ||
| 361 | assertMapEntryAbsent(t, cfg.Workspace.ResizeMode.Actions, "added-resize") | 373 | assertMapEntryAbsent(t, cfg.Workspace.ResizeMode.Actions, "added-resize") | ||
| 362 | 374 | ||||
M internal/domain/entity/config_types.go +13 -0
| @@ -78,6 +78,18 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string { | |||||
| 78 | return keyBindingsFromActions(t.Actions) | 78 | return keyBindingsFromActions(t.Actions) | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | + | // PageModeConfig holds page scroll mode shortcut configuration. | |||
| 82 | + | type PageModeConfig struct { | |||
| 83 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |||
| 84 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |||
| 85 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // GetKeyBindings returns a map from key string to action name. | |||
| 89 | + | func (p *PageModeConfig) GetKeyBindings() map[string]string { | |||
| 90 | + | return keyBindingsFromActions(p.Actions) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 81 | // ResizeModeConfig holds resize mode shortcut configuration. | 93 | // ResizeModeConfig holds resize mode shortcut configuration. | ||
| 82 | type ResizeModeConfig struct { | 94 | type ResizeModeConfig struct { | ||
| 83 | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 95 | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | ||
| @@ -202,6 +214,7 @@ type WorkspaceConfig struct { | |||||
| 202 | NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"` | 214 | NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"` | ||
| 203 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | 215 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | ||
| 204 | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | 216 | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | ||
| 217 | + | PageMode PageModeConfig `mapstructure:"page_mode" yaml:"page_mode" toml:"page_mode" json:"page_mode"` | |||
| 205 | ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"` | 218 | ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"` | ||
| 206 | Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"` | 219 | Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"` | ||
| 207 | FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"` | 220 | FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"` | ||
M internal/infrastructure/cef/app.go +5 -5
| @@ -280,16 +280,16 @@ type dumberApp struct { | |||||
| 280 | func newDumberApp(engine *Engine) purecef.App { | 280 | func newDumberApp(engine *Engine) purecef.App { | ||
| 281 | app := &dumberApp{engine: engine} | 281 | app := &dumberApp{engine: engine} | ||
| 282 | app.bph = &dumberBPH{engine: engine} | 282 | app.bph = &dumberBPH{engine: engine} | ||
| 283 | - | app.rph = newPopupOpenerRenderProcessHandler() | 283 | + | app.rph = newComposedRenderProcessHandler() |
| 284 | return app | 284 | return app | ||
| 285 | } | 285 | } | ||
| 286 | 286 | ||||
| 287 | // NewSubprocessApp returns a lightweight raw App implementation for CEF | 287 | // NewSubprocessApp returns a lightweight raw App implementation for CEF | ||
| 288 | - | // subprocesses re-executed from the main dumber binary. Keep the legacy | 288 | + | // subprocesses re-executed from the main dumber binary. Keep the legacy V8 |
| 289 | - | // renderer bridge disabled, but allow the minimal popup-opener render handler | 289 | + | // renderer bridge disabled while composing editable-focus reporting with the |
| 290 | - | // needed to install synthetic opener state before page scripts run. | 290 | + | // popup-opener callbacks needed before page scripts run. |
| 291 | func NewSubprocessApp() purecef.App { | 291 | func NewSubprocessApp() purecef.App { | ||
| 292 | - | return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()} | 292 | + | return &subprocessApp{rph: newComposedRenderProcessHandler()} |
| 293 | } | 293 | } | ||
| 294 | 294 | ||||
| 295 | // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths. | 295 | // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths. | ||
M internal/infrastructure/cef/popup_opener_renderer.go +25 -0
| @@ -50,6 +50,31 @@ func decodePopupOpenerRenderMetadata(extraInfo purecef.DictionaryValue) (popupOp | |||||
| 50 | return metadata, true | 50 | return metadata, true | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | + | // composedRenderProcessHandler keeps the popup-opener handler as the primary | |||
| 54 | + | // CEF renderer handler and adds only editable-focus reporting from the legacy | |||
| 55 | + | // renderer bridge. Embedding preserves every popup callback without enabling | |||
| 56 | + | // the bridge's V8 extension in OnWebKitInitialized. | |||
| 57 | + | type composedRenderProcessHandler struct { | |||
| 58 | + | *popupOpenerRenderProcessHandler | |||
| 59 | + | editableFocusHandler *rendererBridgeProcessHandler | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func newComposedRenderProcessHandler() *composedRenderProcessHandler { | |||
| 63 | + | return &composedRenderProcessHandler{ | |||
| 64 | + | popupOpenerRenderProcessHandler: newPopupOpenerRenderProcessHandler(), | |||
| 65 | + | editableFocusHandler: &rendererBridgeProcessHandler{}, | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (h *composedRenderProcessHandler) OnFocusedNodeChanged( | |||
| 70 | + | browser purecef.Browser, | |||
| 71 | + | frame purecef.Frame, | |||
| 72 | + | node purecef.Domnode, | |||
| 73 | + | ) { | |||
| 74 | + | h.popupOpenerRenderProcessHandler.OnFocusedNodeChanged(browser, frame, node) | |||
| 75 | + | h.editableFocusHandler.OnFocusedNodeChanged(browser, frame, node) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 53 | type popupOpenerRenderProcessHandler struct { | 78 | type popupOpenerRenderProcessHandler struct { | ||
| 54 | mu sync.RWMutex | 79 | mu sync.RWMutex | ||
| 55 | byBrowserID map[int32]popupOpenerRenderMetadata | 80 | byBrowserID map[int32]popupOpenerRenderMetadata | ||
M internal/infrastructure/cef/renderer_bridge_test.go +95 -0
| @@ -267,3 +267,98 @@ func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState( | |||||
| 267 | 267 | ||||
| 268 | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true}) | 268 | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true}) | ||
| 269 | } | 269 | } | ||
| 270 | + | ||||
| 271 | + | type runtimeEditableDomnode struct { | |||
| 272 | + | stubEditableDomnode | |||
| 273 | + | tagName string | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func (n runtimeEditableDomnode) GetElementTagName() string { return n.tagName } | |||
| 277 | + | ||||
| 278 | + | func TestRuntimeRenderProcessHandlers_ReportEditableFocusedNodes(t *testing.T) { | |||
| 279 | + | oldFactory := newRendererBridgeProcessMessage | |||
| 280 | + | t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory }) | |||
| 281 | + | newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage { | |||
| 282 | + | return newTestBridgeProcessMessage(name, true) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | apps := []struct { | |||
| 286 | + | name string | |||
| 287 | + | new func() purecef.App | |||
| 288 | + | }{ | |||
| 289 | + | {name: "main process", new: func() purecef.App { return newDumberApp(&Engine{}) }}, | |||
| 290 | + | {name: "subprocess", new: NewSubprocessApp}, | |||
| 291 | + | } | |||
| 292 | + | nodes := []struct { | |||
| 293 | + | name string | |||
| 294 | + | node purecef.Domnode | |||
| 295 | + | }{ | |||
| 296 | + | {name: "input", node: runtimeEditableDomnode{stubEditableDomnode: stubEditableDomnode{editable: true}, tagName: "INPUT"}}, | |||
| 297 | + | {name: "textarea", node: runtimeEditableDomnode{stubEditableDomnode: stubEditableDomnode{editable: true}, tagName: "TEXTAREA"}}, | |||
| 298 | + | {name: "contenteditable", node: runtimeEditableDomnode{stubEditableDomnode: stubEditableDomnode{editable: true}, tagName: "DIV"}}, | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | for _, appCase := range apps { | |||
| 302 | + | t.Run(appCase.name, func(t *testing.T) { | |||
| 303 | + | for _, nodeCase := range nodes { | |||
| 304 | + | t.Run(nodeCase.name, func(t *testing.T) { | |||
| 305 | + | frame := cefmocks.NewMockFrame(t) | |||
| 306 | + | frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything). | |||
| 307 | + | Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) { | |||
| 308 | + | action, editable, ok := decodeRendererBridgeProcessMessage(message) | |||
| 309 | + | require.True(t, ok) | |||
| 310 | + | require.Equal(t, rendererBridgeActionEditableFocusChanged, action) | |||
| 311 | + | require.Equal(t, "1", editable) | |||
| 312 | + | }).Once() | |||
| 313 | + | ||||
| 314 | + | handler := appCase.new().GetRenderProcessHandler() | |||
| 315 | + | require.NotNil(t, handler) | |||
| 316 | + | handler.OnFocusedNodeChanged(nil, frame, nodeCase.node) | |||
| 317 | + | }) | |||
| 318 | + | } | |||
| 319 | + | }) | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | func TestRuntimeRenderProcessHandlers_PreservePopupOpenerCallbacks(t *testing.T) { | |||
| 324 | + | apps := []struct { | |||
| 325 | + | name string | |||
| 326 | + | new func() purecef.App | |||
| 327 | + | }{ | |||
| 328 | + | {name: "main process", new: func() purecef.App { return newDumberApp(&Engine{}) }}, | |||
| 329 | + | {name: "subprocess", new: NewSubprocessApp}, | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | for _, appCase := range apps { | |||
| 333 | + | t.Run(appCase.name, func(t *testing.T) { | |||
| 334 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 335 | + | browser.EXPECT().GetIdentifier().Return(int32(42)).Times(4) | |||
| 336 | + | ctx := &testPopupOpenerV8Context{valid: true} | |||
| 337 | + | handler := appCase.new().GetRenderProcessHandler() | |||
| 338 | + | require.NotNil(t, handler) | |||
| 339 | + | require.Nil(t, handler.GetLoadHandler()) | |||
| 340 | + | ||||
| 341 | + | handler.OnBrowserCreated(browser, testPopupOpenerDictionaryValue{ | |||
| 342 | + | strings: map[string]string{ | |||
| 343 | + | popupOpenerExtraInfoParentURIKey: "https://example.com/login", | |||
| 344 | + | popupOpenerExtraInfoBridgeNonceKey: "bridge-nonce", | |||
| 345 | + | }, | |||
| 346 | + | bools: map[string]int32{popupOpenerExtraInfoEnabledKey: 1}, | |||
| 347 | + | }) | |||
| 348 | + | handler.OnContextCreated(browser, stubFrame{main: true, url: "https://example.com/popup"}, ctx) | |||
| 349 | + | ||||
| 350 | + | require.Equal(t, 1, ctx.enterCalls) | |||
| 351 | + | require.Equal(t, 1, ctx.exitCalls) | |||
| 352 | + | require.Contains(t, ctx.evalCode, "popup-opener-navigate") | |||
| 353 | + | require.Contains(t, ctx.evalCode, "popup-opener-post-message") | |||
| 354 | + | require.Equal(t, popupOpenerRenderScriptURL, ctx.evalURL) | |||
| 355 | + | require.Zero(t, handler.OnProcessMessageReceived(nil, nil, purecef.ProcessIDPidBrowser, nil)) | |||
| 356 | + | ||||
| 357 | + | handler.OnBrowserDestroyed(browser) | |||
| 358 | + | releasedCtx := &testPopupOpenerV8Context{valid: true} | |||
| 359 | + | handler.OnContextCreated(browser, stubFrame{main: true, url: "https://example.com/popup"}, releasedCtx) | |||
| 360 | + | require.Zero(t, releasedCtx.enterCalls) | |||
| 361 | + | require.Empty(t, releasedCtx.evalCode) | |||
| 362 | + | }) | |||
| 363 | + | } | |||
| 364 | + | } | |||
M internal/infrastructure/cef/webview.go +195 -10
| @@ -21,6 +21,7 @@ import ( | |||||
| 21 | "github.com/bnema/dumber/internal/application/dto" | 21 | "github.com/bnema/dumber/internal/application/dto" | ||
| 22 | "github.com/bnema/dumber/internal/application/port" | 22 | "github.com/bnema/dumber/internal/application/port" | ||
| 23 | "github.com/bnema/dumber/internal/domain/entity" | 23 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 24 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |||
| 24 | "github.com/bnema/dumber/internal/logging" | 25 | "github.com/bnema/dumber/internal/logging" | ||
| 25 | "github.com/bnema/dumber/internal/shared/syncdispatch" | 26 | "github.com/bnema/dumber/internal/shared/syncdispatch" | ||
| 26 | ) | 27 | ) | ||
| @@ -34,6 +35,8 @@ var ( | |||||
| 34 | _ port.PopupOpenerCapable = (*WebView)(nil) | 35 | _ port.PopupOpenerCapable = (*WebView)(nil) | ||
| 35 | _ port.ViewportSyncCapable = (*WebView)(nil) | 36 | _ port.ViewportSyncCapable = (*WebView)(nil) | ||
| 36 | _ port.OAuthCallbackCapable = (*WebView)(nil) | 37 | _ port.OAuthCallbackCapable = (*WebView)(nil) | ||
| 38 | + | _ port.PageScrollable = (*WebView)(nil) | |||
| 39 | + | _ port.PageScrollCanceler = (*WebView)(nil) | |||
| 37 | ) | 40 | ) | ||
| 38 | 41 | ||||
| 39 | // errDestroyed is returned when an operation is attempted on a destroyed WebView. | 42 | // errDestroyed is returned when an operation is attempted on a destroyed WebView. | ||
| @@ -69,8 +72,24 @@ var cefLoadWatchdogDelays = []time.Duration{ | |||||
| 69 | const ( | 72 | const ( | ||
| 70 | cefGTKSyncDispatchTimeout = 2 * time.Second | 73 | cefGTKSyncDispatchTimeout = 2 * time.Second | ||
| 71 | cefGTKSyncDispatchSlowThreshold = 250 * time.Millisecond | 74 | cefGTKSyncDispatchSlowThreshold = 250 * time.Millisecond | ||
| 75 | + | maxPendingPageScroll = 3200 | |||
| 72 | ) | 76 | ) | ||
| 73 | 77 | ||||
| 78 | + | type pageScrollQueue struct { | |||
| 79 | + | mu sync.Mutex | |||
| 80 | + | commitMu sync.Mutex | |||
| 81 | + | tapDX int | |||
| 82 | + | tapDY int | |||
| 83 | + | heldDX int | |||
| 84 | + | heldDY int | |||
| 85 | + | flushPending bool | |||
| 86 | + | generation uint64 | |||
| 87 | + | ||||
| 88 | + | // beforeExecute is a deterministic test seam for the drain-to-commit | |||
| 89 | + | // boundary. Production leaves it nil. | |||
| 90 | + | beforeExecute func() | |||
| 91 | + | } | |||
| 92 | + | ||||
| 74 | var ( | 93 | var ( | ||
| 75 | cefNewTask = purecef.NewTask | 94 | cefNewTask = purecef.NewTask | ||
| 76 | cefNewStringVisitor = purecef.NewStringVisitor | 95 | cefNewStringVisitor = purecef.NewStringVisitor | ||
| @@ -131,6 +150,8 @@ type WebView struct { | |||||
| 131 | adaptiveFrameRatePollID uint | 150 | adaptiveFrameRatePollID uint | ||
| 132 | lastAdaptiveFrameRate int32 | 151 | lastAdaptiveFrameRate int32 | ||
| 133 | 152 | ||||
| 153 | + | pageScrollQueue pageScrollQueue | |||
| 154 | + | ||||
| 134 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | 155 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | ||
| 135 | // widget is visible. Access is guarded by mu. | 156 | // widget is visible. Access is guarded by mu. | ||
| 136 | beginFrameTick *gtk.TickCallback | 157 | beginFrameTick *gtk.TickCallback | ||
| @@ -1201,6 +1222,7 @@ func (wv *WebView) Destroy() { | |||||
| 1201 | if !wv.destroyed.CompareAndSwap(false, true) { | 1222 | if !wv.destroyed.CompareAndSwap(false, true) { | ||
| 1202 | return | 1223 | return | ||
| 1203 | } | 1224 | } | ||
| 1225 | + | wv.resetPageScrollQueue() | |||
| 1204 | wv.syntheticPopupMu.Lock() | 1226 | wv.syntheticPopupMu.Lock() | ||
| 1205 | wv.syntheticPopups = nil | 1227 | wv.syntheticPopups = nil | ||
| 1206 | wv.syntheticPopupMu.Unlock() | 1228 | wv.syntheticPopupMu.Unlock() | ||
| @@ -1423,23 +1445,28 @@ func (wv *WebView) setEditableFocus(editable bool) { | |||||
| 1423 | wv.mu.Lock() | 1445 | wv.mu.Lock() | ||
| 1424 | previous := wv.focusedEditable | 1446 | previous := wv.focusedEditable | ||
| 1425 | wv.focusedEditable = editable | 1447 | wv.focusedEditable = editable | ||
| 1448 | + | cb := wv.callbacks | |||
| 1449 | + | var timer stoppableTimer | |||
| 1426 | if editable { | 1450 | if editable { | ||
| 1427 | wv.selectionDebounceSeq++ | 1451 | wv.selectionDebounceSeq++ | ||
| 1428 | - | timer := wv.selectionDebounceTimer | 1452 | + | timer = wv.selectionDebounceTimer |
| 1429 | wv.selectionDebounceTimer = nil | 1453 | wv.selectionDebounceTimer = nil | ||
| 1430 | - | wv.mu.Unlock() | |||
| 1431 | - | if timer != nil { | |||
| 1432 | - | timer.Stop() | |||
| 1433 | - | } | |||
| 1434 | - | if previous != editable && wv.ctx != nil { | |||
| 1435 | - | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | |||
| 1436 | - | } | |||
| 1437 | - | return | |||
| 1438 | } | 1454 | } | ||
| 1439 | wv.mu.Unlock() | 1455 | wv.mu.Unlock() | ||
| 1440 | - | if previous != editable && wv.ctx != nil { | 1456 | + | if timer != nil { |
| 1457 | + | timer.Stop() | |||
| 1458 | + | } | |||
| 1459 | + | if previous == editable { | |||
| 1460 | + | return | |||
| 1461 | + | } | |||
| 1462 | + | if wv.ctx != nil { | |||
| 1441 | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | 1463 | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | ||
| 1442 | } | 1464 | } | ||
| 1465 | + | if cb != nil && cb.OnEditableFocusChanged != nil { | |||
| 1466 | + | wv.runOnGTK(func() { | |||
| 1467 | + | cb.OnEditableFocusChanged(editable) | |||
| 1468 | + | }) | |||
| 1469 | + | } | |||
| 1443 | } | 1470 | } | ||
| 1444 | 1471 | ||||
| 1445 | func (wv *WebView) cancelSelectionDebounce() { | 1472 | func (wv *WebView) cancelSelectionDebounce() { | ||
| @@ -2368,6 +2395,164 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |||||
| 2368 | return pc | 2395 | return pc | ||
| 2369 | } | 2396 | } | ||
| 2370 | 2397 | ||||
| 2398 | + | // pageScrollWheelEvent builds the CEF mouse wheel event used for keyboard-driven | |||
| 2399 | + | // Page Mode scrolling. It targets the center of the current OSR view so the | |||
| 2400 | + | // scroll goes through Chromium's compositor/input pipeline instead of a series | |||
| 2401 | + | // of synthetic key taps. | |||
| 2402 | + | func (wv *WebView) pageScrollWheelEvent() purecef.MouseEvent { | |||
| 2403 | + | width, height := int32(1), int32(1) | |||
| 2404 | + | if wv != nil && wv.viewBridge != nil { | |||
| 2405 | + | width, height = wv.viewBridge.Size() | |||
| 2406 | + | } | |||
| 2407 | + | scale := normalizeScale(wv.osrBackingScaleFactor()) | |||
| 2408 | + | return purecef.MouseEvent{ | |||
| 2409 | + | X: int32(math.Floor(float64(width) * scale / 2)), | |||
| 2410 | + | Y: int32(math.Floor(float64(height) * scale / 2)), | |||
| 2411 | + | Modifiers: uint32(purecef.EventFlagsEventflagPrecisionScrollingDelta), | |||
| 2412 | + | } | |||
| 2413 | + | } | |||
| 2414 | + | ||||
| 2415 | + | func pageScrollWheelDeltas(request port.PageScrollRequest) (deltaX, deltaY int32) { | |||
| 2416 | + | return int32(-request.FallbackDX), int32(-request.FallbackDY) | |||
| 2417 | + | } | |||
| 2418 | + | ||||
| 2419 | + | // ScrollPage resolves a DOM scroll target when the browser is ready so an | |||
| 2420 | + | // exhausted nested scroller can hand off automatically to its ancestor or the | |||
| 2421 | + | // document. Native precision-wheel input remains the pre-frame fallback. | |||
| 2422 | + | func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error { | |||
| 2423 | + | if wv.destroyed.Load() { | |||
| 2424 | + | return errDestroyed | |||
| 2425 | + | } | |||
| 2426 | + | ||||
| 2427 | + | wv.mu.RLock() | |||
| 2428 | + | host := wv.host | |||
| 2429 | + | browser := wv.browser | |||
| 2430 | + | wv.mu.RUnlock() | |||
| 2431 | + | if browser != nil { | |||
| 2432 | + | wv.enqueuePageScroll(request) | |||
| 2433 | + | return nil | |||
| 2434 | + | } | |||
| 2435 | + | if host != nil { | |||
| 2436 | + | deltaX, deltaY := pageScrollWheelDeltas(request) | |||
| 2437 | + | if deltaX != 0 || deltaY != 0 { | |||
| 2438 | + | event := wv.pageScrollWheelEvent() | |||
| 2439 | + | host.SendMouseWheelEvent(&event, deltaX, deltaY) | |||
| 2440 | + | } | |||
| 2441 | + | return nil | |||
| 2442 | + | } | |||
| 2443 | + | ||||
| 2444 | + | wv.RunJavaScript(ctx, webutil.BuildScrollByJS(request.FallbackDX, request.FallbackDY)) | |||
| 2445 | + | return nil | |||
| 2446 | + | } | |||
| 2447 | + | ||||
| 2448 | + | func (wv *WebView) enqueuePageScroll(request port.PageScrollRequest) { | |||
| 2449 | + | q := &wv.pageScrollQueue | |||
| 2450 | + | q.mu.Lock() | |||
| 2451 | + | if request.Continuous { | |||
| 2452 | + | q.heldDX = saturatingPageScrollAdd(q.heldDX, request.FallbackDX) | |||
| 2453 | + | q.heldDY = saturatingPageScrollAdd(q.heldDY, request.FallbackDY) | |||
| 2454 | + | } else { | |||
| 2455 | + | q.tapDX = saturatingPageScrollAdd(q.tapDX, request.FallbackDX) | |||
| 2456 | + | q.tapDY = saturatingPageScrollAdd(q.tapDY, request.FallbackDY) | |||
| 2457 | + | } | |||
| 2458 | + | q.mu.Unlock() | |||
| 2459 | + | wv.ensurePageScrollFlush() | |||
| 2460 | + | } | |||
| 2461 | + | ||||
| 2462 | + | func (wv *WebView) ensurePageScrollFlush() { | |||
| 2463 | + | q := &wv.pageScrollQueue | |||
| 2464 | + | q.mu.Lock() | |||
| 2465 | + | if q.flushPending || (q.tapDX == 0 && q.tapDY == 0 && q.heldDX == 0 && q.heldDY == 0) { | |||
| 2466 | + | q.mu.Unlock() | |||
| 2467 | + | return | |||
| 2468 | + | } | |||
| 2469 | + | q.flushPending = true | |||
| 2470 | + | q.mu.Unlock() | |||
| 2471 | + | ||||
| 2472 | + | if wv.engine == nil { | |||
| 2473 | + | wv.flushPageScroll() | |||
| 2474 | + | return | |||
| 2475 | + | } | |||
| 2476 | + | task := cefNewTask(cefTaskFunc(wv.flushPageScroll)) | |||
| 2477 | + | if task == nil || cefPostTask(purecef.ThreadIDTidUi, task) != 1 { | |||
| 2478 | + | // Schedule failed: drop held (cancel semantics) and synchronously drain | |||
| 2479 | + | // any queued tap so it cannot remain stranded with flushPending cleared. | |||
| 2480 | + | q.mu.Lock() | |||
| 2481 | + | q.heldDX = 0 | |||
| 2482 | + | q.heldDY = 0 | |||
| 2483 | + | q.mu.Unlock() | |||
| 2484 | + | wv.flushPageScroll() | |||
| 2485 | + | } | |||
| 2486 | + | } | |||
| 2487 | + | ||||
| 2488 | + | func (wv *WebView) flushPageScroll() { | |||
| 2489 | + | q := &wv.pageScrollQueue | |||
| 2490 | + | q.mu.Lock() | |||
| 2491 | + | tapDX, tapDY := q.tapDX, q.tapDY | |||
| 2492 | + | heldDX, heldDY := q.heldDX, q.heldDY | |||
| 2493 | + | heldGeneration := q.generation | |||
| 2494 | + | beforeExecute := q.beforeExecute | |||
| 2495 | + | q.tapDX, q.tapDY, q.heldDX, q.heldDY = 0, 0, 0, 0 | |||
| 2496 | + | q.mu.Unlock() | |||
| 2497 | + | ||||
| 2498 | + | if beforeExecute != nil { | |||
| 2499 | + | beforeExecute() | |||
| 2500 | + | } | |||
| 2501 | + | ||||
| 2502 | + | // commitMu makes the generation check and the start of ExecuteJavaScript | |||
| 2503 | + | // linearizable with CancelPageScroll. The queue mutex is released before | |||
| 2504 | + | // taking it and before CEF execution, so JavaScript callbacks may enqueue a | |||
| 2505 | + | // successor without deadlocking; flushPending still bounds that to one task. | |||
| 2506 | + | q.commitMu.Lock() | |||
| 2507 | + | q.mu.Lock() | |||
| 2508 | + | if heldGeneration != q.generation { | |||
| 2509 | + | heldDX, heldDY = 0, 0 | |||
| 2510 | + | } | |||
| 2511 | + | q.mu.Unlock() | |||
| 2512 | + | if dx, dy := tapDX+heldDX, tapDY+heldDY; (dx != 0 || dy != 0) && !wv.destroyed.Load() { | |||
| 2513 | + | wv.executeJavaScriptNow(webutil.BuildScrollByJS(dx, dy)) | |||
| 2514 | + | } | |||
| 2515 | + | q.commitMu.Unlock() | |||
| 2516 | + | ||||
| 2517 | + | q.mu.Lock() | |||
| 2518 | + | q.flushPending = false | |||
| 2519 | + | q.mu.Unlock() | |||
| 2520 | + | wv.ensurePageScrollFlush() | |||
| 2521 | + | } | |||
| 2522 | + | ||||
| 2523 | + | // CancelPageScroll invalidates all accepted but unconsumed held-key deltas. | |||
| 2524 | + | // Tap deltas remain queued so a quick press always retains its 80/320 px step. | |||
| 2525 | + | // Returning is also a barrier: an old held batch cannot start afterward. | |||
| 2526 | + | func (wv *WebView) CancelPageScroll(_ context.Context) { | |||
| 2527 | + | q := &wv.pageScrollQueue | |||
| 2528 | + | q.commitMu.Lock() | |||
| 2529 | + | q.mu.Lock() | |||
| 2530 | + | q.generation++ | |||
| 2531 | + | q.heldDX = 0 | |||
| 2532 | + | q.heldDY = 0 | |||
| 2533 | + | q.mu.Unlock() | |||
| 2534 | + | q.commitMu.Unlock() | |||
| 2535 | + | } | |||
| 2536 | + | ||||
| 2537 | + | func (wv *WebView) resetPageScrollQueue() { | |||
| 2538 | + | q := &wv.pageScrollQueue | |||
| 2539 | + | q.mu.Lock() | |||
| 2540 | + | q.generation++ | |||
| 2541 | + | q.tapDX, q.tapDY, q.heldDX, q.heldDY = 0, 0, 0, 0 | |||
| 2542 | + | q.mu.Unlock() | |||
| 2543 | + | } | |||
| 2544 | + | ||||
| 2545 | + | func saturatingPageScrollAdd(current, delta int) int { | |||
| 2546 | + | result := current + delta | |||
| 2547 | + | if result > maxPendingPageScroll { | |||
| 2548 | + | return maxPendingPageScroll | |||
| 2549 | + | } | |||
| 2550 | + | if result < -maxPendingPageScroll { | |||
| 2551 | + | return -maxPendingPageScroll | |||
| 2552 | + | } | |||
| 2553 | + | return result | |||
| 2554 | + | } | |||
| 2555 | + | ||||
| 2371 | // beginAudioStreamStart invalidates any prior stream before creating a new | 2556 | // beginAudioStreamStart invalidates any prior stream before creating a new | ||
| 2372 | // one. A stream created for the returned generation may only be published if | 2557 | // one. A stream created for the returned generation may only be published if | ||
| 2373 | // that generation is still current. | 2558 | // that generation is still current. | ||
M internal/infrastructure/cef/webview_handlers.go +1 -0
| @@ -472,6 +472,7 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||||
| 472 | Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)). | 472 | Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)). | ||
| 473 | Msg("cef: OnLoadStart") | 473 | Msg("cef: OnLoadStart") | ||
| 474 | } | 474 | } | ||
| 475 | + | h.wv.setEditableFocus(false) | |||
| 475 | h.wv.updateURI(frame.GetURL()) | 476 | h.wv.updateURI(frame.GetURL()) | ||
| 476 | h.wv.mu.RLock() | 477 | h.wv.mu.RLock() | ||
| 477 | cb := h.wv.callbacks | 478 | cb := h.wv.callbacks | ||
M internal/infrastructure/cef/webview_handlers_test.go +34 -0
| @@ -544,6 +544,40 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||||
| 544 | require.Equal(t, "free selection", recorder.selection.Text) | 544 | require.Equal(t, "free selection", recorder.selection.Text) | ||
| 545 | } | 545 | } | ||
| 546 | 546 | ||||
| 547 | + | func TestSetEditableFocus_InvokesCallbackOnTransitions(t *testing.T) { | |||
| 548 | + | var states []bool | |||
| 549 | + | wv := &WebView{ctx: context.Background()} | |||
| 550 | + | wv.SetCallbacks(&port.WebViewCallbacks{ | |||
| 551 | + | OnEditableFocusChanged: func(editable bool) { | |||
| 552 | + | states = append(states, editable) | |||
| 553 | + | }, | |||
| 554 | + | }) | |||
| 555 | + | ||||
| 556 | + | wv.setEditableFocus(true) | |||
| 557 | + | wv.setEditableFocus(true) | |||
| 558 | + | wv.setEditableFocus(false) | |||
| 559 | + | ||||
| 560 | + | require.Equal(t, []bool{true, false}, states) | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | func TestOnLoadStart_ClearsEditableFocus(t *testing.T) { | |||
| 564 | + | var states []bool | |||
| 565 | + | wv := &WebView{ctx: context.Background(), focusedEditable: true} | |||
| 566 | + | wv.SetCallbacks(&port.WebViewCallbacks{ | |||
| 567 | + | OnEditableFocusChanged: func(editable bool) { | |||
| 568 | + | states = append(states, editable) | |||
| 569 | + | }, | |||
| 570 | + | }) | |||
| 571 | + | frame := cefmocks.NewMockFrame(t) | |||
| 572 | + | frame.EXPECT().IsMain().Return(true).Once() | |||
| 573 | + | frame.EXPECT().GetURL().Return("https://example.com").Maybe() | |||
| 574 | + | ||||
| 575 | + | (&handlerSet{wv: wv}).OnLoadStart(nil, frame, 0) | |||
| 576 | + | ||||
| 577 | + | require.Equal(t, []bool{false}, states) | |||
| 578 | + | require.False(t, wv.focusedEditable) | |||
| 579 | + | } | |||
| 580 | + | ||||
| 547 | func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) { | 581 | func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) { | ||
| 548 | called := false | 582 | called := false | ||
| 549 | wv := &WebView{closeCallbacks: []func(){func() { called = true }}} | 583 | wv := &WebView{closeCallbacks: []func(){func() { called = true }}} | ||
A internal/infrastructure/cef/webview_scroll_test.go +495 -0
| @@ -0,0 +1,495 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 13 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestPageScrollWheelDeltas_UseFallbackDeltasWithCEFSign(t *testing.T) { | |||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | req port.PageScrollRequest | |||
| 23 | + | wantDeltaX int32 | |||
| 24 | + | wantDeltaY int32 | |||
| 25 | + | }{ | |||
| 26 | + | {"left", port.PageScrollRequest{FallbackDX: -80}, 80, 0}, | |||
| 27 | + | {"right", port.PageScrollRequest{FallbackDX: 80}, -80, 0}, | |||
| 28 | + | {"up", port.PageScrollRequest{FallbackDY: -80}, 0, 80}, | |||
| 29 | + | {"down", port.PageScrollRequest{FallbackDY: 80}, 0, -80}, | |||
| 30 | + | {"up fast", port.PageScrollRequest{FallbackDY: -320}, 0, 320}, | |||
| 31 | + | {"down fast", port.PageScrollRequest{FallbackDY: 320}, 0, -320}, | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | for _, tt := range tests { | |||
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 36 | + | gotDeltaX, gotDeltaY := pageScrollWheelDeltas(tt.req) | |||
| 37 | + | if gotDeltaX != tt.wantDeltaX || gotDeltaY != tt.wantDeltaY { | |||
| 38 | + | t.Fatalf("pageScrollWheelDeltas() = (%d, %d), want (%d, %d)", gotDeltaX, gotDeltaY, tt.wantDeltaX, tt.wantDeltaY) | |||
| 39 | + | } | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // scrollRecorderHost wraps purecef.BrowserHost and records mouse wheel events, | |||
| 45 | + | // allowing tests to verify that Page Mode uses Chromium's native scroll path. | |||
| 46 | + | type scrollRecorderHost struct { | |||
| 47 | + | purecef.BrowserHost // embedded nil — only SendMouseWheelEvent is called in these tests | |||
| 48 | + | ||||
| 49 | + | mu sync.Mutex | |||
| 50 | + | events []recordedWheelEvent | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | type recordedWheelEvent struct { | |||
| 54 | + | event purecef.MouseEvent | |||
| 55 | + | deltaX, deltaY int32 | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (h *scrollRecorderHost) SendMouseWheelEvent(event *purecef.MouseEvent, deltaX, deltaY int32) { | |||
| 59 | + | h.mu.Lock() | |||
| 60 | + | defer h.mu.Unlock() | |||
| 61 | + | // Record a copy so the test can inspect it after the call returns. | |||
| 62 | + | h.events = append(h.events, recordedWheelEvent{event: *event, deltaX: deltaX, deltaY: deltaY}) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (h *scrollRecorderHost) recordedEvents() []recordedWheelEvent { | |||
| 66 | + | h.mu.Lock() | |||
| 67 | + | defer h.mu.Unlock() | |||
| 68 | + | out := make([]recordedWheelEvent, len(h.events)) | |||
| 69 | + | copy(out, h.events) | |||
| 70 | + | return out | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (h *scrollRecorderHost) resetRecords() { | |||
| 74 | + | h.mu.Lock() | |||
| 75 | + | defer h.mu.Unlock() | |||
| 76 | + | h.events = nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestScrollPage_NativeWheelPath_SendsPrecisionWheelEvent(t *testing.T) { | |||
| 80 | + | host := &scrollRecorderHost{} | |||
| 81 | + | wv := &WebView{host: host} | |||
| 82 | + | ||||
| 83 | + | req := port.PageScrollRequest{ | |||
| 84 | + | Command: port.PageScrollCommandDown, | |||
| 85 | + | FallbackDX: 0, | |||
| 86 | + | FallbackDY: 80, | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | err := wv.ScrollPage(context.Background(), req) | |||
| 90 | + | if err != nil { | |||
| 91 | + | t.Fatalf("ScrollPage() unexpected error: %v", err) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | events := host.recordedEvents() | |||
| 95 | + | if len(events) != 1 { | |||
| 96 | + | t.Fatalf("expected 1 wheel event, got %d", len(events)) | |||
| 97 | + | } | |||
| 98 | + | if events[0].deltaX != 0 || events[0].deltaY != -80 { | |||
| 99 | + | t.Fatalf("wheel delta = (%d, %d), want (0, -80)", events[0].deltaX, events[0].deltaY) | |||
| 100 | + | } | |||
| 101 | + | if events[0].event.Modifiers&uint32(purecef.EventFlagsEventflagPrecisionScrollingDelta) == 0 { | |||
| 102 | + | t.Fatal("wheel event should set EVENTFLAG_PRECISION_SCROLLING_DELTA") | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestScrollPage_NativeWheelPath_AllCommandsUseFallbackDeltas(t *testing.T) { | |||
| 107 | + | host := &scrollRecorderHost{} | |||
| 108 | + | wv := &WebView{host: host} | |||
| 109 | + | ||||
| 110 | + | commands := []struct { | |||
| 111 | + | name string | |||
| 112 | + | req port.PageScrollRequest | |||
| 113 | + | wantDeltaX int32 | |||
| 114 | + | wantDeltaY int32 | |||
| 115 | + | }{ | |||
| 116 | + | {"PageScrollLeft", port.PageScrollRequest{Command: port.PageScrollCommandLeft, FallbackDX: -80}, 80, 0}, | |||
| 117 | + | {"PageScrollRight", port.PageScrollRequest{Command: port.PageScrollCommandRight, FallbackDX: 80}, -80, 0}, | |||
| 118 | + | {"PageScrollUp", port.PageScrollRequest{Command: port.PageScrollCommandUp, FallbackDY: -80}, 0, 80}, | |||
| 119 | + | {"PageScrollDown", port.PageScrollRequest{Command: port.PageScrollCommandDown, FallbackDY: 80}, 0, -80}, | |||
| 120 | + | {"PageScrollUpFast", port.PageScrollRequest{Command: port.PageScrollCommandUpFast, FallbackDY: -320}, 0, 320}, | |||
| 121 | + | {"PageScrollDownFast", port.PageScrollRequest{Command: port.PageScrollCommandDownFast, FallbackDY: 320}, 0, -320}, | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | for _, tt := range commands { | |||
| 125 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 126 | + | host.resetRecords() | |||
| 127 | + | ||||
| 128 | + | if err := wv.ScrollPage(context.Background(), tt.req); err != nil { | |||
| 129 | + | t.Fatalf("ScrollPage() error: %v", err) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | events := host.recordedEvents() | |||
| 133 | + | if len(events) != 1 { | |||
| 134 | + | t.Fatalf("expected 1 wheel event, got %d", len(events)) | |||
| 135 | + | } | |||
| 136 | + | if events[0].deltaX != tt.wantDeltaX || events[0].deltaY != tt.wantDeltaY { | |||
| 137 | + | t.Fatalf("wheel delta = (%d, %d), want (%d, %d)", events[0].deltaX, events[0].deltaY, tt.wantDeltaX, tt.wantDeltaY) | |||
| 138 | + | } | |||
| 139 | + | }) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestScrollPage_ReadyBrowser_HorizontalDirectionsUseDOMSigns(t *testing.T) { | |||
| 144 | + | tests := []struct { | |||
| 145 | + | name string | |||
| 146 | + | dx int | |||
| 147 | + | marker string | |||
| 148 | + | }{ | |||
| 149 | + | {name: "h scrolls left", dx: -80, marker: "var dx=-80,dy=0"}, | |||
| 150 | + | {name: "l scrolls right", dx: 80, marker: "var dx=80,dy=0"}, | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | for _, tt := range tests { | |||
| 154 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 155 | + | host := &scrollRecorderHost{} | |||
| 156 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 157 | + | frame := cefmocks.NewMockFrame(t) | |||
| 158 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 159 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool { | |||
| 160 | + | return strings.Contains(script, tt.marker) | |||
| 161 | + | }), "", int32(0)).Once() | |||
| 162 | + | ||||
| 163 | + | wv := &WebView{host: host, browser: browser} | |||
| 164 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: tt.dx}); err != nil { | |||
| 165 | + | t.Fatalf("ScrollPage() error: %v", err) | |||
| 166 | + | } | |||
| 167 | + | if events := host.recordedEvents(); len(events) != 0 { | |||
| 168 | + | t.Fatalf("ready browser should use DOM target resolution, got %d wheel events", len(events)) | |||
| 169 | + | } | |||
| 170 | + | }) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestScrollPage_ReadyBrowser_NestedBoundaryHandsOffToDocument(t *testing.T) { | |||
| 175 | + | node, err := exec.LookPath("node") | |||
| 176 | + | if err != nil { | |||
| 177 | + | t.Skip("node is required to execute the generated page-scroll helper") | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | host := &scrollRecorderHost{} | |||
| 181 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 182 | + | frame := cefmocks.NewMockFrame(t) | |||
| 183 | + | var pageScrollScript string | |||
| 184 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 185 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool { | |||
| 186 | + | pageScrollScript = script | |||
| 187 | + | return true | |||
| 188 | + | }), "", int32(0)).Once() | |||
| 189 | + | ||||
| 190 | + | wv := &WebView{host: host, browser: browser} | |||
| 191 | + | if scrollErr := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); scrollErr != nil { | |||
| 192 | + | t.Fatalf("ScrollPage() error: %v", scrollErr) | |||
| 193 | + | } | |||
| 194 | + | if events := host.recordedEvents(); len(events) != 0 { | |||
| 195 | + | t.Fatalf("ready browser should resolve a scrollable DOM target instead of latching a wheel event, got %d wheel events", len(events)) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // The centered nested scroller can consume the first tick only. The next | |||
| 199 | + | // tick must skip its exhausted boundary and resume on the document scroller. | |||
| 200 | + | harness := fmt.Sprintf(` | |||
| 201 | + | const body={parentElement:null}; | |||
| 202 | + | const root={scrollTop:0,scrollLeft:0,scrollHeight:3000,clientHeight:500,scrollWidth:800,clientWidth:800,parentElement:null,style:{overflowY:'auto',overflowX:'auto'}}; | |||
| 203 | + | const inner={scrollTop:120,scrollLeft:0,scrollHeight:300,clientHeight:100,scrollWidth:200,clientWidth:200,parentElement:body,style:{overflowY:'auto',overflowX:'auto'}}; | |||
| 204 | + | body.parentElement=root; | |||
| 205 | + | global.document={activeElement:body,body:body,documentElement:root,scrollingElement:root,elementFromPoint:()=>inner}; | |||
| 206 | + | global.window={innerWidth:800,innerHeight:600,getComputedStyle:(el)=>el.style||{},scrollX:0,scrollY:0,scrollBy:()=>{},scrollTo:()=>{}}; | |||
| 207 | + | const tick=%q; | |||
| 208 | + | eval(tick); | |||
| 209 | + | eval(tick); | |||
| 210 | + | process.stdout.write(inner.scrollTop+','+root.scrollTop); | |||
| 211 | + | `, pageScrollScript) | |||
| 212 | + | output, err := exec.Command(node, "-e", harness).CombinedOutput() | |||
| 213 | + | if err != nil { | |||
| 214 | + | t.Fatalf("execute generated page-scroll helper: %v\n%s", err, output) | |||
| 215 | + | } | |||
| 216 | + | if got := strings.TrimSpace(string(output)); got != "200,80" { | |||
| 217 | + | t.Fatalf("nested/document scroll offsets = %s, want 200,80", got) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func TestScrollPage_NativeWheelPath_ZeroDeltaDoesNotSendEvent(t *testing.T) { | |||
| 222 | + | host := &scrollRecorderHost{} | |||
| 223 | + | wv := &WebView{host: host} | |||
| 224 | + | ||||
| 225 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{}); err != nil { | |||
| 226 | + | t.Fatalf("ScrollPage() error: %v", err) | |||
| 227 | + | } | |||
| 228 | + | if events := host.recordedEvents(); len(events) != 0 { | |||
| 229 | + | t.Fatalf("expected no wheel events for zero delta, got %d", len(events)) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestScrollPage_NativePath_FallbackToJSWhenHostIsNil(t *testing.T) { | |||
| 234 | + | // With no host set, ScrollPage should still succeed via the JS fallback. | |||
| 235 | + | wv := &WebView{} | |||
| 236 | + | ||||
| 237 | + | req := port.PageScrollRequest{ | |||
| 238 | + | Command: port.PageScrollCommandDown, | |||
| 239 | + | FallbackDX: 0, | |||
| 240 | + | FallbackDY: 80, | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // Should not panic and should succeed (JS fallback is fire-and-forget | |||
| 244 | + | // via RunJavaScript, which is safe with a nil browser). | |||
| 245 | + | err := wv.ScrollPage(context.Background(), req) | |||
| 246 | + | if err != nil { | |||
| 247 | + | t.Fatalf("ScrollPage() with nil host should not return error: %v", err) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func TestScrollPage_CoalescesHeldLoadIntoOneBoundedCEFTask(t *testing.T) { | |||
| 252 | + | oldNewTask, oldPostTask := cefNewTask, cefPostTask | |||
| 253 | + | defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }() | |||
| 254 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 255 | + | var scheduled purecef.Task | |||
| 256 | + | posts := 0 | |||
| 257 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |||
| 258 | + | if threadID != purecef.ThreadIDTidUi { | |||
| 259 | + | t.Fatalf("thread=%v, want CEF UI", threadID) | |||
| 260 | + | } | |||
| 261 | + | posts++ | |||
| 262 | + | scheduled = task | |||
| 263 | + | return 1 | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 267 | + | frame := cefmocks.NewMockFrame(t) | |||
| 268 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 269 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool { | |||
| 270 | + | return strings.Contains(script, "var dx=0,dy=3200") | |||
| 271 | + | }), "", int32(0)).Once() | |||
| 272 | + | wv := &WebView{engine: &Engine{}, browser: browser} | |||
| 273 | + | ||||
| 274 | + | for range 10_000 { | |||
| 275 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil { | |||
| 276 | + | t.Fatal(err) | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | if posts != 1 { | |||
| 280 | + | t.Fatalf("CEF flush posts=%d, want exactly 1 under load", posts) | |||
| 281 | + | } | |||
| 282 | + | if scheduled == nil { | |||
| 283 | + | t.Fatal("missing scheduled flush") | |||
| 284 | + | } | |||
| 285 | + | scheduled.Execute() | |||
| 286 | + | if posts != 1 { | |||
| 287 | + | t.Fatalf("unexpected replay after drain: posts=%d", posts) | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func TestCancelPageScroll_DropsHeldDeltaButPreservesTap(t *testing.T) { | |||
| 292 | + | oldNewTask, oldPostTask := cefNewTask, cefPostTask | |||
| 293 | + | defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }() | |||
| 294 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 295 | + | var scheduled purecef.Task | |||
| 296 | + | posts := 0 | |||
| 297 | + | cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 { | |||
| 298 | + | posts++ | |||
| 299 | + | scheduled = task | |||
| 300 | + | return 1 | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 304 | + | frame := cefmocks.NewMockFrame(t) | |||
| 305 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 306 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool { | |||
| 307 | + | return strings.Contains(script, "var dx=0,dy=80") && !strings.Contains(script, "requestAnimationFrame") | |||
| 308 | + | }), "", int32(0)).Once() | |||
| 309 | + | wv := &WebView{engine: &Engine{}, browser: browser} | |||
| 310 | + | ||||
| 311 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil { | |||
| 312 | + | t.Fatal(err) | |||
| 313 | + | } | |||
| 314 | + | for range 50 { | |||
| 315 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil { | |||
| 316 | + | t.Fatal(err) | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | wv.CancelPageScroll(context.Background()) | |||
| 320 | + | if posts != 1 { | |||
| 321 | + | t.Fatalf("posts before release=%d, want 1", posts) | |||
| 322 | + | } | |||
| 323 | + | scheduled.Execute() | |||
| 324 | + | if posts != 1 { | |||
| 325 | + | t.Fatalf("release must not schedule a trailing replay: posts=%d", posts) | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func TestCancelPageScroll_AfterDrainDropsOldHeldAndPreservesTapAndNextGesture(t *testing.T) { | |||
| 330 | + | oldNewTask, oldPostTask := cefNewTask, cefPostTask | |||
| 331 | + | defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }() | |||
| 332 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 333 | + | var scheduled []purecef.Task | |||
| 334 | + | cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 { | |||
| 335 | + | scheduled = append(scheduled, task) | |||
| 336 | + | return 1 | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | drained := make(chan struct{}) | |||
| 340 | + | resume := make(chan struct{}) | |||
| 341 | + | var pauseOnce sync.Once | |||
| 342 | + | beforeExecute := func() { | |||
| 343 | + | pauseOnce.Do(func() { | |||
| 344 | + | close(drained) | |||
| 345 | + | <-resume | |||
| 346 | + | }) | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 350 | + | frame := cefmocks.NewMockFrame(t) | |||
| 351 | + | browser.EXPECT().GetMainFrame().Return(frame).Twice() | |||
| 352 | + | var scripts []string | |||
| 353 | + | frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(script, _ string, _ int32) { | |||
| 354 | + | scripts = append(scripts, script) | |||
| 355 | + | }).Twice() | |||
| 356 | + | wv := &WebView{engine: &Engine{}, browser: browser} | |||
| 357 | + | wv.pageScrollQueue.beforeExecute = beforeExecute | |||
| 358 | + | ||||
| 359 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil { | |||
| 360 | + | t.Fatal(err) | |||
| 361 | + | } | |||
| 362 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil { | |||
| 363 | + | t.Fatal(err) | |||
| 364 | + | } | |||
| 365 | + | if len(scheduled) != 1 { | |||
| 366 | + | t.Fatalf("initial pending tasks=%d, want 1", len(scheduled)) | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | flushDone := make(chan struct{}) | |||
| 370 | + | go func() { | |||
| 371 | + | defer close(flushDone) | |||
| 372 | + | scheduled[0].Execute() | |||
| 373 | + | }() | |||
| 374 | + | <-drained | |||
| 375 | + | ||||
| 376 | + | // Cancel returns while the old batch is drained but has not committed to | |||
| 377 | + | // ExecuteJavaScript. Its held component must therefore be invalidated. | |||
| 378 | + | wv.CancelPageScroll(context.Background()) | |||
| 379 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: 80}); err != nil { | |||
| 380 | + | t.Fatal(err) | |||
| 381 | + | } | |||
| 382 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: 80, Continuous: true}); err != nil { | |||
| 383 | + | t.Fatal(err) | |||
| 384 | + | } | |||
| 385 | + | if len(scheduled) != 1 { | |||
| 386 | + | t.Fatalf("tasks while old flush is paused=%d, want 1", len(scheduled)) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | close(resume) | |||
| 390 | + | <-flushDone | |||
| 391 | + | if len(scripts) != 1 || !strings.Contains(scripts[0], "var dx=0,dy=80") { | |||
| 392 | + | t.Fatalf("old generation scripts=%v, want the preserved 80px tap without held delta", scripts) | |||
| 393 | + | } | |||
| 394 | + | if len(scheduled) != 2 { | |||
| 395 | + | t.Fatalf("tasks after old flush=%d, want one successor for the new gesture", len(scheduled)) | |||
| 396 | + | } | |||
| 397 | + | scheduled[1].Execute() | |||
| 398 | + | if len(scripts) != 2 || !strings.Contains(scripts[1], "var dx=160,dy=0") { | |||
| 399 | + | t.Fatalf("scripts after new gesture=%v, want a fresh 160px horizontal batch", scripts) | |||
| 400 | + | } | |||
| 401 | + | if len(scheduled) != 2 { | |||
| 402 | + | t.Fatalf("trailing tasks=%d, want 2 total", len(scheduled)) | |||
| 403 | + | } | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func TestScrollPage_AllowsAtMostOneSuccessorAfterInFlightFlush(t *testing.T) { | |||
| 407 | + | oldNewTask, oldPostTask := cefNewTask, cefPostTask | |||
| 408 | + | defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }() | |||
| 409 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 410 | + | var scheduled []purecef.Task | |||
| 411 | + | cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 { | |||
| 412 | + | scheduled = append(scheduled, task) | |||
| 413 | + | return 1 | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 417 | + | frame := cefmocks.NewMockFrame(t) | |||
| 418 | + | browser.EXPECT().GetMainFrame().Return(frame).Twice() | |||
| 419 | + | var wv *WebView | |||
| 420 | + | frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(string, string, int32) { | |||
| 421 | + | for range 100 { | |||
| 422 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil { | |||
| 423 | + | t.Fatal(err) | |||
| 424 | + | } | |||
| 425 | + | } | |||
| 426 | + | if len(scheduled) != 1 { | |||
| 427 | + | t.Fatalf("tasks while flush in flight=%d, want 1", len(scheduled)) | |||
| 428 | + | } | |||
| 429 | + | }).Once() | |||
| 430 | + | frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Once() | |||
| 431 | + | wv = &WebView{engine: &Engine{}, browser: browser} | |||
| 432 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil { | |||
| 433 | + | t.Fatal(err) | |||
| 434 | + | } | |||
| 435 | + | if len(scheduled) != 1 { | |||
| 436 | + | t.Fatalf("initial pending tasks=%d, want 1", len(scheduled)) | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | scheduled[0].Execute() | |||
| 440 | + | if len(scheduled) != 2 { | |||
| 441 | + | t.Fatalf("successor tasks=%d, want one successor", len(scheduled)) | |||
| 442 | + | } | |||
| 443 | + | scheduled[1].Execute() | |||
| 444 | + | if len(scheduled) != 2 { | |||
| 445 | + | t.Fatalf("prolonged replay tasks=%d, want 2 total", len(scheduled)) | |||
| 446 | + | } | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func TestEnsurePageScrollFlush_PostTaskFailureDrainsTapAndDropsHeld(t *testing.T) { | |||
| 450 | + | oldNewTask, oldPostTask := cefNewTask, cefPostTask | |||
| 451 | + | defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }() | |||
| 452 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 453 | + | cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 { return 0 } | |||
| 454 | + | ||||
| 455 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 456 | + | frame := cefmocks.NewMockFrame(t) | |||
| 457 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 458 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool { | |||
| 459 | + | return strings.Contains(script, "var dx=0,dy=80") && !strings.Contains(script, "dy=160") | |||
| 460 | + | }), "", int32(0)).Once() | |||
| 461 | + | wv := &WebView{engine: &Engine{}, browser: browser} | |||
| 462 | + | ||||
| 463 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil { | |||
| 464 | + | t.Fatal(err) | |||
| 465 | + | } | |||
| 466 | + | if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil { | |||
| 467 | + | t.Fatal(err) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | q := &wv.pageScrollQueue | |||
| 471 | + | q.mu.Lock() | |||
| 472 | + | tapDX, tapDY := q.tapDX, q.tapDY | |||
| 473 | + | heldDX, heldDY := q.heldDX, q.heldDY | |||
| 474 | + | pending := q.flushPending | |||
| 475 | + | q.mu.Unlock() | |||
| 476 | + | if tapDX != 0 || tapDY != 0 { | |||
| 477 | + | t.Fatalf("tap deltas stranded after PostTask failure: tap=(%d,%d)", tapDX, tapDY) | |||
| 478 | + | } | |||
| 479 | + | if heldDX != 0 || heldDY != 0 { | |||
| 480 | + | t.Fatalf("held deltas must keep cancel-on-schedule-failure semantics: held=(%d,%d)", heldDX, heldDY) | |||
| 481 | + | } | |||
| 482 | + | if pending { | |||
| 483 | + | t.Fatal("flushPending must clear after PostTask failure drain") | |||
| 484 | + | } | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func TestScrollPage_Destroyed_ReturnsError(t *testing.T) { | |||
| 488 | + | wv := &WebView{} | |||
| 489 | + | wv.destroyed.Store(true) | |||
| 490 | + | ||||
| 491 | + | err := wv.ScrollPage(context.Background(), port.PageScrollRequest{}) | |||
| 492 | + | if !errors.Is(err, errDestroyed) { | |||
| 493 | + | t.Fatalf("ScrollPage() on destroyed webview: got %v, want %v", err, errDestroyed) | |||
| 494 | + | } | |||
| 495 | + | } | |||
M internal/infrastructure/config/defaults.go +16 -0
| @@ -44,6 +44,8 @@ const ( | |||||
| 44 | defaultTabTimeoutMilliseconds = 3000 | 44 | defaultTabTimeoutMilliseconds = 3000 | ||
| 45 | defaultResizeActivationShortcut = "ctrl+n" | 45 | defaultResizeActivationShortcut = "ctrl+n" | ||
| 46 | defaultResizeTimeoutMilliseconds = 3000 | 46 | defaultResizeTimeoutMilliseconds = 3000 | ||
| 47 | + | defaultPageActivationShortcut = "ctrl+y" | |||
| 48 | + | defaultPageTimeoutMilliseconds = 0 | |||
| 47 | defaultResizeStepPercent = 5.0 | 49 | defaultResizeStepPercent = 5.0 | ||
| 48 | defaultResizeMinPanePercent = 10.0 | 50 | defaultResizeMinPanePercent = 10.0 | ||
| 49 | defaultTabBarPosition = "bottom" | 51 | defaultTabBarPosition = "bottom" | ||
| @@ -300,6 +302,20 @@ func DefaultConfig() *Config { | |||||
| 300 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | 302 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | ||
| 301 | }, | 303 | }, | ||
| 302 | }, | 304 | }, | ||
| 305 | + | PageMode: PageModeConfig{ | |||
| 306 | + | ActivationShortcut: defaultPageActivationShortcut, | |||
| 307 | + | TimeoutMilliseconds: defaultPageTimeoutMilliseconds, | |||
| 308 | + | Actions: map[string]ActionBinding{ | |||
| 309 | + | "page-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"}, | |||
| 310 | + | "page-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"}, | |||
| 311 | + | "page-scroll-up": {Keys: []string{"k"}, Desc: "Scroll page up"}, | |||
| 312 | + | "page-scroll-right": {Keys: []string{"l"}, Desc: "Scroll page right"}, | |||
| 313 | + | "page-scroll-down-fast": {Keys: []string{"shift+j"}, Desc: "Scroll page down fast"}, | |||
| 314 | + | "page-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"}, | |||
| 315 | + | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | |||
| 316 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | |||
| 317 | + | }, | |||
| 318 | + | }, | |||
| 303 | ResizeMode: ResizeModeConfig{ | 319 | ResizeMode: ResizeModeConfig{ | ||
| 304 | ActivationShortcut: defaultResizeActivationShortcut, | 320 | ActivationShortcut: defaultResizeActivationShortcut, | ||
| 305 | TimeoutMilliseconds: defaultResizeTimeoutMilliseconds, | 321 | TimeoutMilliseconds: defaultResizeTimeoutMilliseconds, | ||
M internal/infrastructure/config/keybindings_gateway.go +23 -0
| @@ -32,6 +32,7 @@ func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.Keybindin | |||||
| 32 | g.buildGlobalGroup(cfg, defaults), | 32 | g.buildGlobalGroup(cfg, defaults), | ||
| 33 | g.buildPaneModeGroup(cfg, defaults), | 33 | g.buildPaneModeGroup(cfg, defaults), | ||
| 34 | g.buildTabModeGroup(cfg, defaults), | 34 | g.buildTabModeGroup(cfg, defaults), | ||
| 35 | + | g.buildPageModeGroup(cfg, defaults), | |||
| 35 | g.buildResizeModeGroup(cfg, defaults), | 36 | g.buildResizeModeGroup(cfg, defaults), | ||
| 36 | g.buildSessionModeGroup(cfg, defaults), | 37 | g.buildSessionModeGroup(cfg, defaults), | ||
| 37 | } | 38 | } | ||
| @@ -48,6 +49,7 @@ func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.Ke | |||||
| 48 | g.buildGlobalGroup(defaults, defaults), | 49 | g.buildGlobalGroup(defaults, defaults), | ||
| 49 | g.buildPaneModeGroup(defaults, defaults), | 50 | g.buildPaneModeGroup(defaults, defaults), | ||
| 50 | g.buildTabModeGroup(defaults, defaults), | 51 | g.buildTabModeGroup(defaults, defaults), | ||
| 52 | + | g.buildPageModeGroup(defaults, defaults), | |||
| 51 | g.buildResizeModeGroup(defaults, defaults), | 53 | g.buildResizeModeGroup(defaults, defaults), | ||
| 52 | g.buildSessionModeGroup(defaults, defaults), | 54 | g.buildSessionModeGroup(defaults, defaults), | ||
| 53 | } | 55 | } | ||
| @@ -108,6 +110,7 @@ func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error { | |||||
| 108 | 110 | ||||
| 109 | cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions | 111 | cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions | ||
| 110 | cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions | 112 | cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions | ||
| 113 | + | cfg.Workspace.PageMode.Actions = defaults.Workspace.PageMode.Actions | |||
| 111 | cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions | 114 | cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions | ||
| 112 | cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts | 115 | cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts | ||
| 113 | cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions | 116 | cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions | ||
| @@ -144,6 +147,16 @@ func (g *KeybindingsGateway) buildTabModeGroup(cfg, defaults *Config) port.Keybi | |||||
| 144 | } | 147 | } | ||
| 145 | } | 148 | } | ||
| 146 | 149 | ||||
| 150 | + | // buildPageModeGroup builds the page mode group. | |||
| 151 | + | func (g *KeybindingsGateway) buildPageModeGroup(cfg, defaults *Config) port.KeybindingGroup { | |||
| 152 | + | return port.KeybindingGroup{ | |||
| 153 | + | Mode: "page", | |||
| 154 | + | DisplayName: "Page Mode", | |||
| 155 | + | Bindings: g.buildModeBindings(cfg.Workspace.PageMode.Actions, defaults.Workspace.PageMode.Actions), | |||
| 156 | + | Activation: cfg.Workspace.PageMode.ActivationShortcut, | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 147 | // buildResizeModeGroup builds the resize mode group. | 160 | // buildResizeModeGroup builds the resize mode group. | ||
| 148 | func (g *KeybindingsGateway) buildResizeModeGroup(cfg, defaults *Config) port.KeybindingGroup { | 161 | func (g *KeybindingsGateway) buildResizeModeGroup(cfg, defaults *Config) port.KeybindingGroup { | ||
| 149 | return port.KeybindingGroup{ | 162 | return port.KeybindingGroup{ | ||
| @@ -208,6 +221,11 @@ func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingR | |||||
| 208 | existing.Keys = req.Keys | 221 | existing.Keys = req.Keys | ||
| 209 | cfg.Workspace.TabMode.Actions[req.Action] = existing | 222 | cfg.Workspace.TabMode.Actions[req.Action] = existing | ||
| 210 | } | 223 | } | ||
| 224 | + | case "page": | |||
| 225 | + | if existing, ok := cfg.Workspace.PageMode.Actions[req.Action]; ok { | |||
| 226 | + | existing.Keys = req.Keys | |||
| 227 | + | cfg.Workspace.PageMode.Actions[req.Action] = existing | |||
| 228 | + | } | |||
| 211 | case "resize": | 229 | case "resize": | ||
| 212 | if existing, ok := cfg.Workspace.ResizeMode.Actions[req.Action]; ok { | 230 | if existing, ok := cfg.Workspace.ResizeMode.Actions[req.Action]; ok { | ||
| 213 | existing.Keys = req.Keys | 231 | existing.Keys = req.Keys | ||
| @@ -236,6 +254,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string) | |||||
| 236 | if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok { | 254 | if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok { | ||
| 237 | return binding.Keys | 255 | return binding.Keys | ||
| 238 | } | 256 | } | ||
| 257 | + | case "page": | |||
| 258 | + | if binding, ok := defaults.Workspace.PageMode.Actions[action]; ok { | |||
| 259 | + | return binding.Keys | |||
| 260 | + | } | |||
| 239 | case "resize": | 261 | case "resize": | ||
| 240 | if binding, ok := defaults.Workspace.ResizeMode.Actions[action]; ok { | 262 | if binding, ok := defaults.Workspace.ResizeMode.Actions[action]; ok { | ||
| 241 | return binding.Keys | 263 | return binding.Keys | ||
| @@ -269,6 +291,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction | |||||
| 269 | addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions) | 291 | addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions) | ||
| 270 | addBindings("pane", cfg.Workspace.PaneMode.Actions) | 292 | addBindings("pane", cfg.Workspace.PaneMode.Actions) | ||
| 271 | addBindings("tab", cfg.Workspace.TabMode.Actions) | 293 | addBindings("tab", cfg.Workspace.TabMode.Actions) | ||
| 294 | + | addBindings("page", cfg.Workspace.PageMode.Actions) | |||
| 272 | addBindings("resize", cfg.Workspace.ResizeMode.Actions) | 295 | addBindings("resize", cfg.Workspace.ResizeMode.Actions) | ||
| 273 | addBindings("session", cfg.Session.SessionMode.Actions) | 296 | addBindings("session", cfg.Session.SessionMode.Actions) | ||
| 274 | 297 | ||||
A internal/infrastructure/config/keybindings_gateway_test.go +216 -0
| @@ -0,0 +1,216 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // newTestKeybindingsManager creates a Manager with a temp config dir. | |||
| 16 | + | func newTestKeybindingsManager(t *testing.T) *Manager { | |||
| 17 | + | t.Helper() | |||
| 18 | + | configHome := t.TempDir() | |||
| 19 | + | ||||
| 20 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |||
| 21 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(configHome, "data")) | |||
| 22 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(configHome, "state")) | |||
| 23 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(configHome, "cache")) | |||
| 24 | + | ||||
| 25 | + | mgr, err := NewManager() | |||
| 26 | + | require.NoError(t, err) | |||
| 27 | + | require.NoError(t, mgr.Load()) | |||
| 28 | + | return mgr | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // inMemoryManager creates a lightweight Manager without file I/O for tests | |||
| 32 | + | // that only query config data without saving. | |||
| 33 | + | func inMemoryManager() *Manager { | |||
| 34 | + | return &Manager{ | |||
| 35 | + | config: DefaultConfig(), | |||
| 36 | + | mu: sync.RWMutex{}, | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestKeybindingsGateway_PageModeIncludedInGetKeybindings(t *testing.T) { | |||
| 41 | + | gw := NewKeybindingsGateway(inMemoryManager()) | |||
| 42 | + | cfg, err := gw.GetKeybindings(context.Background()) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | ||||
| 45 | + | var pageGroup *port.KeybindingGroup | |||
| 46 | + | for i, g := range cfg.Groups { | |||
| 47 | + | if g.Mode == "page" { | |||
| 48 | + | pageGroup = &cfg.Groups[i] | |||
| 49 | + | break | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | require.NotNil(t, pageGroup, "page mode group must be present in GetKeybindings output") | |||
| 53 | + | assert.Equal(t, "Page Mode", pageGroup.DisplayName) | |||
| 54 | + | assert.Equal(t, "ctrl+y", pageGroup.Activation) | |||
| 55 | + | ||||
| 56 | + | // Verify all expected actions are present. | |||
| 57 | + | expectedActions := map[string]struct{}{ | |||
| 58 | + | "page-scroll-left": {}, | |||
| 59 | + | "page-scroll-down": {}, | |||
| 60 | + | "page-scroll-up": {}, | |||
| 61 | + | "page-scroll-right": {}, | |||
| 62 | + | "page-scroll-down-fast": {}, | |||
| 63 | + | "page-scroll-up-fast": {}, | |||
| 64 | + | "confirm": {}, | |||
| 65 | + | "cancel": {}, | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | for _, entry := range pageGroup.Bindings { | |||
| 69 | + | delete(expectedActions, entry.Action) | |||
| 70 | + | assert.NotEmpty(t, entry.Keys, "action %q has empty keys", entry.Action) | |||
| 71 | + | assert.False(t, entry.IsCustom, "default page mode config should not have IsCustom=true") | |||
| 72 | + | } | |||
| 73 | + | assert.Empty(t, expectedActions, "page mode group missing actions: %v", expectedActions) | |||
| 74 | + | ||||
| 75 | + | // Must be placed after global, pane, tab groups (consistent ordering). | |||
| 76 | + | groups := cfg.Groups | |||
| 77 | + | foundIdx := -1 | |||
| 78 | + | for i, g := range groups { | |||
| 79 | + | if g.Mode == "page" { | |||
| 80 | + | foundIdx = i | |||
| 81 | + | break | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | assert.Greater(t, foundIdx, 2, "page mode group should be after pane/tab groups") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestKeybindingsGateway_PageModeIncludedInGetDefaultKeybindings(t *testing.T) { | |||
| 88 | + | gw := NewKeybindingsGateway(inMemoryManager()) | |||
| 89 | + | cfg, err := gw.GetDefaultKeybindings(context.Background()) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | ||||
| 92 | + | var pageGroup *port.KeybindingGroup | |||
| 93 | + | for i, g := range cfg.Groups { | |||
| 94 | + | if g.Mode == "page" { | |||
| 95 | + | pageGroup = &cfg.Groups[i] | |||
| 96 | + | break | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | require.NotNil(t, pageGroup, "page mode group must be present in GetDefaultKeybindings output") | |||
| 100 | + | assert.Equal(t, "Page Mode", pageGroup.DisplayName) | |||
| 101 | + | assert.Equal(t, "ctrl+y", pageGroup.Activation) | |||
| 102 | + | ||||
| 103 | + | // Verify default keys match actual keys. | |||
| 104 | + | for _, entry := range pageGroup.Bindings { | |||
| 105 | + | assert.Equal(t, entry.DefaultKeys, entry.Keys, | |||
| 106 | + | "default page mode action %q should have keys == default_keys", entry.Action) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestKeybindingsGateway_PageModeSetKeybindingUpdatesFile(t *testing.T) { | |||
| 111 | + | mgr := newTestKeybindingsManager(t) | |||
| 112 | + | gw := NewKeybindingsGateway(mgr) | |||
| 113 | + | ||||
| 114 | + | err := gw.SetKeybinding(context.Background(), port.SetKeybindingRequest{ | |||
| 115 | + | Mode: "page", | |||
| 116 | + | Action: "page-scroll-left", | |||
| 117 | + | Keys: []string{"a"}, | |||
| 118 | + | }) | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | ||||
| 121 | + | // Verify the file was written with the updated keybinding. | |||
| 122 | + | configFile := mgr.GetConfigFile() | |||
| 123 | + | data, err := os.ReadFile(configFile) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | content := string(data) | |||
| 126 | + | assert.Contains(t, content, `keys = ['a']`) | |||
| 127 | + | assert.NotContains(t, content, `keys = ['h']`) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestKeybindingsGateway_PageModeResetKeybinding(t *testing.T) { | |||
| 131 | + | mgr := newTestKeybindingsManager(t) | |||
| 132 | + | gw := NewKeybindingsGateway(mgr) | |||
| 133 | + | ||||
| 134 | + | // Set a keybinding. | |||
| 135 | + | require.NoError(t, gw.SetKeybinding(context.Background(), port.SetKeybindingRequest{ | |||
| 136 | + | Mode: "page", | |||
| 137 | + | Action: "page-scroll-left", | |||
| 138 | + | Keys: []string{"a"}, | |||
| 139 | + | })) | |||
| 140 | + | ||||
| 141 | + | // Reset it. | |||
| 142 | + | err := gw.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{ | |||
| 143 | + | Mode: "page", | |||
| 144 | + | Action: "page-scroll-left", | |||
| 145 | + | }) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | ||||
| 148 | + | // Verify the file was updated back to default. | |||
| 149 | + | configFile := mgr.GetConfigFile() | |||
| 150 | + | data, err := os.ReadFile(configFile) | |||
| 151 | + | require.NoError(t, err) | |||
| 152 | + | content := string(data) | |||
| 153 | + | assert.Contains(t, content, `keys = ['h']`) | |||
| 154 | + | assert.NotContains(t, content, `keys = ['a']`) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestKeybindingsGateway_PageModeIsPartOfResetAll(t *testing.T) { | |||
| 158 | + | mgr := newTestKeybindingsManager(t) | |||
| 159 | + | gw := NewKeybindingsGateway(mgr) | |||
| 160 | + | ||||
| 161 | + | // Change a page mode binding. | |||
| 162 | + | require.NoError(t, gw.SetKeybinding(context.Background(), port.SetKeybindingRequest{ | |||
| 163 | + | Mode: "page", | |||
| 164 | + | Action: "page-scroll-left", | |||
| 165 | + | Keys: []string{"a"}, | |||
| 166 | + | })) | |||
| 167 | + | ||||
| 168 | + | // Change another action. | |||
| 169 | + | require.NoError(t, gw.SetKeybinding(context.Background(), port.SetKeybindingRequest{ | |||
| 170 | + | Mode: "page", | |||
| 171 | + | Action: "page-scroll-down", | |||
| 172 | + | Keys: []string{"b"}, | |||
| 173 | + | })) | |||
| 174 | + | ||||
| 175 | + | // Reset all. | |||
| 176 | + | err := gw.ResetAllKeybindings(context.Background()) | |||
| 177 | + | require.NoError(t, err) | |||
| 178 | + | ||||
| 179 | + | // Verify file has defaults. | |||
| 180 | + | configFile := mgr.GetConfigFile() | |||
| 181 | + | data, err := os.ReadFile(configFile) | |||
| 182 | + | require.NoError(t, err) | |||
| 183 | + | content := string(data) | |||
| 184 | + | assert.Contains(t, content, `keys = ['h']`, "page-scroll-left should be restored to default") | |||
| 185 | + | assert.Contains(t, content, `keys = ['j']`, "page-scroll-down should be restored to default") | |||
| 186 | + | assert.NotContains(t, content, "keys = ['a']") | |||
| 187 | + | assert.NotContains(t, content, "keys = ['b']") | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestKeybindingsGateway_PageModeConflictsWithinSelf(t *testing.T) { | |||
| 191 | + | gw := NewKeybindingsGateway(inMemoryManager()) | |||
| 192 | + | conflicts, err := gw.CheckConflicts(context.Background(), "page", "custom", []string{"h"}) | |||
| 193 | + | require.NoError(t, err) | |||
| 194 | + | require.NotEmpty(t, conflicts, "should detect conflict with page-scroll-left which uses 'h'") | |||
| 195 | + | ||||
| 196 | + | // The conflict should reference the same mode. | |||
| 197 | + | assert.Equal(t, "page-scroll-left", conflicts[0].ConflictingAction) | |||
| 198 | + | assert.Equal(t, "page", conflicts[0].ConflictingMode) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestKeybindingsGateway_PageModeConflictsWithOtherModes(t *testing.T) { | |||
| 202 | + | gw := NewKeybindingsGateway(inMemoryManager()) | |||
| 203 | + | // "enter" is used by page mode's confirm - but also by other modes like pane_mode. | |||
| 204 | + | conflicts, err := gw.CheckConflicts(context.Background(), "page", "custom", []string{"enter"}) | |||
| 205 | + | require.NoError(t, err) | |||
| 206 | + | ||||
| 207 | + | // There should be conflicts since "enter" is used in other modes too. | |||
| 208 | + | assert.NotEmpty(t, conflicts, "should detect cross-mode conflict for 'enter'") | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestKeybindingsGateway_PageModeNoConflictForUnusedKey(t *testing.T) { | |||
| 212 | + | gw := NewKeybindingsGateway(inMemoryManager()) | |||
| 213 | + | conflicts, err := gw.CheckConflicts(context.Background(), "page", "custom", []string{"ctrl+9"}) | |||
| 214 | + | require.NoError(t, err) | |||
| 215 | + | assert.Empty(t, conflicts, "no conflict expected for unused key") | |||
| 216 | + | } | |||
M internal/infrastructure/config/loader.go +3 -0
| @@ -695,6 +695,9 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||||
| 695 | m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut) | 695 | m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut) | ||
| 696 | m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds) | 696 | m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds) | ||
| 697 | m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions) | 697 | m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions) | ||
| 698 | + | m.viper.SetDefault("workspace.page_mode.activation_shortcut", defaults.Workspace.PageMode.ActivationShortcut) | |||
| 699 | + | m.viper.SetDefault("workspace.page_mode.timeout_ms", defaults.Workspace.PageMode.TimeoutMilliseconds) | |||
| 700 | + | m.viper.SetDefault("workspace.page_mode.actions", defaults.Workspace.PageMode.Actions) | |||
| 698 | m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut) | 701 | m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut) | ||
| 699 | m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds) | 702 | m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds) | ||
| 700 | m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent) | 703 | m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent) | ||
M internal/infrastructure/config/migrate.go +1 -0
| @@ -299,6 +299,7 @@ func (m *Migrator) defaultActionMaps() []defaultActionMap { | |||||
| 299 | {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions}, | 299 | {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions}, | ||
| 300 | {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions}, | 300 | {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions}, | ||
| 301 | {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions}, | 301 | {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions}, | ||
| 302 | + | {key: "workspace.page_mode.actions", actions: m.defaultConfig.Workspace.PageMode.Actions}, | |||
| 302 | {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions}, | 303 | {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions}, | ||
| 303 | {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions}, | 304 | {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions}, | ||
| 304 | } | 305 | } | ||
A internal/infrastructure/config/page_mode_config_test.go +223 -0
| @@ -0,0 +1,223 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestDefaultConfig_PageModeDefaults(t *testing.T) { | |||
| 11 | + | cfg := DefaultConfig() | |||
| 12 | + | ||||
| 13 | + | // PageMode must exist in workspace config. | |||
| 14 | + | pageMode := cfg.Workspace.PageMode | |||
| 15 | + | assert.Equal(t, "ctrl+y", pageMode.ActivationShortcut) | |||
| 16 | + | assert.Equal(t, 0, pageMode.TimeoutMilliseconds) | |||
| 17 | + | ||||
| 18 | + | // All required actions must exist with exact key bindings. | |||
| 19 | + | require.NotNil(t, pageMode.Actions) | |||
| 20 | + | requireActionBinding(t, pageMode.Actions, "page-scroll-left", []string{"h"}) | |||
| 21 | + | requireActionBinding(t, pageMode.Actions, "page-scroll-down", []string{"j"}) | |||
| 22 | + | requireActionBinding(t, pageMode.Actions, "page-scroll-up", []string{"k"}) | |||
| 23 | + | requireActionBinding(t, pageMode.Actions, "page-scroll-right", []string{"l"}) | |||
| 24 | + | requireActionBinding(t, pageMode.Actions, "page-scroll-down-fast", []string{"shift+j"}) | |||
| 25 | + | requireActionBinding(t, pageMode.Actions, "page-scroll-up-fast", []string{"shift+k"}) | |||
| 26 | + | requireActionBinding(t, pageMode.Actions, "confirm", []string{"enter"}) | |||
| 27 | + | requireActionBinding(t, pageMode.Actions, "cancel", []string{"escape"}) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestPageModeConfig_GetKeyBindings(t *testing.T) { | |||
| 31 | + | cfg := DefaultConfig() | |||
| 32 | + | bindings := cfg.Workspace.PageMode.GetKeyBindings() | |||
| 33 | + | ||||
| 34 | + | assert.Equal(t, "page-scroll-left", bindings["h"]) | |||
| 35 | + | assert.Equal(t, "page-scroll-down", bindings["j"]) | |||
| 36 | + | assert.Equal(t, "page-scroll-up", bindings["k"]) | |||
| 37 | + | assert.Equal(t, "page-scroll-right", bindings["l"]) | |||
| 38 | + | assert.Equal(t, "page-scroll-down-fast", bindings["shift+j"]) | |||
| 39 | + | assert.Equal(t, "page-scroll-up-fast", bindings["shift+k"]) | |||
| 40 | + | assert.Equal(t, "confirm", bindings["enter"]) | |||
| 41 | + | assert.Equal(t, "cancel", bindings["escape"]) | |||
| 42 | + | assert.Len(t, bindings, 8) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestValidatePageMode_NonNegativeTimeout(t *testing.T) { | |||
| 46 | + | tests := []struct { | |||
| 47 | + | name string | |||
| 48 | + | mutate func(*Config) | |||
| 49 | + | want string | |||
| 50 | + | }{ | |||
| 51 | + | { | |||
| 52 | + | name: "negative timeout fails", | |||
| 53 | + | mutate: func(cfg *Config) { | |||
| 54 | + | cfg.Workspace.PageMode.TimeoutMilliseconds = -1 | |||
| 55 | + | }, | |||
| 56 | + | want: "workspace.page_mode.timeout_ms must be non-negative", | |||
| 57 | + | }, | |||
| 58 | + | { | |||
| 59 | + | name: "zero timeout ok", | |||
| 60 | + | mutate: func(_ *Config) { | |||
| 61 | + | }, | |||
| 62 | + | want: "", | |||
| 63 | + | }, | |||
| 64 | + | { | |||
| 65 | + | name: "positive timeout ok", | |||
| 66 | + | mutate: func(cfg *Config) { | |||
| 67 | + | cfg.Workspace.PageMode.TimeoutMilliseconds = 5000 | |||
| 68 | + | }, | |||
| 69 | + | want: "", | |||
| 70 | + | }, | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | for _, tt := range tests { | |||
| 74 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 75 | + | cfg := DefaultConfig() | |||
| 76 | + | tt.mutate(cfg) | |||
| 77 | + | err := validateConfig(cfg) | |||
| 78 | + | if tt.want == "" { | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | } else { | |||
| 81 | + | require.Error(t, err) | |||
| 82 | + | assert.Contains(t, err.Error(), tt.want) | |||
| 83 | + | } | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestValidatePageMode_NilActionsFail(t *testing.T) { | |||
| 89 | + | cfg := DefaultConfig() | |||
| 90 | + | cfg.Workspace.PageMode.Actions = nil | |||
| 91 | + | ||||
| 92 | + | err := validateConfig(cfg) | |||
| 93 | + | require.Error(t, err) | |||
| 94 | + | assert.Contains(t, err.Error(), "workspace.page_mode.actions cannot be empty") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestValidatePageMode_DuplicateKeyBindings(t *testing.T) { | |||
| 98 | + | cfg := DefaultConfig() | |||
| 99 | + | // Add a duplicate key binding that conflicts with an existing one. | |||
| 100 | + | action := "custom-action" | |||
| 101 | + | cfg.Workspace.PageMode.Actions[action] = ActionBinding{ | |||
| 102 | + | Keys: []string{"h"}, // "h" is already used by page-scroll-left | |||
| 103 | + | Desc: "Custom action", | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | err := validateConfig(cfg) | |||
| 107 | + | require.Error(t, err) | |||
| 108 | + | assert.Contains(t, err.Error(), "duplicate key binding 'h' found in page_mode actions") | |||
| 109 | + | assert.Contains(t, err.Error(), "page-scroll-left") | |||
| 110 | + | assert.Contains(t, err.Error(), action) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestValidatePageMode_EmptyActionKeysFail(t *testing.T) { | |||
| 114 | + | cfg := DefaultConfig() | |||
| 115 | + | // Override an action with no key bindings. | |||
| 116 | + | cfg.Workspace.PageMode.Actions = map[string]ActionBinding{ | |||
| 117 | + | "page-scroll-left": {Keys: []string{}, Desc: "No keys"}, | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | err := validateConfig(cfg) | |||
| 121 | + | require.Error(t, err) | |||
| 122 | + | assert.Contains(t, err.Error(), "workspace.page_mode.actions.page-scroll-left must have at least one key binding") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestValidateModalModeActions_RegressionTable(t *testing.T) { | |||
| 126 | + | tests := []struct { | |||
| 127 | + | name string | |||
| 128 | + | modePath string | |||
| 129 | + | timeout int | |||
| 130 | + | actions map[string]ActionBinding | |||
| 131 | + | want string | |||
| 132 | + | }{ | |||
| 133 | + | { | |||
| 134 | + | name: "page negative timeout", | |||
| 135 | + | modePath: "page_mode", | |||
| 136 | + | timeout: -1, | |||
| 137 | + | actions: map[string]ActionBinding{"page-scroll-left": {Keys: []string{"h"}}}, | |||
| 138 | + | want: "workspace.page_mode.timeout_ms must be non-negative", | |||
| 139 | + | }, | |||
| 140 | + | { | |||
| 141 | + | name: "pane empty actions", | |||
| 142 | + | modePath: "pane_mode", | |||
| 143 | + | timeout: 0, | |||
| 144 | + | actions: nil, | |||
| 145 | + | want: "workspace.pane_mode.actions cannot be empty", | |||
| 146 | + | }, | |||
| 147 | + | { | |||
| 148 | + | name: "tab empty keys", | |||
| 149 | + | modePath: "tab_mode", | |||
| 150 | + | timeout: 0, | |||
| 151 | + | actions: map[string]ActionBinding{"new-tab": {Keys: []string{}}}, | |||
| 152 | + | want: "workspace.tab_mode.actions.new-tab must have at least one key binding", | |||
| 153 | + | }, | |||
| 154 | + | { | |||
| 155 | + | name: "page duplicate vim binding", | |||
| 156 | + | modePath: "page_mode", | |||
| 157 | + | timeout: 0, | |||
| 158 | + | actions: map[string]ActionBinding{ | |||
| 159 | + | "page-scroll-left": {Keys: []string{"h"}}, | |||
| 160 | + | "page-scroll-right": {Keys: []string{"h"}}, | |||
| 161 | + | }, | |||
| 162 | + | want: "duplicate key binding 'h' found in page_mode actions", | |||
| 163 | + | }, | |||
| 164 | + | { | |||
| 165 | + | name: "page canonical vim set ok", | |||
| 166 | + | modePath: "page_mode", | |||
| 167 | + | timeout: 0, | |||
| 168 | + | actions: map[string]ActionBinding{ | |||
| 169 | + | "page-scroll-left": {Keys: []string{"h"}}, | |||
| 170 | + | "page-scroll-down": {Keys: []string{"j"}}, | |||
| 171 | + | "page-scroll-up": {Keys: []string{"k"}}, | |||
| 172 | + | "page-scroll-right": {Keys: []string{"l"}}, | |||
| 173 | + | "page-scroll-down-fast": {Keys: []string{"shift+j"}}, | |||
| 174 | + | "page-scroll-up-fast": {Keys: []string{"shift+k"}}, | |||
| 175 | + | "confirm": {Keys: []string{"enter"}}, | |||
| 176 | + | "cancel": {Keys: []string{"escape"}}, | |||
| 177 | + | }, | |||
| 178 | + | want: "", | |||
| 179 | + | }, | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | for _, tt := range tests { | |||
| 183 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 184 | + | errs := validateModalModeActions(tt.modePath, tt.timeout, tt.actions) | |||
| 185 | + | if tt.want == "" { | |||
| 186 | + | assert.Empty(t, errs) | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | require.NotEmpty(t, errs) | |||
| 190 | + | assert.Contains(t, errs[0], tt.want) | |||
| 191 | + | }) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestSchemaProvider_PageModeKeys(t *testing.T) { | |||
| 196 | + | provider := &SchemaProvider{} | |||
| 197 | + | schema := provider.GetSchema() | |||
| 198 | + | ||||
| 199 | + | var foundActivation, foundTimeout, foundActions bool | |||
| 200 | + | for _, key := range schema { | |||
| 201 | + | switch key.Key { | |||
| 202 | + | case "workspace.page_mode.activation_shortcut": | |||
| 203 | + | foundActivation = true | |||
| 204 | + | assert.Equal(t, "string", key.Type) | |||
| 205 | + | assert.Equal(t, "ctrl+y", key.Default) | |||
| 206 | + | assert.Equal(t, SectionWorkspace, key.Section) | |||
| 207 | + | case "workspace.page_mode.timeout_ms": | |||
| 208 | + | foundTimeout = true | |||
| 209 | + | assert.Equal(t, "int", key.Type) | |||
| 210 | + | assert.Equal(t, "0", key.Default) | |||
| 211 | + | assert.Equal(t, ">=0", key.Range) | |||
| 212 | + | assert.Equal(t, SectionWorkspace, key.Section) | |||
| 213 | + | case "workspace.page_mode.actions.<action>": | |||
| 214 | + | foundActions = true | |||
| 215 | + | assert.Equal(t, "[]string", key.Type) | |||
| 216 | + | assert.Equal(t, SectionWorkspace, key.Section) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | assert.True(t, foundActivation, "missing workspace.page_mode.activation_shortcut schema key") | |||
| 221 | + | assert.True(t, foundTimeout, "missing workspace.page_mode.timeout_ms schema key") | |||
| 222 | + | assert.True(t, foundActions, "missing workspace.page_mode.actions.<action> schema key") | |||
| 223 | + | } | |||
M internal/infrastructure/config/schema.go +3 -0
| @@ -24,6 +24,9 @@ type TabModeConfig = entity.TabModeConfig | |||||
| 24 | // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style). | 24 | // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style). | ||
| 25 | type ResizeModeConfig = entity.ResizeModeConfig | 25 | type ResizeModeConfig = entity.ResizeModeConfig | ||
| 26 | 26 | ||||
| 27 | + | // PageModeConfig defines modal behavior for page scrolling. | |||
| 28 | + | type PageModeConfig = entity.PageModeConfig | |||
| 29 | + | ||||
| 27 | // SessionModeConfig defines modal behavior for session management. | 30 | // SessionModeConfig defines modal behavior for session management. | ||
| 28 | type SessionModeConfig = entity.SessionModeConfig | 31 | type SessionModeConfig = entity.SessionModeConfig | ||
| 29 | 32 | ||||
M internal/infrastructure/config/schema_provider.go +23 -0
| @@ -472,6 +472,29 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||||
| 472 | Description: "Key bindings for tab mode actions", | 472 | Description: "Key bindings for tab mode actions", | ||
| 473 | Section: SectionWorkspace, | 473 | Section: SectionWorkspace, | ||
| 474 | }, | 474 | }, | ||
| 475 | + | // Page mode | |||
| 476 | + | { | |||
| 477 | + | Key: "workspace.page_mode.activation_shortcut", | |||
| 478 | + | Type: "string", | |||
| 479 | + | Default: defaults.Workspace.PageMode.ActivationShortcut, | |||
| 480 | + | Description: "Shortcut to enter page scroll mode", | |||
| 481 | + | Section: SectionWorkspace, | |||
| 482 | + | }, | |||
| 483 | + | { | |||
| 484 | + | Key: "workspace.page_mode.timeout_ms", | |||
| 485 | + | Type: "int", | |||
| 486 | + | Default: fmt.Sprintf("%d", defaults.Workspace.PageMode.TimeoutMilliseconds), | |||
| 487 | + | Description: "Page mode timeout in milliseconds (0 = no timeout)", | |||
| 488 | + | Range: ">=0", | |||
| 489 | + | Section: SectionWorkspace, | |||
| 490 | + | }, | |||
| 491 | + | { | |||
| 492 | + | Key: "workspace.page_mode.actions.<action>", | |||
| 493 | + | Type: "[]string", | |||
| 494 | + | Default: "(see defaults)", | |||
| 495 | + | Description: "Key bindings for page mode actions", | |||
| 496 | + | Section: SectionWorkspace, | |||
| 497 | + | }, | |||
| 475 | // Resize mode | 498 | // Resize mode | ||
| 476 | { | 499 | { | ||
| 477 | Key: "workspace.resize_mode.activation_shortcut", | 500 | Key: "workspace.resize_mode.activation_shortcut", | ||
M internal/infrastructure/config/schema_provider_test.go +1 -0
| @@ -146,6 +146,7 @@ func TestSchemaProviderCoversViperDefaults(t *testing.T) { | |||||
| 146 | "search_shortcuts": "search_shortcuts.<key>", | 146 | "search_shortcuts": "search_shortcuts.<key>", | ||
| 147 | "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>", | 147 | "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>", | ||
| 148 | "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>", | 148 | "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>", | ||
| 149 | + | "workspace.page_mode.actions": "workspace.page_mode.actions.<action>", | |||
| 149 | "workspace.resize_mode.actions": "workspace.resize_mode.actions.<action>", | 150 | "workspace.resize_mode.actions": "workspace.resize_mode.actions.<action>", | ||
| 150 | "workspace.floating_pane.profiles": "workspace.floating_pane.profiles.<name>", | 151 | "workspace.floating_pane.profiles": "workspace.floating_pane.profiles.<name>", | ||
| 151 | "session.session_mode.actions": "session.session_mode.actions.<action>", | 152 | "session.session_mode.actions": "session.session_mode.actions.<action>", | ||
M internal/infrastructure/config/validation.go +23 -36
| @@ -28,6 +28,7 @@ func validateConfig(config *Config) error { | |||||
| 28 | validationErrors = append(validationErrors, validatePaneMode(config)...) | 28 | validationErrors = append(validationErrors, validatePaneMode(config)...) | ||
| 29 | validationErrors = append(validationErrors, validateTabBar(config)...) | 29 | validationErrors = append(validationErrors, validateTabBar(config)...) | ||
| 30 | validationErrors = append(validationErrors, validateTabMode(config)...) | 30 | validationErrors = append(validationErrors, validateTabMode(config)...) | ||
| 31 | + | validationErrors = append(validationErrors, validatePageMode(config)...) | |||
| 31 | validationErrors = append(validationErrors, validateFloatingPane(config)...) | 32 | validationErrors = append(validationErrors, validateFloatingPane(config)...) | ||
| 32 | validationErrors = append(validationErrors, validateLogging(config)...) | 33 | validationErrors = append(validationErrors, validateLogging(config)...) | ||
| 33 | validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...) | 34 | validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...) | ||
| @@ -202,32 +203,7 @@ func validateWorkspaceStyling(config *Config) []string { | |||||
| 202 | } | 203 | } | ||
| 203 | 204 | ||||
| 204 | func validatePaneMode(config *Config) []string { | 205 | func validatePaneMode(config *Config) []string { | ||
| 205 | - | var validationErrors []string | 206 | + | return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions) |
| 206 | - | if config.Workspace.PaneMode.TimeoutMilliseconds < 0 { | |||
| 207 | - | validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative") | |||
| 208 | - | } | |||
| 209 | - | if len(config.Workspace.PaneMode.Actions) == 0 { | |||
| 210 | - | validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty") | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | seenKeys := make(map[string]string) | |||
| 214 | - | for action, binding := range config.Workspace.PaneMode.Actions { | |||
| 215 | - | if len(binding.Keys) == 0 { | |||
| 216 | - | validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action)) | |||
| 217 | - | } | |||
| 218 | - | for _, key := range binding.Keys { | |||
| 219 | - | if existingAction, exists := seenKeys[key]; exists { | |||
| 220 | - | validationErrors = append(validationErrors, fmt.Sprintf( | |||
| 221 | - | "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", | |||
| 222 | - | key, | |||
| 223 | - | existingAction, | |||
| 224 | - | action, | |||
| 225 | - | )) | |||
| 226 | - | } | |||
| 227 | - | seenKeys[key] = action | |||
| 228 | - | } | |||
| 229 | - | } | |||
| 230 | - | return validationErrors | |||
| 231 | } | 207 | } | ||
| 232 | 208 | ||||
| 233 | func validateTabBar(config *Config) []string { | 209 | func validateTabBar(config *Config) []string { | ||
| @@ -240,29 +216,40 @@ func validateTabBar(config *Config) []string { | |||||
| 240 | } | 216 | } | ||
| 241 | 217 | ||||
| 242 | func validateTabMode(config *Config) []string { | 218 | func validateTabMode(config *Config) []string { | ||
| 219 | + | return validateModalModeActions("tab_mode", config.Workspace.TabMode.TimeoutMilliseconds, config.Workspace.TabMode.Actions) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func validatePageMode(config *Config) []string { | |||
| 223 | + | return validateModalModeActions("page_mode", config.Workspace.PageMode.TimeoutMilliseconds, config.Workspace.PageMode.Actions) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for | |||
| 227 | + | // pane, tab, and page modal modes. Messages stay path-exact via modePath. | |||
| 228 | + | func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string { | |||
| 243 | var validationErrors []string | 229 | var validationErrors []string | ||
| 244 | - | if config.Workspace.TabMode.TimeoutMilliseconds < 0 { | 230 | + | if timeoutMS < 0 { |
| 245 | - | validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative") | 231 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath)) |
| 246 | } | 232 | } | ||
| 247 | - | if len(config.Workspace.TabMode.Actions) == 0 { | 233 | + | if len(actions) == 0 { |
| 248 | - | validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty") | 234 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) |
| 249 | } | 235 | } | ||
| 250 | 236 | ||||
| 251 | - | tabSeenKeys := make(map[string]string) | 237 | + | seenKeys := make(map[string]string) |
| 252 | - | for action, binding := range config.Workspace.TabMode.Actions { | 238 | + | for action, binding := range actions { |
| 253 | if len(binding.Keys) == 0 { | 239 | if len(binding.Keys) == 0 { | ||
| 254 | - | validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action)) | 240 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action)) |
| 255 | } | 241 | } | ||
| 256 | for _, key := range binding.Keys { | 242 | for _, key := range binding.Keys { | ||
| 257 | - | if existingAction, exists := tabSeenKeys[key]; exists { | 243 | + | if existingAction, exists := seenKeys[key]; exists { |
| 258 | validationErrors = append(validationErrors, fmt.Sprintf( | 244 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 259 | - | "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", | 245 | + | "duplicate key binding '%s' found in %s actions '%s' and '%s'", |
| 260 | key, | 246 | key, | ||
| 247 | + | modePath, | |||
| 261 | existingAction, | 248 | existingAction, | ||
| 262 | action, | 249 | action, | ||
| 263 | )) | 250 | )) | ||
| 264 | } | 251 | } | ||
| 265 | - | tabSeenKeys[key] = action | 252 | + | seenKeys[key] = action |
| 266 | } | 253 | } | ||
| 267 | } | 254 | } | ||
| 268 | return validationErrors | 255 | return validationErrors | ||
A internal/infrastructure/webkit/editable_focus_token_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestNewEditableFocusBridgeToken_UsesRandomHex(t *testing.T) { | |||
| 12 | + | old := editableFocusTokenRand | |||
| 13 | + | t.Cleanup(func() { editableFocusTokenRand = old }) | |||
| 14 | + | editableFocusTokenRand = func(b []byte) (int, error) { | |||
| 15 | + | for i := range b { | |||
| 16 | + | b[i] = byte(i + 1) | |||
| 17 | + | } | |||
| 18 | + | return len(b), nil | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | token := newEditableFocusBridgeToken() | |||
| 22 | + | require.Equal(t, "0102030405060708090a0b0c0d0e0f10", token) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestNewEditableFocusBridgeToken_RandFailureReturnsEmpty(t *testing.T) { | |||
| 26 | + | old := editableFocusTokenRand | |||
| 27 | + | t.Cleanup(func() { editableFocusTokenRand = old }) | |||
| 28 | + | editableFocusTokenRand = func([]byte) (int, error) { | |||
| 29 | + | return 0, errors.New("entropy exhausted") | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | token := newEditableFocusBridgeToken() | |||
| 33 | + | require.Empty(t, token) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestEditableFocusBridgeToken_ConcurrentAccess(t *testing.T) { | |||
| 37 | + | t.Parallel() | |||
| 38 | + | wv := &WebView{} | |||
| 39 | + | wv.setEditableFocusBridgeToken("initial-token") | |||
| 40 | + | ||||
| 41 | + | var wg sync.WaitGroup | |||
| 42 | + | for range 32 { | |||
| 43 | + | wg.Add(2) | |||
| 44 | + | go func() { | |||
| 45 | + | defer wg.Done() | |||
| 46 | + | for range 200 { | |||
| 47 | + | _ = wv.EditableFocusBridgeToken() | |||
| 48 | + | _ = wv.matchesEditableFocusBridgeToken("initial-token") | |||
| 49 | + | } | |||
| 50 | + | }() | |||
| 51 | + | go func() { | |||
| 52 | + | defer wg.Done() | |||
| 53 | + | for i := range 200 { | |||
| 54 | + | wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken()) | |||
| 55 | + | if i%17 == 0 { | |||
| 56 | + | wv.setEditableFocusBridgeToken("initial-token") | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | }() | |||
| 60 | + | } | |||
| 61 | + | wg.Wait() | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestMatchesEditableFocusBridgeToken_RejectsEmpty(t *testing.T) { | |||
| 65 | + | wv := &WebView{} | |||
| 66 | + | wv.setEditableFocusBridgeToken("") | |||
| 67 | + | require.False(t, wv.matchesEditableFocusBridgeToken("")) | |||
| 68 | + | require.False(t, wv.matchesEditableFocusBridgeToken("anything")) | |||
| 69 | + | ||||
| 70 | + | wv.setEditableFocusBridgeToken("secret") | |||
| 71 | + | require.True(t, wv.matchesEditableFocusBridgeToken("secret")) | |||
| 72 | + | require.False(t, wv.matchesEditableFocusBridgeToken("other")) | |||
| 73 | + | require.False(t, wv.matchesEditableFocusBridgeToken("")) | |||
| 74 | + | } | |||
M internal/infrastructure/webkit/injector.go +61 -17
| @@ -182,28 +182,34 @@ const explicitCopyScript = `(function() { | |||||
| 182 | // accentDetectionScript is built at init from entity.AccentMap so the JS | 182 | // accentDetectionScript is built at init from entity.AccentMap so the JS | ||
| 183 | // filter stays in sync with the Go-side accent table. | 183 | // filter stays in sync with the Go-side accent table. | ||
| 184 | var accentDetectionScript string | 184 | var accentDetectionScript string | ||
| 185 | + | var accentDetectionScriptKeys string | |||
| 185 | 186 | ||||
| 186 | func buildExplicitCopyScript() string { | 187 | func buildExplicitCopyScript() string { | ||
| 187 | return explicitCopyScript | 188 | return explicitCopyScript | ||
| 188 | } | 189 | } | ||
| 189 | 190 | ||||
| 190 | - | func init() { | 191 | + | func buildAccentDetectionScript(token string) string { |
| 191 | - | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | 192 | + | return fmt.Sprintf(`(function() { |
| 192 | - | keys := make([]string, 0, len(entity.AccentMap)) | |||
| 193 | - | for k := range entity.AccentMap { | |||
| 194 | - | keys = append(keys, fmt.Sprintf("'%c'", k)) | |||
| 195 | - | } | |||
| 196 | - | sort.Strings(keys) | |||
| 197 | - | ||||
| 198 | - | accentDetectionScript = fmt.Sprintf(`(function() { | |||
| 199 | 'use strict'; | 193 | 'use strict'; | ||
| 200 | const accentKeys = new Set([%s]); | 194 | const accentKeys = new Set([%s]); | ||
| 195 | + | const editableFocusToken = %q; | |||
| 201 | let pressedKey = null; | 196 | let pressedKey = null; | ||
| 202 | 197 | ||||
| 198 | + | function postEditableFocus(editable) { | |||
| 199 | + | if (!editableFocusToken) return; | |||
| 200 | + | if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return; | |||
| 201 | + | window.webkit.messageHandlers.dumber.postMessage({ | |||
| 202 | + | type: 'editable_focus_changed', | |||
| 203 | + | payload: { editable: editable, token: editableFocusToken } | |||
| 204 | + | }); | |||
| 205 | + | } | |||
| 206 | + | ||||
| 203 | function isEditableTarget(target) { | 207 | function isEditableTarget(target) { | ||
| 204 | const el = target instanceof Element ? target : null; | 208 | const el = target instanceof Element ? target : null; | ||
| 205 | if (!el) return false; | 209 | if (!el) return false; | ||
| 206 | - | if (el.closest('[contenteditable=""], [contenteditable="true"]')) return true; | 210 | + | if (el.isContentEditable) return true; |
| 211 | + | const editableAncestor = el.closest('[contenteditable]'); | |||
| 212 | + | if (editableAncestor && editableAncestor.isContentEditable) return true; | |||
| 207 | if (el.closest('textarea')) return true; | 213 | if (el.closest('textarea')) return true; | ||
| 208 | const input = el.closest('input'); | 214 | const input = el.closest('input'); | ||
| 209 | if (!input) return false; | 215 | if (!input) return false; | ||
| @@ -234,12 +240,44 @@ func init() { | |||||
| 234 | } | 240 | } | ||
| 235 | }, true); | 241 | }, true); | ||
| 236 | 242 | ||||
| 237 | - | document.addEventListener('focusin', function(e) { | 243 | + | document.addEventListener('focusin', function(e) { |
| 238 | - | if (isEditableTarget(e.target)) { | 244 | + | if (e && e.isTrusted === false) return; |
| 239 | - | window.__dumber_lastEditableEl = e.target; | 245 | + | if (isEditableTarget(e.target)) { |
| 240 | - | } | 246 | + | window.__dumber_lastEditableEl = e.target; |
| 241 | - | }, true); | 247 | + | postEditableFocus(true); |
| 242 | - | })();`, strings.Join(keys, ",")) | 248 | + | return; |
| 249 | + | } | |||
| 250 | + | postEditableFocus(false); | |||
| 251 | + | }, true); | |||
| 252 | + | ||||
| 253 | + | document.addEventListener('focusout', function(e) { | |||
| 254 | + | if (e && e.isTrusted === false) return; | |||
| 255 | + | if (!isEditableTarget(e.target)) return; | |||
| 256 | + | setTimeout(function() { | |||
| 257 | + | if (!isEditableTarget(document.activeElement)) { | |||
| 258 | + | postEditableFocus(false); | |||
| 259 | + | } | |||
| 260 | + | }, 0); | |||
| 261 | + | }, true); | |||
| 262 | + | ||||
| 263 | + | if (isEditableTarget(document.activeElement)) { | |||
| 264 | + | window.__dumber_lastEditableEl = document.activeElement; | |||
| 265 | + | postEditableFocus(true); | |||
| 266 | + | } else { | |||
| 267 | + | postEditableFocus(false); | |||
| 268 | + | } | |||
| 269 | + | })();`, accentDetectionScriptKeys, token) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func init() { | |||
| 273 | + | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | |||
| 274 | + | keys := make([]string, 0, len(entity.AccentMap)) | |||
| 275 | + | for k := range entity.AccentMap { | |||
| 276 | + | keys = append(keys, fmt.Sprintf("'%c'", k)) | |||
| 277 | + | } | |||
| 278 | + | sort.Strings(keys) | |||
| 279 | + | accentDetectionScriptKeys = strings.Join(keys, ",") | |||
| 280 | + | accentDetectionScript = buildAccentDetectionScript("") | |||
| 243 | } | 281 | } | ||
| 244 | 282 | ||||
| 245 | // accentDetectionInjectionMode controls which frames receive the accent detection script. | 283 | // accentDetectionInjectionMode controls which frames receive the accent detection script. | ||
| @@ -449,9 +487,15 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 449 | 487 | ||||
| 450 | // 8. Inject accent key detection for all pages and all frames (unconditional). | 488 | // 8. Inject accent key detection for all pages and all frames (unconditional). | ||
| 451 | // JS only reports keydown/keyup events; Go handles timing and picker display. | 489 | // JS only reports keydown/keyup events; Go handles timing and picker display. | ||
| 490 | + | accentScript := accentDetectionScript | |||
| 491 | + | if wv := LookupWebView(webviewID); wv != nil { | |||
| 492 | + | if token := wv.EditableFocusBridgeToken(); token != "" { | |||
| 493 | + | accentScript = buildAccentDetectionScript(token) | |||
| 494 | + | } | |||
| 495 | + | } | |||
| 452 | addScript( | 496 | addScript( | ||
| 453 | webkit.NewUserScript( | 497 | webkit.NewUserScript( | ||
| 454 | - | accentDetectionScript, | 498 | + | accentScript, |
| 455 | accentDetectionInjectionMode, | 499 | accentDetectionInjectionMode, | ||
| 456 | webkit.UserScriptInjectAtDocumentEndValue, | 500 | webkit.UserScriptInjectAtDocumentEndValue, | ||
| 457 | nil, // all pages | 501 | nil, // all pages | ||
M internal/infrastructure/webkit/injector_accent_test.go +24 -0
| @@ -29,6 +29,30 @@ func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) { | |||||
| 29 | "document.addEventListener('focusin'", | 29 | "document.addEventListener('focusin'", | ||
| 30 | "accent detection script must register a focusin listener", | 30 | "accent detection script must register a focusin listener", | ||
| 31 | ) | 31 | ) | ||
| 32 | + | assert.Contains(t, accentDetectionScript, "el.isContentEditable") | |||
| 33 | + | assert.Contains(t, accentDetectionScript, "closest('[contenteditable]')") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) { | |||
| 37 | + | assert.Contains(t, accentDetectionScript, "editable_focus_changed") | |||
| 38 | + | assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'") | |||
| 39 | + | assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`) | |||
| 40 | + | assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`) | |||
| 41 | + | assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`) | |||
| 42 | + | assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`) | |||
| 43 | + | assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`) | |||
| 44 | + | assert.Contains(t, accentDetectionScript, `document.activeElement`) | |||
| 45 | + | assert.Contains(t, accentDetectionScript, `window.__dumber_lastEditableEl = document.activeElement`) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestBuildAccentDetectionScript_EmptyTokenReturnsBeforeHandlerLookup(t *testing.T) { | |||
| 49 | + | empty := buildAccentDetectionScript("") | |||
| 50 | + | assert.Contains(t, empty, "if (!editableFocusToken) return;") | |||
| 51 | + | nonEmpty := buildAccentDetectionScript("abc123") | |||
| 52 | + | assert.Contains(t, nonEmpty, `const editableFocusToken = "abc123";`) | |||
| 53 | + | assert.Contains(t, nonEmpty, "if (!editableFocusToken) return;") | |||
| 54 | + | assert.Contains(t, nonEmpty, `postEditableFocus(true)`) | |||
| 55 | + | assert.Contains(t, nonEmpty, "window.webkit.messageHandlers.dumber.postMessage") | |||
| 32 | } | 56 | } | ||
| 33 | 57 | ||||
| 34 | func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | 58 | func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | ||
M internal/infrastructure/webkit/messaging.go +66 -26
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/logging" | 11 | "github.com/bnema/dumber/internal/logging" | ||
| 12 | "github.com/bnema/puregotk/v4/javascriptcore" | 12 | "github.com/bnema/puregotk/v4/javascriptcore" | ||
| 13 | "github.com/bnema/puregotk/v4/webkit" | 13 | "github.com/bnema/puregotk/v4/webkit" | ||
| 14 | + | "github.com/rs/zerolog" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | // Message is the JS -> Go message envelope sent via postMessage. | 17 | // Message is the JS -> Go message envelope sent via postMessage. | ||
| @@ -187,32 +188,8 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 187 | 188 | ||||
| 188 | log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry") | 189 | log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry") | ||
| 189 | 190 | ||||
| 190 | - | if valuePtr == 0 { | 191 | + | msg, ok := decodeScriptMessage(valuePtr, log) |
| 191 | - | log.Warn().Msg("received script message with nil value pointer") | 192 | + | if !ok { |
| 192 | - | return | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr) | |||
| 196 | - | if jscValue == nil { | |||
| 197 | - | log.Warn().Msg("failed to wrap script message JSC value") | |||
| 198 | - | return | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | rawJSON := jscValue.ToJson(0) | |||
| 202 | - | log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result") | |||
| 203 | - | if rawJSON == "" { | |||
| 204 | - | // This typically happens when the JS side sends non-serializable values | |||
| 205 | - | // like Svelte 5 Proxy objects or functions. The frontend should use | |||
| 206 | - | // JSON.parse(JSON.stringify(obj)) to convert Proxies to plain objects. | |||
| 207 | - | log.Error(). | |||
| 208 | - | Msg("script message JSON serialization failed (ToJson returned empty) - " + | |||
| 209 | - | "this usually means JS sent non-serializable values like Proxy objects") | |||
| 210 | - | return | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | var msg Message | |||
| 214 | - | if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil { | |||
| 215 | - | log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message") | |||
| 216 | return | 193 | return | ||
| 217 | } | 194 | } | ||
| 218 | 195 | ||||
| @@ -221,6 +198,9 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 221 | log.Warn().Msg("rejecting script message from unknown sender") | 198 | log.Warn().Msg("rejecting script message from unknown sender") | ||
| 222 | return | 199 | return | ||
| 223 | } | 200 | } | ||
| 201 | + | if r.handleAllowlistedBridgeMessage(senderWV, msg) { | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 224 | if !isTrustedBridgeWebView(senderWV) { | 204 | if !isTrustedBridgeWebView(senderWV) { | ||
| 225 | log.Warn(). | 205 | log.Warn(). | ||
| 226 | Str("type", msg.Type). | 206 | Str("type", msg.Type). | ||
| @@ -279,6 +259,66 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 279 | } | 259 | } | ||
| 280 | } | 260 | } | ||
| 281 | 261 | ||||
| 262 | + | func decodeScriptMessage(valuePtr uintptr, log zerolog.Logger) (Message, bool) { | |||
| 263 | + | if valuePtr == 0 { | |||
| 264 | + | log.Warn().Msg("received script message with nil value pointer") | |||
| 265 | + | return Message{}, false | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr) | |||
| 269 | + | if jscValue == nil { | |||
| 270 | + | log.Warn().Msg("failed to wrap script message JSC value") | |||
| 271 | + | return Message{}, false | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | rawJSON := jscValue.ToJson(0) | |||
| 275 | + | log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result") | |||
| 276 | + | if rawJSON == "" { | |||
| 277 | + | log.Error(). | |||
| 278 | + | Msg("script message JSON serialization failed (ToJson returned empty) - " + | |||
| 279 | + | "this usually means JS sent non-serializable values like Proxy objects") | |||
| 280 | + | return Message{}, false | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | var msg Message | |||
| 284 | + | if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil { | |||
| 285 | + | log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message") | |||
| 286 | + | return Message{}, false | |||
| 287 | + | } | |||
| 288 | + | return msg, true | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // handleAllowlistedBridgeMessage handles the one narrow page-origin hint that | |||
| 292 | + | // is allowed before trusted-page routing: editable focus state for Page mode. | |||
| 293 | + | // This is intentionally not a privileged bridge command; the only effect is | |||
| 294 | + | // updating editable-focus state on the sender WebView so Page mode can block or | |||
| 295 | + | // exit locally. The per-WebView token is a reuse/staleness guard, not a broad | |||
| 296 | + | // authorization boundary for page privileges. | |||
| 297 | + | func (r *MessageRouter) handleAllowlistedBridgeMessage(senderWV *WebView, msg Message) bool { | |||
| 298 | + | if msg.Type != "editable_focus_changed" { | |||
| 299 | + | return false | |||
| 300 | + | } | |||
| 301 | + | if senderWV == nil { | |||
| 302 | + | return true | |||
| 303 | + | } | |||
| 304 | + | var payload struct { | |||
| 305 | + | Editable bool `json:"editable"` | |||
| 306 | + | Token string `json:"token"` | |||
| 307 | + | } | |||
| 308 | + | if len(msg.Payload) != 0 { | |||
| 309 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 310 | + | logging.FromContext(r.baseCtx).Warn().Err(err).Str("type", msg.Type).Msg("failed to decode allowlisted bridge payload") | |||
| 311 | + | return true | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | if payload.Token == "" || !senderWV.matchesEditableFocusBridgeToken(payload.Token) { | |||
| 315 | + | logging.FromContext(r.baseCtx).Warn().Str("type", msg.Type).Msg("rejecting allowlisted bridge message with invalid token") | |||
| 316 | + | return true | |||
| 317 | + | } | |||
| 318 | + | senderWV.dispatchEditableFocusChanged(payload.Editable) | |||
| 319 | + | return true | |||
| 320 | + | } | |||
| 321 | + | ||||
| 282 | func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView { | 322 | func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView { | ||
| 283 | ptr := senderUCM.GoPointer() | 323 | ptr := senderUCM.GoPointer() | ||
| 284 | if ptr == 0 { | 324 | if ptr == 0 { | ||
M internal/infrastructure/webkit/messaging_test.go +53 -0
| @@ -2,9 +2,11 @@ package webkit | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| @@ -35,6 +37,57 @@ func TestMessageRouterBaseContextConcurrentUpdate(t *testing.T) { | |||||
| 35 | wg.Wait() | 37 | wg.Wait() | ||
| 36 | } | 38 | } | ||
| 37 | 39 | ||||
| 40 | + | func TestHandleAllowlistedEditableFocusMessage_AllowsUntrustedPage(t *testing.T) { | |||
| 41 | + | router := NewMessageRouter(context.Background()) | |||
| 42 | + | wv := &WebView{uri: "https://example.com", editableFocusBridgeToken: "secret-token"} | |||
| 43 | + | ||||
| 44 | + | var states []bool | |||
| 45 | + | wv.SetCallbacks(&port.WebViewCallbacks{ | |||
| 46 | + | OnEditableFocusChanged: func(editable bool) { | |||
| 47 | + | states = append(states, editable) | |||
| 48 | + | }, | |||
| 49 | + | }) | |||
| 50 | + | ||||
| 51 | + | handled := router.handleAllowlistedBridgeMessage(wv, Message{ | |||
| 52 | + | Type: "editable_focus_changed", | |||
| 53 | + | Payload: json.RawMessage(`{"editable":true,"token":"secret-token"}`), | |||
| 54 | + | }) | |||
| 55 | + | ||||
| 56 | + | require.True(t, handled) | |||
| 57 | + | require.Equal(t, []bool{true}, states) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestHandleAllowlistedEditableFocusMessage_IsNoopAfterPoolReuse(t *testing.T) { | |||
| 61 | + | router := NewMessageRouter(context.Background()) | |||
| 62 | + | wv := &WebView{uri: "https://example.com", editableFocusBridgeToken: "secret-token"} | |||
| 63 | + | wv.ResetForPoolReuse() | |||
| 64 | + | ||||
| 65 | + | // Re-register after reset so a stale token is the only reason handling is a noop. | |||
| 66 | + | called := false | |||
| 67 | + | wv.SetCallbacks(&port.WebViewCallbacks{ | |||
| 68 | + | OnEditableFocusChanged: func(bool) { | |||
| 69 | + | called = true | |||
| 70 | + | }, | |||
| 71 | + | }) | |||
| 72 | + | ||||
| 73 | + | handled := router.handleAllowlistedBridgeMessage(wv, Message{ | |||
| 74 | + | Type: "editable_focus_changed", | |||
| 75 | + | Payload: json.RawMessage(`{"editable":true,"token":"secret-token"}`), | |||
| 76 | + | }) | |||
| 77 | + | ||||
| 78 | + | require.True(t, handled) | |||
| 79 | + | require.False(t, called) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestResetForPoolReuse_RotatesEditableFocusBridgeToken(t *testing.T) { | |||
| 83 | + | wv := &WebView{editableFocusBridgeToken: "secret-token"} | |||
| 84 | + | ||||
| 85 | + | wv.ResetForPoolReuse() | |||
| 86 | + | ||||
| 87 | + | require.NotEmpty(t, wv.EditableFocusBridgeToken()) | |||
| 88 | + | require.NotEqual(t, "secret-token", wv.EditableFocusBridgeToken()) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 38 | func TestIsTrustedBridgeURI(t *testing.T) { | 91 | func TestIsTrustedBridgeURI(t *testing.T) { | ||
| 39 | t.Parallel() | 92 | t.Parallel() | ||
| 40 | 93 | ||||
M internal/infrastructure/webkit/pool.go +15 -0
| @@ -178,7 +178,15 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | // Ensure frontend is attached even if this WebView was pooled before injection was configured. | 180 | // Ensure frontend is attached even if this WebView was pooled before injection was configured. | ||
| 181 | + | alreadyAttached := wv.FrontendAttached() | |||
| 181 | _ = wv.AttachFrontend(ctx, p.injector, p.router) | 182 | _ = wv.AttachFrontend(ctx, p.injector, p.router) | ||
| 183 | + | // Reinjection is only for already-attached reuse after ResetForPoolReuse. | |||
| 184 | + | // First AttachFrontend already injected; avoid an immediate double inject. | |||
| 185 | + | if needsFrontendReinjectionOnAcquire(alreadyAttached) && p.injector != nil && wv.ucm != nil { | |||
| 186 | + | wv.ucm.RemoveAllScripts() | |||
| 187 | + | wv.ucm.RemoveAllStyleSheets() | |||
| 188 | + | p.injector.InjectScripts(ctx, wv.ucm, wv.ID()) | |||
| 189 | + | } | |||
| 182 | // Apply filters if available (may not have been applied during prewarm) | 190 | // Apply filters if available (may not have been applied during prewarm) | ||
| 183 | if p.filterApplier != nil { | 191 | if p.filterApplier != nil { | ||
| 184 | p.filterApplier.ApplyTo(ctx, wv.ucm) | 192 | p.filterApplier.ApplyTo(ctx, wv.ucm) | ||
| @@ -240,6 +248,13 @@ func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuP | |||||
| 240 | return wv != nil && pipeline != nil && len(wv.signalIDs) > 0 | 248 | return wv != nil && pipeline != nil && len(wv.signalIDs) > 0 | ||
| 241 | } | 249 | } | ||
| 242 | 250 | ||||
| 251 | + | // needsFrontendReinjectionOnAcquire is true when AttachFrontend already succeeded | |||
| 252 | + | // earlier, so pool acquire must wipe and reinject after reuse. First-time attach | |||
| 253 | + | // injects inside AttachFrontend and must not immediately reinject. | |||
| 254 | + | func needsFrontendReinjectionOnAcquire(alreadyAttached bool) bool { | |||
| 255 | + | return alreadyAttached | |||
| 256 | + | } | |||
| 257 | + | ||||
| 243 | func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool { | 258 | func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool { | ||
| 244 | return settings != nil && wv != nil && wv.inner != nil | 259 | return settings != nil && wv != nil && wv.inner != nil | ||
| 245 | } | 260 | } | ||
M internal/infrastructure/webkit/pool_policy_test.go +5 -0
| @@ -16,3 +16,8 @@ func TestResolveReusePolicy(t *testing.T) { | |||||
| 16 | t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe") | 16 | t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe") | ||
| 17 | assert.Equal(t, reusePolicySafe, resolveReusePolicy("")) | 17 | assert.Equal(t, reusePolicySafe, resolveReusePolicy("")) | ||
| 18 | } | 18 | } | ||
| 19 | + | ||||
| 20 | + | func TestNeedsFrontendReinjectionOnAcquire(t *testing.T) { | |||
| 21 | + | assert.False(t, needsFrontendReinjectionOnAcquire(false), "first AttachFrontend must not double-inject") | |||
| 22 | + | assert.True(t, needsFrontendReinjectionOnAcquire(true), "already-attached reuse must reinject") | |||
| 23 | + | } | |||
M internal/infrastructure/webkit/webview.go +142 -39
| @@ -2,6 +2,9 @@ package webkit | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "crypto/rand" | |||
| 6 | + | "crypto/subtle" | |||
| 7 | + | "encoding/hex" | |||
| 5 | "fmt" | 8 | "fmt" | ||
| 6 | "net/url" | 9 | "net/url" | ||
| 7 | "os" | 10 | "os" | ||
| @@ -16,6 +19,7 @@ import ( | |||||
| 16 | "github.com/bnema/dumber/internal/domain/entity" | 19 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 17 | urlutil "github.com/bnema/dumber/internal/domain/url" | 20 | urlutil "github.com/bnema/dumber/internal/domain/url" | ||
| 18 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 21 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 22 | + | "github.com/bnema/dumber/internal/infrastructure/webutil" | |||
| 19 | "github.com/bnema/dumber/internal/logging" | 23 | "github.com/bnema/dumber/internal/logging" | ||
| 20 | "github.com/bnema/puregotk/v4/gdk" | 24 | "github.com/bnema/puregotk/v4/gdk" | ||
| 21 | "github.com/bnema/puregotk/v4/gio" | 25 | "github.com/bnema/puregotk/v4/gio" | ||
| @@ -34,6 +38,26 @@ var _ port.Printer = (*WebView)(nil) | |||||
| 34 | var _ port.PopupLifecycleCapable = (*WebView)(nil) | 38 | var _ port.PopupLifecycleCapable = (*WebView)(nil) | ||
| 35 | var _ port.PopupFeatureResolver = (*WebView)(nil) | 39 | var _ port.PopupFeatureResolver = (*WebView)(nil) | ||
| 36 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | 40 | var _ port.OAuthCallbackCapable = (*WebView)(nil) | ||
| 41 | + | var _ port.PageScrollable = (*WebView)(nil) | |||
| 42 | + | ||||
| 43 | + | var buildPageScrollFallbackJS = webutil.BuildScrollByJS | |||
| 44 | + | var runPageScrollFallbackJS = func(ctx context.Context, wv *WebView, script string) { | |||
| 45 | + | wv.RunJavaScript(ctx, script) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // editableFocusTokenRand is the injectable CSPRNG seam for bridge tokens. | |||
| 49 | + | // Tests may override it; production uses crypto/rand.Read. | |||
| 50 | + | var editableFocusTokenRand = rand.Read | |||
| 51 | + | ||||
| 52 | + | const editableFocusBridgeTokenBytes = 16 | |||
| 53 | + | ||||
| 54 | + | func newEditableFocusBridgeToken() string { | |||
| 55 | + | buf := make([]byte, editableFocusBridgeTokenBytes) | |||
| 56 | + | if _, err := editableFocusTokenRand(buf); err != nil { | |||
| 57 | + | return "" | |||
| 58 | + | } | |||
| 59 | + | return hex.EncodeToString(buf) | |||
| 60 | + | } | |||
| 37 | 61 | ||||
| 38 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | 62 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | ||
| 39 | // Infrastructure layer uses the type defined in the application port. | 63 | // Infrastructure layer uses the type defined in the application port. | ||
| @@ -163,6 +187,7 @@ type WebView struct { | |||||
| 163 | OnEnterFullscreen func() bool // Return true to prevent fullscreen | 187 | OnEnterFullscreen func() bool // Return true to prevent fullscreen | ||
| 164 | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | 188 | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | ||
| 165 | OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops | 189 | OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops | ||
| 190 | + | OnEditableFocusChanged func(editable bool) // Called when page editable focus changes | |||
| 166 | OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving) | 191 | OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving) | ||
| 167 | OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) | 192 | OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) | ||
| 168 | browsingContextDecision dto.HostDecision | 193 | browsingContextDecision dto.HostDecision | ||
| @@ -179,8 +204,9 @@ type WebView struct { | |||||
| 179 | logger zerolog.Logger | 204 | logger zerolog.Logger | ||
| 180 | mu sync.RWMutex | 205 | mu sync.RWMutex | ||
| 181 | 206 | ||||
| 182 | - | frontendAttached atomic.Bool | 207 | + | frontendAttached atomic.Bool |
| 183 | - | navigationActive atomic.Bool | 208 | + | navigationActive atomic.Bool |
| 209 | + | editableFocusBridgeToken string | |||
| 184 | 210 | ||||
| 185 | // asyncCallbacks keeps references to async JS callbacks to prevent GC | 211 | // asyncCallbacks keeps references to async JS callbacks to prevent GC | ||
| 186 | asyncCallbacks []any | 212 | asyncCallbacks []any | ||
| @@ -346,11 +372,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan | |||||
| 346 | } | 372 | } | ||
| 347 | 373 | ||||
| 348 | wv := &WebView{ | 374 | wv := &WebView{ | ||
| 349 | - | inner: inner, | 375 | + | inner: inner, |
| 350 | - | ucm: inner.GetUserContentManager(), | 376 | + | ucm: inner.GetUserContentManager(), |
| 351 | - | logger: log.With().Str("component", "webview").Logger(), | 377 | + | logger: log.With().Str("component", "webview").Logger(), |
| 352 | - | signalIDs: make([]uintptr, 0, 4), | 378 | + | signalIDs: make([]uintptr, 0, 4), |
| 353 | - | runJSErrorStats: make(map[string]runJSErrorStat), | 379 | + | runJSErrorStats: make(map[string]runJSErrorStat), |
| 380 | + | editableFocusBridgeToken: newEditableFocusBridgeToken(), | |||
| 354 | } | 381 | } | ||
| 355 | 382 | ||||
| 356 | // Register in global registry | 383 | // Register in global registry | ||
| @@ -396,12 +423,13 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti | |||||
| 396 | Msg("related webview created, checking pointers") | 423 | Msg("related webview created, checking pointers") | ||
| 397 | 424 | ||||
| 398 | wv := &WebView{ | 425 | wv := &WebView{ | ||
| 399 | - | inner: inner, | 426 | + | inner: inner, |
| 400 | - | isRelated: true, // Shares web process with parent - must not terminate process on destroy | 427 | + | isRelated: true, // Shares web process with parent - must not terminate process on destroy |
| 401 | - | ucm: inner.GetUserContentManager(), | 428 | + | ucm: inner.GetUserContentManager(), |
| 402 | - | logger: log.With().Str("component", "webview-popup").Logger(), | 429 | + | logger: log.With().Str("component", "webview-popup").Logger(), |
| 403 | - | signalIDs: make([]uintptr, 0, 6), | 430 | + | signalIDs: make([]uintptr, 0, 6), |
| 404 | - | runJSErrorStats: make(map[string]runJSErrorStat), | 431 | + | runJSErrorStats: make(map[string]runJSErrorStat), |
| 432 | + | editableFocusBridgeToken: newEditableFocusBridgeToken(), | |||
| 405 | } | 433 | } | ||
| 406 | 434 | ||||
| 407 | wv.id = globalRegistry.register(wv) | 435 | wv.id = globalRegistry.register(wv) | ||
| @@ -448,37 +476,49 @@ func (wv *WebView) connectLoadChangedSignal() { | |||||
| 448 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | 476 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | ||
| 449 | uri := inner.GetUri() | 477 | uri := inner.GetUri() | ||
| 450 | title := inner.GetTitle() | 478 | title := inner.GetTitle() | ||
| 451 | - | 479 | + | progress := inner.GetEstimatedLoadProgress() | |
| 452 | - | wv.mu.Lock() | 480 | + | wv.handleLoadChanged(LoadEvent(event), uri, title, progress) |
| 453 | - | wv.uri = uri | |||
| 454 | - | wv.title = title | |||
| 455 | - | // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal | |||
| 456 | - | // which fires for both traditional navigation and SPA history.pushState() | |||
| 457 | - | wv.progress = inner.GetEstimatedLoadProgress() | |||
| 458 | - | ||||
| 459 | - | switch event { | |||
| 460 | - | case webkit.LoadStartedValue: | |||
| 461 | - | wv.navigationActive.Store(true) | |||
| 462 | - | wv.isLoading = true | |||
| 463 | - | wv.logger.Debug().Str("uri", uri).Msg("load started") | |||
| 464 | - | case webkit.LoadRedirectedValue: | |||
| 465 | - | wv.logger.Debug().Str("uri", uri).Msg("load redirected") | |||
| 466 | - | case webkit.LoadCommittedValue: | |||
| 467 | - | wv.logger.Debug().Str("uri", uri).Msg("load committed") | |||
| 468 | - | case webkit.LoadFinishedValue: | |||
| 469 | - | wv.isLoading = false | |||
| 470 | - | wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished") | |||
| 471 | - | } | |||
| 472 | - | wv.mu.Unlock() | |||
| 473 | - | ||||
| 474 | - | if wv.OnLoadChanged != nil { | |||
| 475 | - | wv.OnLoadChanged(LoadEvent(event)) | |||
| 476 | - | } | |||
| 477 | } | 481 | } | ||
| 478 | sigID := wv.inner.ConnectLoadChanged(&loadChangedCb) | 482 | sigID := wv.inner.ConnectLoadChanged(&loadChangedCb) | ||
| 479 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | 483 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | ||
| 480 | } | 484 | } | ||
| 481 | 485 | ||||
| 486 | + | // handleLoadChanged updates load state then notifies outside wv.mu so callbacks | |||
| 487 | + | // may re-enter URI()/State() without deadlocking. | |||
| 488 | + | func (wv *WebView) handleLoadChanged(event LoadEvent, uri, title string, progress float64) { | |||
| 489 | + | wv.mu.Lock() | |||
| 490 | + | wv.uri = uri | |||
| 491 | + | wv.title = title | |||
| 492 | + | // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal | |||
| 493 | + | // which fires for both traditional navigation and SPA history.pushState() | |||
| 494 | + | wv.progress = progress | |||
| 495 | + | ||||
| 496 | + | notifyEditableBlur := false | |||
| 497 | + | switch event { | |||
| 498 | + | case LoadStarted: | |||
| 499 | + | wv.navigationActive.Store(true) | |||
| 500 | + | wv.isLoading = true | |||
| 501 | + | wv.logger.Debug().Str("uri", uri).Msg("load started") | |||
| 502 | + | notifyEditableBlur = true | |||
| 503 | + | case LoadRedirected: | |||
| 504 | + | wv.logger.Debug().Str("uri", uri).Msg("load redirected") | |||
| 505 | + | case LoadCommitted: | |||
| 506 | + | wv.logger.Debug().Str("uri", uri).Msg("load committed") | |||
| 507 | + | case LoadFinished: | |||
| 508 | + | wv.isLoading = false | |||
| 509 | + | wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished") | |||
| 510 | + | } | |||
| 511 | + | wv.mu.Unlock() | |||
| 512 | + | ||||
| 513 | + | if notifyEditableBlur { | |||
| 514 | + | wv.dispatchEditableFocusChanged(false) | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | if wv.OnLoadChanged != nil { | |||
| 518 | + | wv.OnLoadChanged(event) | |||
| 519 | + | } | |||
| 520 | + | } | |||
| 521 | + | ||||
| 482 | func (wv *WebView) connectLoadFailedSignal() { | 522 | func (wv *WebView) connectLoadFailedSignal() { | ||
| 483 | loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool { | 523 | loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool { | ||
| 484 | wv.logger.Warn(). | 524 | wv.logger.Warn(). | ||
| @@ -1762,6 +1802,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||||
| 1762 | wv.OnEnterFullscreen = nil | 1802 | wv.OnEnterFullscreen = nil | ||
| 1763 | wv.OnLeaveFullscreen = nil | 1803 | wv.OnLeaveFullscreen = nil | ||
| 1764 | wv.OnAudioStateChanged = nil | 1804 | wv.OnAudioStateChanged = nil | ||
| 1805 | + | wv.OnEditableFocusChanged = nil | |||
| 1765 | return | 1806 | return | ||
| 1766 | } | 1807 | } | ||
| 1767 | 1808 | ||||
| @@ -1807,6 +1848,16 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||||
| 1807 | wv.OnEnterFullscreen = callbacks.OnEnterFullscreen | 1848 | wv.OnEnterFullscreen = callbacks.OnEnterFullscreen | ||
| 1808 | wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen | 1849 | wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen | ||
| 1809 | wv.OnAudioStateChanged = callbacks.OnAudioStateChanged | 1850 | wv.OnAudioStateChanged = callbacks.OnAudioStateChanged | ||
| 1851 | + | wv.OnEditableFocusChanged = callbacks.OnEditableFocusChanged | |||
| 1852 | + | } | |||
| 1853 | + | ||||
| 1854 | + | func (wv *WebView) dispatchEditableFocusChanged(editable bool) { | |||
| 1855 | + | if wv == nil || wv.destroyed.Load() { | |||
| 1856 | + | return | |||
| 1857 | + | } | |||
| 1858 | + | if wv.OnEditableFocusChanged != nil { | |||
| 1859 | + | wv.OnEditableFocusChanged(editable) | |||
| 1860 | + | } | |||
| 1810 | } | 1861 | } | ||
| 1811 | 1862 | ||||
| 1812 | // ShowDevTools opens the WebKit inspector/developer tools. | 1863 | // ShowDevTools opens the WebKit inspector/developer tools. | ||
| @@ -1999,6 +2050,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) { | |||||
| 1999 | wv.OnEnterFullscreen = nil | 2050 | wv.OnEnterFullscreen = nil | ||
| 2000 | wv.OnLeaveFullscreen = nil | 2051 | wv.OnLeaveFullscreen = nil | ||
| 2001 | wv.OnAudioStateChanged = nil | 2052 | wv.OnAudioStateChanged = nil | ||
| 2053 | + | wv.OnEditableFocusChanged = nil | |||
| 2002 | wv.OnLinkHover = nil | 2054 | wv.OnLinkHover = nil | ||
| 2003 | wv.OnWebProcessTerminated = nil | 2055 | wv.OnWebProcessTerminated = nil | ||
| 2004 | wv.OnPermissionRequest = nil | 2056 | wv.OnPermissionRequest = nil | ||
| @@ -2047,6 +2099,7 @@ func (wv *WebView) ResetForPoolReuse() { | |||||
| 2047 | return | 2099 | return | ||
| 2048 | } | 2100 | } | ||
| 2049 | wv.generation.Add(1) | 2101 | wv.generation.Add(1) | ||
| 2102 | + | wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken()) | |||
| 2050 | 2103 | ||||
| 2051 | // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView. | 2104 | // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView. | ||
| 2052 | // They will be reconnected when the WebView is re-acquired from the pool. | 2105 | // They will be reconnected when the WebView is re-acquired from the pool. | ||
| @@ -2064,6 +2117,7 @@ func (wv *WebView) ResetForPoolReuse() { | |||||
| 2064 | wv.OnEnterFullscreen = nil | 2117 | wv.OnEnterFullscreen = nil | ||
| 2065 | wv.OnLeaveFullscreen = nil | 2118 | wv.OnLeaveFullscreen = nil | ||
| 2066 | wv.OnAudioStateChanged = nil | 2119 | wv.OnAudioStateChanged = nil | ||
| 2120 | + | wv.OnEditableFocusChanged = nil | |||
| 2067 | wv.OnLinkHover = nil | 2121 | wv.OnLinkHover = nil | ||
| 2068 | wv.OnWebProcessTerminated = nil | 2122 | wv.OnWebProcessTerminated = nil | ||
| 2069 | wv.OnPermissionRequest = nil | 2123 | wv.OnPermissionRequest = nil | ||
| @@ -2376,3 +2430,52 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector | |||||
| 2376 | log.Debug().Msg("frontend assets attached to webview") | 2430 | log.Debug().Msg("frontend assets attached to webview") | ||
| 2377 | return nil | 2431 | return nil | ||
| 2378 | } | 2432 | } | ||
| 2433 | + | ||||
| 2434 | + | // ScrollPage scrolls from the viewport center, handing exhausted nested | |||
| 2435 | + | // scrollers off to their ancestors and then the document. CEF uses the same | |||
| 2436 | + | // shared directional DOM-target resolution once its browser is ready. | |||
| 2437 | + | // Implements port.PageScrollable. | |||
| 2438 | + | func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error { | |||
| 2439 | + | if wv.destroyed.Load() { | |||
| 2440 | + | return fmt.Errorf("webview %d is destroyed", wv.id) | |||
| 2441 | + | } | |||
| 2442 | + | js := buildPageScrollFallbackJS(request.FallbackDX, request.FallbackDY) | |||
| 2443 | + | runPageScrollFallbackJS(ctx, wv, js) | |||
| 2444 | + | return nil | |||
| 2445 | + | } | |||
| 2446 | + | ||||
| 2447 | + | // FrontendAttached reports whether AttachFrontend has already wired scripts/router. | |||
| 2448 | + | func (wv *WebView) FrontendAttached() bool { | |||
| 2449 | + | return wv != nil && wv.frontendAttached.Load() | |||
| 2450 | + | } | |||
| 2451 | + | ||||
| 2452 | + | // EditableFocusBridgeToken returns the current allowlisted bridge auth token. | |||
| 2453 | + | func (wv *WebView) EditableFocusBridgeToken() string { | |||
| 2454 | + | if wv == nil { | |||
| 2455 | + | return "" | |||
| 2456 | + | } | |||
| 2457 | + | wv.mu.RLock() | |||
| 2458 | + | defer wv.mu.RUnlock() | |||
| 2459 | + | return wv.editableFocusBridgeToken | |||
| 2460 | + | } | |||
| 2461 | + | ||||
| 2462 | + | func (wv *WebView) setEditableFocusBridgeToken(token string) { | |||
| 2463 | + | if wv == nil { | |||
| 2464 | + | return | |||
| 2465 | + | } | |||
| 2466 | + | wv.mu.Lock() | |||
| 2467 | + | wv.editableFocusBridgeToken = token | |||
| 2468 | + | wv.mu.Unlock() | |||
| 2469 | + | } | |||
| 2470 | + | ||||
| 2471 | + | // matchesEditableFocusBridgeToken compares a candidate token in constant time. | |||
| 2472 | + | func (wv *WebView) matchesEditableFocusBridgeToken(candidate string) bool { | |||
| 2473 | + | if wv == nil || candidate == "" { | |||
| 2474 | + | return false | |||
| 2475 | + | } | |||
| 2476 | + | token := wv.EditableFocusBridgeToken() | |||
| 2477 | + | if token == "" { | |||
| 2478 | + | return false | |||
| 2479 | + | } | |||
| 2480 | + | return subtle.ConstantTimeCompare([]byte(candidate), []byte(token)) == 1 | |||
| 2481 | + | } | |||
A internal/infrastructure/webkit/webview_load_changed_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHandleLoadChanged_LoadStartedDispatchesOutsideLock(t *testing.T) { | |||
| 11 | + | wv := &WebView{uri: "https://example.com/start", title: "start"} | |||
| 12 | + | var sawURI string | |||
| 13 | + | var sawStateURI string | |||
| 14 | + | done := make(chan struct{}) | |||
| 15 | + | wv.OnEditableFocusChanged = func(editable bool) { | |||
| 16 | + | require.False(t, editable) | |||
| 17 | + | // Same-goroutine re-entry would deadlock if wv.mu were still held. | |||
| 18 | + | sawURI = wv.URI() | |||
| 19 | + | state := wv.State() | |||
| 20 | + | sawStateURI = state.URI | |||
| 21 | + | close(done) | |||
| 22 | + | } | |||
| 23 | + | var loadEvents []LoadEvent | |||
| 24 | + | wv.OnLoadChanged = func(ev LoadEvent) { | |||
| 25 | + | loadEvents = append(loadEvents, ev) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | finished := make(chan struct{}) | |||
| 29 | + | go func() { | |||
| 30 | + | wv.handleLoadChanged(LoadStarted, "https://example.com/next", "next", 0.1) | |||
| 31 | + | close(finished) | |||
| 32 | + | }() | |||
| 33 | + | ||||
| 34 | + | select { | |||
| 35 | + | case <-done: | |||
| 36 | + | case <-time.After(2 * time.Second): | |||
| 37 | + | t.Fatal("editable focus callback deadlocked re-entering URI/State") | |||
| 38 | + | } | |||
| 39 | + | select { | |||
| 40 | + | case <-finished: | |||
| 41 | + | case <-time.After(2 * time.Second): | |||
| 42 | + | t.Fatal("handleLoadChanged did not finish") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | require.Equal(t, "https://example.com/next", sawURI) | |||
| 46 | + | require.Equal(t, "https://example.com/next", sawStateURI) | |||
| 47 | + | require.Equal(t, []LoadEvent{LoadStarted}, loadEvents) | |||
| 48 | + | require.True(t, wv.isLoading) | |||
| 49 | + | require.True(t, wv.navigationActive.Load()) | |||
| 50 | + | } | |||
M internal/infrastructure/webkit/webview_runjavascript_test.go +100 -0
| @@ -1,11 +1,15 @@ | |||||
| 1 | package webkit | 1 | package webkit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "errors" | 5 | "errors" | ||
| 6 | + | "fmt" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | "time" | 8 | "time" | ||
| 7 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 9 | ) | 13 | ) | ||
| 10 | 14 | ||||
| 11 | func TestClassifyRunJSEvaluateError(t *testing.T) { | 15 | func TestClassifyRunJSEvaluateError(t *testing.T) { | ||
| @@ -87,3 +91,99 @@ func TestShouldLogRunJSError_FatalAlwaysLogs(t *testing.T) { | |||||
| 87 | assert.True(t, shouldLog) | 91 | assert.True(t, shouldLog) | ||
| 88 | assert.Equal(t, uint64(2), count) | 92 | assert.Equal(t, uint64(2), count) | ||
| 89 | } | 93 | } | ||
| 94 | + | ||||
| 95 | + | func TestScrollPage_Destroyed_ReturnsError(t *testing.T) { | |||
| 96 | + | wv := &WebView{} | |||
| 97 | + | wv.destroyed.Store(true) | |||
| 98 | + | ||||
| 99 | + | err := wv.ScrollPage(context.Background(), port.PageScrollRequest{ | |||
| 100 | + | Command: port.PageScrollCommandDown, | |||
| 101 | + | FallbackDX: 0, | |||
| 102 | + | FallbackDY: 80, | |||
| 103 | + | }) | |||
| 104 | + | ||||
| 105 | + | require.Error(t, err) | |||
| 106 | + | assert.Contains(t, err.Error(), "destroyed") | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestScrollPage_UsesFallbackDeltasAndRunsJavaScript(t *testing.T) { | |||
| 110 | + | oldBuilder := buildPageScrollFallbackJS | |||
| 111 | + | oldRunner := runPageScrollFallbackJS | |||
| 112 | + | defer func() { | |||
| 113 | + | buildPageScrollFallbackJS = oldBuilder | |||
| 114 | + | runPageScrollFallbackJS = oldRunner | |||
| 115 | + | }() | |||
| 116 | + | ||||
| 117 | + | var gotDX, gotDY int | |||
| 118 | + | var gotScript string | |||
| 119 | + | var runCount int | |||
| 120 | + | buildPageScrollFallbackJS = func(dx, dy int) string { | |||
| 121 | + | gotDX, gotDY = dx, dy | |||
| 122 | + | return fmt.Sprintf("scroll(%d,%d)", dx, dy) | |||
| 123 | + | } | |||
| 124 | + | runPageScrollFallbackJS = func(_ context.Context, _ *WebView, script string) { | |||
| 125 | + | runCount++ | |||
| 126 | + | gotScript = script | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | wv := &WebView{ | |||
| 130 | + | uri: "https://example.com", | |||
| 131 | + | runJSErrorStats: make(map[string]runJSErrorStat), | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | err := wv.ScrollPage(context.Background(), port.PageScrollRequest{ | |||
| 135 | + | Command: port.PageScrollCommand(99), | |||
| 136 | + | FallbackDX: -12, | |||
| 137 | + | FallbackDY: 80, | |||
| 138 | + | }) | |||
| 139 | + | ||||
| 140 | + | require.NoError(t, err) | |||
| 141 | + | assert.Equal(t, -12, gotDX) | |||
| 142 | + | assert.Equal(t, 80, gotDY) | |||
| 143 | + | assert.Equal(t, "scroll(-12,80)", gotScript) | |||
| 144 | + | assert.Equal(t, 1, runCount) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestScrollPage_VariousRequestsForwardFallbackDeltas(t *testing.T) { | |||
| 148 | + | oldBuilder := buildPageScrollFallbackJS | |||
| 149 | + | oldRunner := runPageScrollFallbackJS | |||
| 150 | + | defer func() { | |||
| 151 | + | buildPageScrollFallbackJS = oldBuilder | |||
| 152 | + | runPageScrollFallbackJS = oldRunner | |||
| 153 | + | }() | |||
| 154 | + | ||||
| 155 | + | tests := []struct { | |||
| 156 | + | name string | |||
| 157 | + | req port.PageScrollRequest | |||
| 158 | + | }{ | |||
| 159 | + | {"zero request", port.PageScrollRequest{}}, | |||
| 160 | + | {"down", port.PageScrollRequest{Command: port.PageScrollCommandDown, FallbackDY: 80}}, | |||
| 161 | + | {"up", port.PageScrollRequest{Command: port.PageScrollCommandUp, FallbackDY: -80}}, | |||
| 162 | + | {"left", port.PageScrollRequest{Command: port.PageScrollCommandLeft, FallbackDX: -80}}, | |||
| 163 | + | {"right", port.PageScrollRequest{Command: port.PageScrollCommandRight, FallbackDX: 80}}, | |||
| 164 | + | {"up fast", port.PageScrollRequest{Command: port.PageScrollCommandUpFast, FallbackDY: -320}}, | |||
| 165 | + | {"down fast", port.PageScrollRequest{Command: port.PageScrollCommandDownFast, FallbackDY: 320}}, | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | for _, tt := range tests { | |||
| 169 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 170 | + | var gotDX, gotDY int | |||
| 171 | + | buildPageScrollFallbackJS = func(dx, dy int) string { | |||
| 172 | + | gotDX, gotDY = dx, dy | |||
| 173 | + | return "ok" | |||
| 174 | + | } | |||
| 175 | + | runPageScrollFallbackJS = func(_ context.Context, _ *WebView, script string) { | |||
| 176 | + | assert.Equal(t, "ok", script) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | wv := &WebView{ | |||
| 180 | + | uri: "https://example.com", | |||
| 181 | + | runJSErrorStats: make(map[string]runJSErrorStat), | |||
| 182 | + | } | |||
| 183 | + | err := wv.ScrollPage(context.Background(), tt.req) | |||
| 184 | + | require.NoError(t, err) | |||
| 185 | + | assert.Equal(t, tt.req.FallbackDX, gotDX) | |||
| 186 | + | assert.Equal(t, tt.req.FallbackDY, gotDY) | |||
| 187 | + | }) | |||
| 188 | + | } | |||
| 189 | + | } | |||
A internal/infrastructure/webutil/pagescroll.go +68 -0
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | package webutil | |||
| 2 | + | ||||
| 3 | + | import "fmt" | |||
| 4 | + | ||||
| 5 | + | // BuildScrollByJS returns one immediate JavaScript scroll step for the given | |||
| 6 | + | // CSS-pixel delta. It resolves the initial target under the viewport center, | |||
| 7 | + | // walks toward scrollable ancestors that can move in the requested direction, | |||
| 8 | + | // and hands off to the document when a nested scroller reaches its boundary. | |||
| 9 | + | // Cross-origin frame contents remain best-effort because elementFromPoint can | |||
| 10 | + | // only return the accessible frame element from the top-level document. | |||
| 11 | + | // | |||
| 12 | + | // The app-level Page Mode repeater owns held-key cadence; this helper performs | |||
| 13 | + | // no requestAnimationFrame loop of its own. | |||
| 14 | + | func BuildScrollByJS(dx, dy int) string { | |||
| 15 | + | return fmt.Sprintf(`(function(){ | |||
| 16 | + | var dx=%d,dy=%d,doc=document; | |||
| 17 | + | function hasScrollableOverflow(value){ | |||
| 18 | + | return value==='auto'||value==='scroll'||value==='overlay'; | |||
| 19 | + | } | |||
| 20 | + | function canScroll(el){ | |||
| 21 | + | if(!el)return false; | |||
| 22 | + | var style=window.getComputedStyle(el); | |||
| 23 | + | if(dy!==0){ | |||
| 24 | + | var overflowY=style.overflowY||style.overflow; | |||
| 25 | + | var maxTop=el.scrollHeight-el.clientHeight; | |||
| 26 | + | if(hasScrollableOverflow(overflowY)){ | |||
| 27 | + | if((dy<0&&el.scrollTop>0)||(dy>0&&el.scrollTop<maxTop))return true; | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | if(dx!==0){ | |||
| 31 | + | var overflowX=style.overflowX||style.overflow; | |||
| 32 | + | var maxLeft=el.scrollWidth-el.clientWidth; | |||
| 33 | + | if(hasScrollableOverflow(overflowX)){ | |||
| 34 | + | if((dx<0&&el.scrollLeft>0)||(dx>0&&el.scrollLeft<maxLeft))return true; | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | return false; | |||
| 38 | + | } | |||
| 39 | + | function scrollElement(el){ | |||
| 40 | + | if(!el)return false; | |||
| 41 | + | var beforeLeft=el.scrollLeft,beforeTop=el.scrollTop; | |||
| 42 | + | try{ | |||
| 43 | + | if(dx!==0)el.scrollLeft=beforeLeft+dx; | |||
| 44 | + | if(dy!==0)el.scrollTop=beforeTop+dy; | |||
| 45 | + | }catch(_){ | |||
| 46 | + | return false; | |||
| 47 | + | } | |||
| 48 | + | return el.scrollLeft!==beforeLeft||el.scrollTop!==beforeTop; | |||
| 49 | + | } | |||
| 50 | + | try{ | |||
| 51 | + | var node=typeof doc.elementFromPoint==='function'? | |||
| 52 | + | doc.elementFromPoint(window.innerWidth/2,window.innerHeight/2):null; | |||
| 53 | + | while(node&&node!==doc.body&&node!==doc.documentElement){ | |||
| 54 | + | if(canScroll(node)&&scrollElement(node))return; | |||
| 55 | + | node=node.parentElement; | |||
| 56 | + | } | |||
| 57 | + | var scroller=doc.scrollingElement||doc.documentElement; | |||
| 58 | + | if(scroller&&canScroll(scroller)&&scrollElement(scroller))return; | |||
| 59 | + | if(typeof window.scrollBy==='function'){ | |||
| 60 | + | window.scrollBy(dx,dy); | |||
| 61 | + | return; | |||
| 62 | + | } | |||
| 63 | + | if(typeof window.scrollTo==='function'){ | |||
| 64 | + | window.scrollTo(window.scrollX+dx,window.scrollY+dy); | |||
| 65 | + | } | |||
| 66 | + | }catch(e){} | |||
| 67 | + | })()`, dx, dy) | |||
| 68 | + | } | |||
A internal/infrastructure/webutil/pagescroll_test.go +147 -0
| @@ -0,0 +1,147 @@ | |||||
| 1 | + | package webutil | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestBuildScrollByJS_TargetsViewportCenterInsteadOfActiveElement(t *testing.T) { | |||
| 9 | + | js := BuildScrollByJS(0, 80) | |||
| 10 | + | ||||
| 11 | + | if !strings.Contains(js, "doc.elementFromPoint(window.innerWidth/2,window.innerHeight/2)") { | |||
| 12 | + | t.Fatalf("page scroll must resolve its initial target from the viewport center, got: %s", js) | |||
| 13 | + | } | |||
| 14 | + | if strings.Contains(js, "activeElement") { | |||
| 15 | + | t.Fatalf("page scroll must not use the legacy active-element target, got: %s", js) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestBuildScrollByJS_ContainsRequiredOperations(t *testing.T) { | |||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | dx int | |||
| 23 | + | dy int | |||
| 24 | + | }{ | |||
| 25 | + | {"down", 0, 80}, | |||
| 26 | + | {"up", 0, -80}, | |||
| 27 | + | {"left", -80, 0}, | |||
| 28 | + | {"right", 80, 0}, | |||
| 29 | + | {"upFast", 0, -320}, | |||
| 30 | + | {"downFast", 0, 320}, | |||
| 31 | + | } | |||
| 32 | + | required := []struct { | |||
| 33 | + | message string | |||
| 34 | + | snippets []string | |||
| 35 | + | }{ | |||
| 36 | + | {"JS must resolve the viewport-center element", []string{"elementFromPoint", "window.innerWidth/2", "window.innerHeight/2"}}, | |||
| 37 | + | {"JS must walk parentElement in a loop", []string{"parentElement"}}, | |||
| 38 | + | {"JS must reference document.scrollingElement", []string{"scrollingElement"}}, | |||
| 39 | + | {"JS must fall back to window.scrollBy", []string{"window.scrollBy"}}, | |||
| 40 | + | {"JS must use getComputedStyle", []string{"getComputedStyle"}}, | |||
| 41 | + | {"JS must check overflowY", []string{"overflowY"}}, | |||
| 42 | + | {"JS must check overflowX", []string{"overflowX"}}, | |||
| 43 | + | {"JS must use direct element scroll offsets", []string{"scrollLeft=beforeLeft+dx", "scrollTop=beforeTop+dy"}}, | |||
| 44 | + | {"JS must check vertical direction-specific remaining scroll", []string{"dy<0&&el.scrollTop>0", "dy>0&&el.scrollTop<maxTop"}}, | |||
| 45 | + | {"JS must check horizontal direction-specific remaining scroll", []string{"dx<0&&el.scrollLeft>0", "dx>0&&el.scrollLeft<maxLeft"}}, | |||
| 46 | + | {"JS must check scrollable overflow values", []string{"'scroll'", "'auto'", "'overlay'"}}, | |||
| 47 | + | {"JS must wrap in try/catch", []string{"try{", "catch(e)"}}, | |||
| 48 | + | {"JS must support immediate window scrolling", []string{"window.scrollBy(dx,dy)"}}, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | for _, tt := range tests { | |||
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 53 | + | js := BuildScrollByJS(tt.dx, tt.dy) | |||
| 54 | + | for _, check := range required { | |||
| 55 | + | for _, snippet := range check.snippets { | |||
| 56 | + | if !strings.Contains(js, snippet) { | |||
| 57 | + | t.Error(check.message) | |||
| 58 | + | break | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | if !strings.HasPrefix(js, "(function(){") || !strings.HasSuffix(js, "})()") { | |||
| 63 | + | t.Error("JS must be wrapped in an IIFE") | |||
| 64 | + | } | |||
| 65 | + | }) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestBuildScrollByJS_SpecificDelta(t *testing.T) { | |||
| 70 | + | js := BuildScrollByJS(0, 80) | |||
| 71 | + | if !strings.Contains(js, "var dx=0,dy=80") { | |||
| 72 | + | t.Errorf("expected dy=80 in JS variables, got: %s", js) | |||
| 73 | + | } | |||
| 74 | + | if strings.Contains(js, "requestAnimationFrame") { | |||
| 75 | + | t.Errorf("expected immediate fallback scroll step without requestAnimationFrame, got: %s", js) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | js = BuildScrollByJS(-80, 0) | |||
| 79 | + | if !strings.Contains(js, "var dx=-80,dy=0") { | |||
| 80 | + | t.Errorf("expected dx=-80 in JS variables, got: %s", js) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestBuildScrollByJS_HorizontalAxisCheck(t *testing.T) { | |||
| 85 | + | js := BuildScrollByJS(-80, 0) | |||
| 86 | + | if !strings.Contains(js, "clientWidth") || !strings.Contains(js, "scrollWidth") { | |||
| 87 | + | t.Error("horizontal scroll must check scrollWidth/clientWidth") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestBuildScrollByJS_VerticalAxisCheck(t *testing.T) { | |||
| 92 | + | js := BuildScrollByJS(0, 80) | |||
| 93 | + | if !strings.Contains(js, "clientHeight") || !strings.Contains(js, "scrollHeight") { | |||
| 94 | + | t.Error("vertical scroll must check scrollHeight/clientHeight") | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestBuildScrollByJS_NegativeDeltaMagnitude(t *testing.T) { | |||
| 99 | + | js := BuildScrollByJS(0, -320) | |||
| 100 | + | if !strings.Contains(js, "var dx=0,dy=-320") { | |||
| 101 | + | t.Errorf("expected dy=-320 in JS variables, got: %s", js) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestBuildScrollByJS_SyntacticallyValid(t *testing.T) { | |||
| 106 | + | tests := []struct{ dx, dy int }{{0, 80}, {0, -320}, {-80, 0}, {80, 0}, {0, 0}} | |||
| 107 | + | ||||
| 108 | + | for _, tt := range tests { | |||
| 109 | + | js := BuildScrollByJS(tt.dx, tt.dy) | |||
| 110 | + | ||||
| 111 | + | depth := 0 | |||
| 112 | + | for _, ch := range js { | |||
| 113 | + | switch ch { | |||
| 114 | + | case '(': | |||
| 115 | + | depth++ | |||
| 116 | + | case ')': | |||
| 117 | + | depth-- | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | if depth != 0 { | |||
| 121 | + | t.Errorf("dx=%d,dy=%d: unbalanced parentheses (depth=%d)", tt.dx, tt.dy, depth) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | depth = 0 | |||
| 125 | + | for _, ch := range js { | |||
| 126 | + | switch ch { | |||
| 127 | + | case '{': | |||
| 128 | + | depth++ | |||
| 129 | + | case '}': | |||
| 130 | + | depth-- | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | if depth != 0 { | |||
| 134 | + | t.Errorf("dx=%d,dy=%d: unbalanced braces (depth=%d)", tt.dx, tt.dy, depth) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestBuildScrollByJS_ZeroDeltaSafe(t *testing.T) { | |||
| 140 | + | js := BuildScrollByJS(0, 0) | |||
| 141 | + | if !strings.Contains(js, "var dx=0,dy=0") { | |||
| 142 | + | t.Errorf("expected zero deltas in JS variables, got: %s", js) | |||
| 143 | + | } | |||
| 144 | + | if !strings.Contains(js, "clientHeight") || !strings.Contains(js, "scrollHeight") { | |||
| 145 | + | t.Error("zero-delta script must still include vertical scroll checks") | |||
| 146 | + | } | |||
| 147 | + | } | |||
M internal/ui/app.go +362 -28
| @@ -52,6 +52,12 @@ const ( | |||||
| 52 | floatingPaneIDPrefix = "floating-pane:" | 52 | floatingPaneIDPrefix = "floating-pane:" | ||
| 53 | floatingSessionIDDefault = "default" | 53 | floatingSessionIDDefault = "default" | ||
| 54 | floatingPaneVisibleClass = "floating-pane-visible" | 54 | floatingPaneVisibleClass = "floating-pane-visible" | ||
| 55 | + | ||||
| 56 | + | // pageModePulseInterval is the minimum interval between page mode pulses. | |||
| 57 | + | // Held Page Mode scrolling now runs on its own smooth repeater, so the pulse | |||
| 58 | + | // is intentionally throttled well below scroll cadence to avoid GTK CSS churn | |||
| 59 | + | // becoming part of the perceived scroll jank. | |||
| 60 | + | pageModePulseInterval = 120 * time.Millisecond | |||
| 55 | ) | 61 | ) | ||
| 56 | 62 | ||||
| 57 | func gtkApplicationFlags() gio.ApplicationFlags { | 63 | func gtkApplicationFlags() gio.ApplicationFlags { | ||
| @@ -117,6 +123,9 @@ type App struct { | |||||
| 117 | // Focus tracking stays app-global. | 123 | // Focus tracking stays app-global. | ||
| 118 | focusMgr *focus.Manager | 124 | focusMgr *focus.Manager | ||
| 119 | 125 | ||||
| 126 | + | pageModePolicyUC *usecase.PageModePolicyUseCase | |||
| 127 | + | pageEditableFocusByPane map[entity.PaneID]bool | |||
| 128 | + | ||||
| 120 | resizeModeBorderTarget layout.Widget | 129 | resizeModeBorderTarget layout.Widget | ||
| 121 | 130 | ||||
| 122 | // Omnibox configuration (omnibox is created per workspace view) | 131 | // Omnibox configuration (omnibox is created per workspace view) | ||
| @@ -150,6 +159,12 @@ type App struct { | |||||
| 150 | // Accent picker for dead keys support | 159 | // Accent picker for dead keys support | ||
| 151 | accentFocusProvider port.FocusedInputProvider | 160 | accentFocusProvider port.FocusedInputProvider | ||
| 152 | 161 | ||||
| 162 | + | // Page mode pulse debounce - tracks last pulse time to skip rapid | |||
| 163 | + | // repeats during held-key scroll actions. Reset on page mode exit | |||
| 164 | + | // via handleModeChange. | |||
| 165 | + | pageModePulseLastTime time.Time | |||
| 166 | + | pageModePulseMu sync.Mutex | |||
| 167 | + | ||||
| 153 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | 168 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | ||
| 154 | deferredInitOnce sync.Once | 169 | deferredInitOnce sync.Once | ||
| 155 | deferredInitFn func() | 170 | deferredInitFn func() | ||
| @@ -192,15 +207,17 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 192 | ctx, cancel := context.WithCancelCause(deps.Ctx) | 207 | ctx, cancel := context.WithCancelCause(deps.Ctx) | ||
| 193 | 208 | ||||
| 194 | app := &App{ | 209 | app := &App{ | ||
| 195 | - | deps: deps, | 210 | + | deps: deps, |
| 196 | - | runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig), | 211 | + | runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig), |
| 197 | - | tabs: entity.NewTabList(), | 212 | + | tabs: entity.NewTabList(), |
| 198 | - | tabsUC: deps.TabsUC, | 213 | + | tabsUC: deps.TabsUC, |
| 199 | - | panesUC: deps.PanesUC, | 214 | + | panesUC: deps.PanesUC, |
| 200 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | 215 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), |
| 201 | - | windowForTab: make(map[entity.TabID]*browserWindow), | 216 | + | windowForTab: make(map[entity.TabID]*browserWindow), |
| 202 | - | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | 217 | + | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), |
| 203 | - | browserWindows: make(map[string]*browserWindow), | 218 | + | browserWindows: make(map[string]*browserWindow), |
| 219 | + | pageModePolicyUC: usecase.NewPageModePolicyUseCase(), | |||
| 220 | + | pageEditableFocusByPane: make(map[entity.PaneID]bool), | |||
| 204 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | 221 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | ||
| 205 | if fn != nil { | 222 | if fn != nil { | ||
| 206 | fn() | 223 | fn() | ||
| @@ -210,6 +227,9 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 210 | engine: deps.Engine, | 227 | engine: deps.Engine, | ||
| 211 | cancel: cancel, | 228 | cancel: cancel, | ||
| 212 | } | 229 | } | ||
| 230 | + | if deps.Theme != nil { | |||
| 231 | + | deps.Theme.SetTransitionDuration(app.runtimeConfigSnapshot().UI.Workspace.Styling.TransitionDuration) | |||
| 232 | + | } | |||
| 213 | var autoCopyConfig port.AutoCopyConfig | 233 | var autoCopyConfig port.AutoCopyConfig | ||
| 214 | var clipboardOrchestrator port.ClipboardTextOrchestrator | 234 | var clipboardOrchestrator port.ClipboardTextOrchestrator | ||
| 215 | if deps.Clipboard != nil { | 235 | if deps.Clipboard != nil { | ||
| @@ -814,6 +834,9 @@ func (a *App) wireSessionManagerShortcut() { | |||||
| 814 | return nil | 834 | return nil | ||
| 815 | } | 835 | } | ||
| 816 | bw.sessionManager.Toggle(ctx) | 836 | bw.sessionManager.Toggle(ctx) | ||
| 837 | + | if bw.sessionManager.IsVisible() { | |||
| 838 | + | a.handlePageModeFocusTrigger(ctx, bw, usecase.PageModePolicyTriggerOverlayFocus) | |||
| 839 | + | } | |||
| 817 | return nil | 840 | return nil | ||
| 818 | }) | 841 | }) | ||
| 819 | } | 842 | } | ||
| @@ -1065,13 +1088,17 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||||
| 1065 | } | 1088 | } | ||
| 1066 | return a.dispatchBrowserWindowAction(actionCtx, bw, action) | 1089 | return a.dispatchBrowserWindowAction(actionCtx, bw, action) | ||
| 1067 | }) | 1090 | }) | ||
| 1091 | + | bw.keyboardHandler.SetOnPageScrollLifecycle(func(scrollCtx context.Context, action input.Action, phase input.PageScrollPhase) error { | |||
| 1092 | + | a.activateBrowserWindow(bw) | |||
| 1093 | + | return a.kbDispatcher.DispatchPageScrollLifecycle(scrollCtx, action, phase) | |||
| 1094 | + | }) | |||
| 1068 | bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool { | 1095 | bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool { | ||
| 1069 | a.activateBrowserWindow(bw) | 1096 | a.activateBrowserWindow(bw) | ||
| 1070 | return a.handleGlobalEscape(escapeCtx) | 1097 | return a.handleGlobalEscape(escapeCtx) | ||
| 1071 | }) | 1098 | }) | ||
| 1072 | bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | 1099 | bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | ||
| 1073 | a.activateBrowserWindow(bw) | 1100 | a.activateBrowserWindow(bw) | ||
| 1074 | - | a.handleModeChange(ctx, from, to) | 1101 | + | a.handleModeChange(ctx, bw, from, to) |
| 1075 | }) | 1102 | }) | ||
| 1076 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | 1103 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | ||
| 1077 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | 1104 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | ||
| @@ -1099,6 +1126,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||||
| 1099 | 1126 | ||||
| 1100 | return input.RouteHandleShortcuts | 1127 | return input.RouteHandleShortcuts | ||
| 1101 | }) | 1128 | }) | ||
| 1129 | + | bw.keyboardHandler.SetPageModeActivationPassthrough(func() bool { | |||
| 1130 | + | return a.shouldBypassPageModeActivation(bw) | |||
| 1131 | + | }) | |||
| 1102 | bw.keyboardHandler.SetAccentHandler(a) | 1132 | bw.keyboardHandler.SetAccentHandler(a) | ||
| 1103 | bw.keyboardHandler.AttachTo(bw.mainWindow.Window()) | 1133 | bw.keyboardHandler.AttachTo(bw.mainWindow.Window()) | ||
| 1104 | 1134 | ||||
| @@ -1339,6 +1369,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 1339 | a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry)) | 1369 | a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry)) | ||
| 1340 | } | 1370 | } | ||
| 1341 | } | 1371 | } | ||
| 1372 | + | a.handlePageModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.PageModePolicyTriggerOmniboxFocus) | |||
| 1342 | }, | 1373 | }, | ||
| 1343 | OnFocusOut: func() { | 1374 | OnFocusOut: func() { | ||
| 1344 | // When omnibox loses focus, set WebView as the focused input | 1375 | // When omnibox loses focus, set WebView as the focused input | ||
| @@ -1377,6 +1408,7 @@ func (a *App) initFindBarConfig(ctx context.Context) { | |||||
| 1377 | a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry)) | 1408 | a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry)) | ||
| 1378 | } | 1409 | } | ||
| 1379 | } | 1410 | } | ||
| 1411 | + | a.handlePageModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.PageModePolicyTriggerFindBarFocus) | |||
| 1380 | }, | 1412 | }, | ||
| 1381 | OnFocusOut: func() { | 1413 | OnFocusOut: func() { | ||
| 1382 | // When find bar loses focus, set WebView as the focused input | 1414 | // When find bar loses focus, set WebView as the focused input | ||
| @@ -1400,6 +1432,9 @@ func (a *App) ToggleSessionManager(ctx context.Context) { | |||||
| 1400 | return | 1432 | return | ||
| 1401 | } | 1433 | } | ||
| 1402 | bw.sessionManager.Toggle(ctx) | 1434 | bw.sessionManager.Toggle(ctx) | ||
| 1435 | + | if bw.sessionManager.IsVisible() { | |||
| 1436 | + | a.handlePageModeFocusTrigger(ctx, bw, usecase.PageModePolicyTriggerOverlayFocus) | |||
| 1437 | + | } | |||
| 1403 | } | 1438 | } | ||
| 1404 | 1439 | ||||
| 1405 | func (a *App) attachTabPickerToActivePane() { | 1440 | func (a *App) attachTabPickerToActivePane() { | ||
| @@ -1498,6 +1533,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |||||
| 1498 | 1533 | ||||
| 1499 | a.attachTabPickerToActivePane() | 1534 | a.attachTabPickerToActivePane() | ||
| 1500 | bw.tabPicker.Show(ctx, items) | 1535 | bw.tabPicker.Show(ctx, items) | ||
| 1536 | + | a.handlePageModeFocusTrigger(ctx, bw, usecase.PageModePolicyTriggerOverlayFocus) | |||
| 1501 | return nil | 1537 | return nil | ||
| 1502 | } | 1538 | } | ||
| 1503 | 1539 | ||||
| @@ -3072,6 +3108,7 @@ func (a *App) initTabCoordinator(ctx context.Context) { | |||||
| 3072 | // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed. | 3108 | // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed. | ||
| 3073 | if bw := a.browserWindowForTabTarget(target); bw != nil { | 3109 | if bw := a.browserWindowForTabTarget(target); bw != nil { | ||
| 3074 | a.activateBrowserWindow(bw) | 3110 | a.activateBrowserWindow(bw) | ||
| 3111 | + | a.handlePageModeTabSwitch(ctx, bw) | |||
| 3075 | } | 3112 | } | ||
| 3076 | a.switchWorkspaceView(ctx, tab.ID) | 3113 | a.switchWorkspaceView(ctx, tab.ID) | ||
| 3077 | }) | 3114 | }) | ||
| @@ -3209,8 +3246,13 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3209 | a.deps.HistoryRecorderUC, | 3246 | a.deps.HistoryRecorderUC, | ||
| 3210 | a.contentCoord, | 3247 | a.contentCoord, | ||
| 3211 | ) | 3248 | ) | ||
| 3249 | + | // Wire the page scroll usecase so semantic scroll commands (left, right, | |||
| 3250 | + | // up, down, fast up, fast down) can be dispatched through the | |||
| 3251 | + | // NavigationCoordinator to the active WebView. | |||
| 3252 | + | a.navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase()) | |||
| 3212 | a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) { | 3253 | a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) { | ||
| 3213 | a.navCoord.ClearPaneHistory(paneID) | 3254 | a.navCoord.ClearPaneHistory(paneID) | ||
| 3255 | + | a.clearPageEditableFocusState(paneID) | |||
| 3214 | }) | 3256 | }) | ||
| 3215 | 3257 | ||||
| 3216 | // Wire title updates to history persistence | 3258 | // Wire title updates to history persistence | ||
| @@ -3220,6 +3262,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3220 | 3262 | ||||
| 3221 | // Wire history recording on LoadCommitted (URI is guaranteed correct at this point) | 3263 | // Wire history recording on LoadCommitted (URI is guaranteed correct at this point) | ||
| 3222 | a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) { | 3264 | a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) { | ||
| 3265 | + | a.clearPageEditableFocusState(paneID) | |||
| 3223 | a.navCoord.RecordHistory(ctx, paneID, url) | 3266 | a.navCoord.RecordHistory(ctx, paneID, url) | ||
| 3224 | }) | 3267 | }) | ||
| 3225 | 3268 | ||||
| @@ -3235,6 +3278,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3235 | // Mark dirty so snapshot captures the new URI | 3278 | // Mark dirty so snapshot captures the new URI | ||
| 3236 | a.MarkDirty() | 3279 | a.MarkDirty() | ||
| 3237 | }) | 3280 | }) | ||
| 3281 | + | a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) { | |||
| 3282 | + | a.handlePageEditableFocusChanged(ctx, paneID, editable) | |||
| 3283 | + | }) | |||
| 3238 | 3284 | ||||
| 3239 | // Hide loading skeleton once the WebView paints | 3285 | // Hide loading skeleton once the WebView paints | ||
| 3240 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | 3286 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | ||
| @@ -3347,6 +3393,9 @@ func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bo | |||||
| 3347 | 3393 | ||||
| 3348 | func (a *App) wireKeyboardActions() { | 3394 | func (a *App) wireKeyboardActions() { | ||
| 3349 | a.kbDispatcher.SetOnQuit(a.Quit) | 3395 | a.kbDispatcher.SetOnQuit(a.Quit) | ||
| 3396 | + | a.kbDispatcher.SetOnPageModePulse(func(ctx context.Context, fast bool) { | |||
| 3397 | + | a.triggerPageModePulse(ctx, fast) | |||
| 3398 | + | }) | |||
| 3350 | a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error { | 3399 | a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error { | ||
| 3351 | a.ToggleFindBar(ctx) | 3400 | a.ToggleFindBar(ctx) | ||
| 3352 | return nil | 3401 | return nil | ||
| @@ -3678,8 +3727,128 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |||||
| 3678 | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | 3727 | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | ||
| 3679 | } | 3728 | } | ||
| 3680 | 3729 | ||||
| 3681 | - | // handleModeChange is called when the input mode changes. | 3730 | + | func (a *App) pageModePolicy() *usecase.PageModePolicyUseCase { |
| 3682 | - | func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | 3731 | + | if a == nil { |
| 3732 | + | return usecase.NewPageModePolicyUseCase() | |||
| 3733 | + | } | |||
| 3734 | + | if a.pageModePolicyUC == nil { | |||
| 3735 | + | a.pageModePolicyUC = usecase.NewPageModePolicyUseCase() | |||
| 3736 | + | } | |||
| 3737 | + | return a.pageModePolicyUC | |||
| 3738 | + | } | |||
| 3739 | + | ||||
| 3740 | + | func (a *App) ensurePageEditableFocusMap() { | |||
| 3741 | + | if a != nil && a.pageEditableFocusByPane == nil { | |||
| 3742 | + | a.pageEditableFocusByPane = make(map[entity.PaneID]bool) | |||
| 3743 | + | } | |||
| 3744 | + | } | |||
| 3745 | + | ||||
| 3746 | + | func (a *App) pageEditableFocused(paneID entity.PaneID) bool { | |||
| 3747 | + | if a == nil || paneID == "" || a.pageEditableFocusByPane == nil { | |||
| 3748 | + | return false | |||
| 3749 | + | } | |||
| 3750 | + | return a.pageEditableFocusByPane[paneID] | |||
| 3751 | + | } | |||
| 3752 | + | ||||
| 3753 | + | func (a *App) setPageEditableFocused(paneID entity.PaneID, editable bool) { | |||
| 3754 | + | if a == nil || paneID == "" { | |||
| 3755 | + | return | |||
| 3756 | + | } | |||
| 3757 | + | a.ensurePageEditableFocusMap() | |||
| 3758 | + | if editable { | |||
| 3759 | + | a.pageEditableFocusByPane[paneID] = true | |||
| 3760 | + | return | |||
| 3761 | + | } | |||
| 3762 | + | delete(a.pageEditableFocusByPane, paneID) | |||
| 3763 | + | } | |||
| 3764 | + | ||||
| 3765 | + | func (a *App) clearPageEditableFocusState(paneID entity.PaneID) { | |||
| 3766 | + | a.setPageEditableFocused(paneID, false) | |||
| 3767 | + | } | |||
| 3768 | + | ||||
| 3769 | + | func (a *App) pageModeActiveForBrowserWindow(bw *browserWindow) bool { | |||
| 3770 | + | return bw != nil && bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModePage | |||
| 3771 | + | } | |||
| 3772 | + | ||||
| 3773 | + | func (a *App) applyPageModePolicyTransition(_ context.Context, bw *browserWindow, transition usecase.PageModePolicyTransition) { | |||
| 3774 | + | if transition != usecase.PageModePolicyTransitionExit || bw == nil || bw.keyboardHandler == nil { | |||
| 3775 | + | return | |||
| 3776 | + | } | |||
| 3777 | + | if bw.keyboardHandler.Mode() != input.ModePage { | |||
| 3778 | + | return | |||
| 3779 | + | } | |||
| 3780 | + | bw.keyboardHandler.ExitMode() | |||
| 3781 | + | } | |||
| 3782 | + | ||||
| 3783 | + | func (a *App) handlePageModeFocusTrigger(ctx context.Context, bw *browserWindow, trigger usecase.PageModePolicyTrigger) { | |||
| 3784 | + | if bw == nil { | |||
| 3785 | + | return | |||
| 3786 | + | } | |||
| 3787 | + | transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{ | |||
| 3788 | + | Trigger: trigger, | |||
| 3789 | + | PageModeActive: a.pageModeActiveForBrowserWindow(bw), | |||
| 3790 | + | }) | |||
| 3791 | + | a.applyPageModePolicyTransition(ctx, bw, transition) | |||
| 3792 | + | } | |||
| 3793 | + | ||||
| 3794 | + | func (a *App) handlePageModeTabSwitch(_ context.Context, bw *browserWindow) { | |||
| 3795 | + | if bw == nil || bw.tabs == nil || !a.pageModeActiveForBrowserWindow(bw) { | |||
| 3796 | + | return | |||
| 3797 | + | } | |||
| 3798 | + | transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{ | |||
| 3799 | + | Trigger: usecase.PageModePolicyTriggerContextChanged, | |||
| 3800 | + | PageModeActive: true, | |||
| 3801 | + | PreserveOnContextChange: false, | |||
| 3802 | + | }) | |||
| 3803 | + | if transition != usecase.PageModePolicyTransitionExit { | |||
| 3804 | + | return | |||
| 3805 | + | } | |||
| 3806 | + | if prevTabID := bw.tabs.PreviousActiveTabID; prevTabID != "" { | |||
| 3807 | + | if prevView := a.workspaceViews[prevTabID]; prevView != nil { | |||
| 3808 | + | if pv := prevView.GetPaneView(bw.pageModePaneID); pv != nil { | |||
| 3809 | + | pv.SetPageMode(false) | |||
| 3810 | + | } | |||
| 3811 | + | } | |||
| 3812 | + | } | |||
| 3813 | + | bw.pageModePaneID = "" | |||
| 3814 | + | bw.keyboardHandler.ExitMode() | |||
| 3815 | + | } | |||
| 3816 | + | ||||
| 3817 | + | func (a *App) handlePageEditableFocusChanged(ctx context.Context, paneID entity.PaneID, editable bool) { | |||
| 3818 | + | if paneID == "" { | |||
| 3819 | + | return | |||
| 3820 | + | } | |||
| 3821 | + | a.setPageEditableFocused(paneID, editable) | |||
| 3822 | + | ||||
| 3823 | + | bw := a.browserWindowForAnyPane(paneID) | |||
| 3824 | + | if bw == nil { | |||
| 3825 | + | return | |||
| 3826 | + | } | |||
| 3827 | + | ws := a.activeWorkspaceForBrowserWindow(bw) | |||
| 3828 | + | activeContext := ws != nil && ws.ActivePaneID == paneID && a.lastFocusedBrowserWindow() == bw | |||
| 3829 | + | transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{ | |||
| 3830 | + | Trigger: usecase.PageModePolicyTriggerPageEditableFocusChanged, | |||
| 3831 | + | PageModeActive: a.pageModeActiveForBrowserWindow(bw), | |||
| 3832 | + | PageEditableFocused: editable, | |||
| 3833 | + | EventInActiveContext: activeContext, | |||
| 3834 | + | }) | |||
| 3835 | + | a.applyPageModePolicyTransition(ctx, bw, transition) | |||
| 3836 | + | } | |||
| 3837 | + | ||||
| 3838 | + | func (a *App) shouldBypassPageModeActivation(bw *browserWindow) bool { | |||
| 3839 | + | ws := a.activeWorkspaceForBrowserWindow(bw) | |||
| 3840 | + | if ws == nil { | |||
| 3841 | + | return false | |||
| 3842 | + | } | |||
| 3843 | + | transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{ | |||
| 3844 | + | Trigger: usecase.PageModePolicyTriggerActivationAttempt, | |||
| 3845 | + | PageEditableFocused: a.pageEditableFocused(ws.ActivePaneID), | |||
| 3846 | + | }) | |||
| 3847 | + | return transition == usecase.PageModePolicyTransitionBlockActivation | |||
| 3848 | + | } | |||
| 3849 | + | ||||
| 3850 | + | // handleModeChange is called when the input mode changes for a specific browser window. | |||
| 3851 | + | func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to input.Mode) { | |||
| 3683 | log := logging.FromContext(ctx) | 3852 | log := logging.FromContext(ctx) | ||
| 3684 | log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed") | 3853 | log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed") | ||
| 3685 | 3854 | ||||
| @@ -3691,48 +3860,204 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||||
| 3691 | a.applyResizeModeBorder(ctx, a.activeWorkspace()) | 3860 | a.applyResizeModeBorder(ctx, a.activeWorkspace()) | ||
| 3692 | } | 3861 | } | ||
| 3693 | 3862 | ||||
| 3863 | + | // Handle pane-local Page Mode visual ownership. | |||
| 3864 | + | // Entering Page Mode accents the active pane; leaving removes the accent. | |||
| 3865 | + | a.handlePageModeOwnership(ctx, bw, to, from) | |||
| 3866 | + | ||||
| 3694 | // Update global border overlay visibility based on mode. | 3867 | // Update global border overlay visibility based on mode. | ||
| 3695 | // Note: resize mode border is handled per-pane (stack container), not via global overlay. | 3868 | // Note: resize mode border is handled per-pane (stack container), not via global overlay. | ||
| 3696 | - | if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil { | 3869 | + | // Page mode explicitly skips the global border overlay. |
| 3870 | + | if bw != nil && bw.borderMgr != nil { | |||
| 3697 | bw.borderMgr.OnModeChange(ctx, from, to) | 3871 | bw.borderMgr.OnModeChange(ctx, from, to) | ||
| 3698 | } | 3872 | } | ||
| 3699 | 3873 | ||||
| 3700 | - | // Show/hide mode indicator toaster based on config. | 3874 | + | // Show/hide this window's mode indicator toaster based on mode and config. |
| 3701 | - | a.updateModeIndicatorToaster(ctx, to) | 3875 | + | a.updateModeIndicatorToaster(ctx, bw, to) |
| 3702 | } | 3876 | } | ||
| 3703 | 3877 | ||||
| 3704 | - | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | 3878 | + | // transferPageModeOwnershipToPane transfers the pane-local Page Mode accent |
| 3705 | - | func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | 3879 | + | // from the current owning pane to another pane in the same browser window. |
| 3880 | + | // The transfer only happens while that window is in Page Mode. | |||
| 3881 | + | func (a *App) transferPageModeOwnershipToPane(ctx context.Context, bw *browserWindow, newPaneID entity.PaneID) { | |||
| 3882 | + | if bw == nil { | |||
| 3883 | + | return | |||
| 3884 | + | } | |||
| 3885 | + | ||||
| 3886 | + | // Only transfer if page mode is currently active and the owner is changing. | |||
| 3887 | + | if bw.pageModePaneID == "" || bw.pageModePaneID == newPaneID { | |||
| 3888 | + | return | |||
| 3889 | + | } | |||
| 3890 | + | ||||
| 3891 | + | // Check if this window is actually in page mode. | |||
| 3892 | + | if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModePage { | |||
| 3893 | + | // Page mode not active on this window; just clear stale ownership. | |||
| 3894 | + | bw.pageModePaneID = "" | |||
| 3895 | + | return | |||
| 3896 | + | } | |||
| 3897 | + | ||||
| 3898 | + | wsView := a.activeWorkspaceViewForBrowserWindow(bw) | |||
| 3899 | + | if wsView == nil { | |||
| 3900 | + | return | |||
| 3901 | + | } | |||
| 3902 | + | ||||
| 3903 | + | transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{ | |||
| 3904 | + | Trigger: usecase.PageModePolicyTriggerContextChanged, | |||
| 3905 | + | PageModeActive: true, | |||
| 3906 | + | PreserveOnContextChange: !a.pageEditableFocused(newPaneID), | |||
| 3907 | + | }) | |||
| 3908 | + | if transition == usecase.PageModePolicyTransitionExit { | |||
| 3909 | + | a.applyPageModePolicyTransition(ctx, bw, transition) | |||
| 3910 | + | return | |||
| 3911 | + | } | |||
| 3912 | + | ||||
| 3913 | + | oldPaneID := bw.pageModePaneID | |||
| 3914 | + | ||||
| 3915 | + | // Deactivate the old owning pane. | |||
| 3916 | + | if oldPV := wsView.GetPaneView(oldPaneID); oldPV != nil { | |||
| 3917 | + | oldPV.SetPageMode(false) | |||
| 3918 | + | } | |||
| 3919 | + | ||||
| 3920 | + | // Activate the new pane. | |||
| 3921 | + | if newPV := wsView.GetPaneView(newPaneID); newPV != nil { | |||
| 3922 | + | newPV.SetPageMode(true) | |||
| 3923 | + | } | |||
| 3924 | + | bw.pageModePaneID = newPaneID | |||
| 3925 | + | ||||
| 3926 | + | logging.FromContext(ctx).Debug(). | |||
| 3927 | + | Str("window_id", bw.id). | |||
| 3928 | + | Str("old_pane_id", string(oldPaneID)). | |||
| 3929 | + | Str("new_pane_id", string(newPaneID)). | |||
| 3930 | + | Msg("page mode ownership transferred") | |||
| 3931 | + | } | |||
| 3932 | + | ||||
| 3933 | + | // handlePageModeOwnership manages the pane-local Page Mode accent and pulse | |||
| 3934 | + | // owner for a specific browser window when input modes change. | |||
| 3935 | + | func (a *App) handlePageModeOwnership(ctx context.Context, bw *browserWindow, to, from input.Mode) { | |||
| 3936 | + | if bw == nil { | |||
| 3937 | + | return | |||
| 3938 | + | } | |||
| 3939 | + | ||||
| 3940 | + | ws := a.activeWorkspaceForBrowserWindow(bw) | |||
| 3941 | + | wsView := a.activeWorkspaceViewForBrowserWindow(bw) | |||
| 3942 | + | if ws == nil || wsView == nil { | |||
| 3943 | + | // If there's no workspace yet (startup), just track the intent. | |||
| 3944 | + | if to != input.ModePage && from == input.ModePage { | |||
| 3945 | + | bw.pageModePaneID = "" | |||
| 3946 | + | } | |||
| 3947 | + | return | |||
| 3948 | + | } | |||
| 3949 | + | ||||
| 3950 | + | if to == input.ModePage && from != input.ModePage { | |||
| 3951 | + | // Entering page mode: activate on the current active pane of this window. | |||
| 3952 | + | paneID := ws.ActivePaneID | |||
| 3953 | + | if pv := wsView.GetPaneView(paneID); pv != nil { | |||
| 3954 | + | pv.SetPageMode(true) | |||
| 3955 | + | bw.pageModePaneID = paneID | |||
| 3956 | + | logging.FromContext(ctx).Debug(). | |||
| 3957 | + | Str("window_id", bw.id). | |||
| 3958 | + | Str("pane_id", string(paneID)). | |||
| 3959 | + | Msg("page mode activated on pane") | |||
| 3960 | + | } | |||
| 3961 | + | } else if from == input.ModePage && to != input.ModePage { | |||
| 3962 | + | // Leaving Page Mode: deactivate the pane that owns the visual accent. | |||
| 3963 | + | a.clearPageModeOwnership(ctx, bw) | |||
| 3964 | + | } | |||
| 3965 | + | } | |||
| 3966 | + | ||||
| 3967 | + | // triggerPageModePulse triggers a pane-local page mode pulse on the | |||
| 3968 | + | // last-focused browser window's owning pane. This is called by the | |||
| 3969 | + | // keyboard dispatcher after a page scroll action. The bw used is the | |||
| 3970 | + | // window that was active when the action was dispatched (via | |||
| 3971 | + | // dispatchBrowserWindowAction which calls activateBrowserWindow first). | |||
| 3972 | + | // If no pane is currently in page mode, the pulse is a no-op. | |||
| 3973 | + | // | |||
| 3974 | + | // Pulses are debounced at pageModePulseInterval to prevent excessive GTK CSS | |||
| 3975 | + | // class churn during held-key repeats. Smooth scroll cadence is owned by the | |||
| 3976 | + | // Page Mode repeater and backend scroll path; pulse feedback is deliberately | |||
| 3977 | + | // slower so indicator/overlay animation work does not become part of the | |||
| 3978 | + | // scrolling critical path. | |||
| 3979 | + | func (a *App) triggerPageModePulse(_ context.Context, fast bool) { | |||
| 3980 | + | // Debounce: skip if called again too soon during a continuous hold. | |||
| 3981 | + | // We still serialize the timestamp update so repeated dispatcher calls do | |||
| 3982 | + | // not restart CSS animations at scroll cadence. | |||
| 3983 | + | a.pageModePulseMu.Lock() | |||
| 3984 | + | since := time.Since(a.pageModePulseLastTime) | |||
| 3985 | + | if since < pageModePulseInterval { | |||
| 3986 | + | a.pageModePulseMu.Unlock() | |||
| 3987 | + | return | |||
| 3988 | + | } | |||
| 3989 | + | a.pageModePulseLastTime = time.Now() | |||
| 3990 | + | a.pageModePulseMu.Unlock() | |||
| 3991 | + | ||||
| 3706 | bw := a.lastFocusedBrowserWindow() | 3992 | bw := a.lastFocusedBrowserWindow() | ||
| 3707 | - | if bw == nil || bw.modeToaster == nil { | 3993 | + | if bw == nil || bw.pageModePaneID == "" { |
| 3994 | + | return | |||
| 3995 | + | } | |||
| 3996 | + | wsView := a.activeWorkspaceViewForBrowserWindow(bw) | |||
| 3997 | + | if wsView == nil { | |||
| 3708 | return | 3998 | return | ||
| 3709 | } | 3999 | } | ||
| 4000 | + | if pv := wsView.GetPaneView(bw.pageModePaneID); pv != nil { | |||
| 4001 | + | if fast { | |||
| 4002 | + | pv.TriggerPageModePulseFast() | |||
| 4003 | + | } else { | |||
| 4004 | + | pv.TriggerPageModePulse() | |||
| 4005 | + | } | |||
| 4006 | + | } | |||
| 4007 | + | } | |||
| 3710 | 4008 | ||||
| 3711 | - | // Check if mode indicator toaster is enabled in config. | 4009 | + | // clearPageModeOwnership deactivates page mode on the owning pane for a |
| 3712 | - | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled { | 4010 | + | // specific browser window and resets its tracked pane ID. |
| 3713 | - | bw.modeToaster.Hide() | 4011 | + | // Also resets the pulse debounce timer so the first pulse on re-entry |
| 4012 | + | // is never skipped. | |||
| 4013 | + | func (a *App) clearPageModeOwnership(ctx context.Context, bw *browserWindow) { | |||
| 4014 | + | if bw == nil || bw.pageModePaneID == "" { | |||
| 3714 | return | 4015 | return | ||
| 3715 | } | 4016 | } | ||
| 3716 | 4017 | ||||
| 3717 | - | if mode == input.ModeNormal { | 4018 | + | wsView := a.activeWorkspaceViewForBrowserWindow(bw) |
| 4019 | + | if wsView != nil { | |||
| 4020 | + | if pv := wsView.GetPaneView(bw.pageModePaneID); pv != nil { | |||
| 4021 | + | pv.SetPageMode(false) | |||
| 4022 | + | logging.FromContext(ctx).Debug(). | |||
| 4023 | + | Str("window_id", bw.id). | |||
| 4024 | + | Str("pane_id", string(bw.pageModePaneID)). | |||
| 4025 | + | Msg("page mode deactivated on pane") | |||
| 4026 | + | } | |||
| 4027 | + | } | |||
| 4028 | + | bw.pageModePaneID = "" | |||
| 4029 | + | ||||
| 4030 | + | // Reset debounce timer so re-entry pulses are never mis-skipped. | |||
| 4031 | + | a.pageModePulseMu.Lock() | |||
| 4032 | + | a.pageModePulseLastTime = time.Time{} | |||
| 4033 | + | a.pageModePulseMu.Unlock() | |||
| 4034 | + | } | |||
| 4035 | + | ||||
| 4036 | + | // updateModeIndicatorToaster reconciles one window's mode toaster with its | |||
| 4037 | + | // current mode and the live mode-indicator preference. | |||
| 4038 | + | func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, mode input.Mode) { | |||
| 4039 | + | if bw == nil || bw.modeToaster == nil { | |||
| 4040 | + | return | |||
| 4041 | + | } | |||
| 4042 | + | ||||
| 4043 | + | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal { | |||
| 3718 | bw.modeToaster.Hide() | 4044 | bw.modeToaster.Hide() | ||
| 3719 | return | 4045 | return | ||
| 3720 | } | 4046 | } | ||
| 3721 | 4047 | ||||
| 3722 | - | // Show persistent toaster at bottom-left with mode display name. | 4048 | + | // Modal mode toasts remain visible until that same window exits its mode. |
| 3723 | - | // Mode class is applied atomically with Show() to avoid visual flicker. | 4049 | + | // The mode class is applied atomically with Show() to avoid visual flicker. |
| 3724 | - | modeClass := getModeToastClass(mode) | |||
| 3725 | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | 4050 | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | ||
| 3726 | - | component.WithDuration(0), // Persistent until mode exits. | 4051 | + | component.WithDuration(0), |
| 3727 | component.WithPosition(component.ToastPositionBottomLeft), | 4052 | component.WithPosition(component.ToastPositionBottomLeft), | ||
| 3728 | - | component.WithModeClass(modeClass), | 4053 | + | component.WithModeClass(getModeToastClass(mode)), |
| 3729 | ) | 4054 | ) | ||
| 3730 | } | 4055 | } | ||
| 3731 | 4056 | ||||
| 3732 | // getModeToastClass returns the CSS class for the given mode's toast styling. | 4057 | // getModeToastClass returns the CSS class for the given mode's toast styling. | ||
| 3733 | func getModeToastClass(mode input.Mode) string { | 4058 | func getModeToastClass(mode input.Mode) string { | ||
| 3734 | switch mode { | 4059 | switch mode { | ||
| 3735 | - | case input.ModePane: | 4060 | + | case input.ModePane, input.ModePage: |
| 3736 | return "toast-pane-mode" | 4061 | return "toast-pane-mode" | ||
| 3737 | case input.ModeTab: | 4062 | case input.ModeTab: | ||
| 3738 | return "toast-tab-mode" | 4063 | return "toast-tab-mode" | ||
| @@ -3843,6 +4168,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||||
| 3843 | }) | 4168 | }) | ||
| 3844 | wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) { | 4169 | wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) { | ||
| 3845 | a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated") | 4170 | a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated") | ||
| 4171 | + | if bw := a.browserWindowForTab(tab.ID); bw != nil { | |||
| 4172 | + | a.transferPageModeOwnershipToPane(ctx, bw, paneID) | |||
| 4173 | + | } | |||
| 3846 | }) | 4174 | }) | ||
| 3847 | } | 4175 | } | ||
| 3848 | 4176 | ||||
| @@ -4965,6 +5293,11 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt | |||||
| 4965 | if bw.globalShortcutHandler != nil { | 5293 | if bw.globalShortcutHandler != nil { | ||
| 4966 | bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg) | 5294 | bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg) | ||
| 4967 | } | 5295 | } | ||
| 5296 | + | mode := input.ModeNormal | |||
| 5297 | + | if bw.keyboardHandler != nil { | |||
| 5298 | + | mode = bw.keyboardHandler.Mode() | |||
| 5299 | + | } | |||
| 5300 | + | a.updateModeIndicatorToaster(ctx, bw, mode) | |||
| 4968 | } | 5301 | } | ||
| 4969 | } | 5302 | } | ||
| 4970 | 5303 | ||||
| @@ -5033,6 +5366,7 @@ func (a *App) applyThemeAppearance(ctx context.Context) { | |||||
| 5033 | if resolveThemeUC == nil { | 5366 | if resolveThemeUC == nil { | ||
| 5034 | resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource) | 5367 | resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource) | ||
| 5035 | } | 5368 | } | ||
| 5369 | + | a.deps.Theme.SetTransitionDuration(workspaceStyling.TransitionDuration) | |||
| 5036 | resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig( | 5370 | resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig( | ||
| 5037 | &appearanceCfg, | 5371 | &appearanceCfg, | ||
| 5038 | runtimeCfg.DefaultUIScale, | 5372 | runtimeCfg.DefaultUIScale, | ||
A internal/ui/app_page_mode_test.go +1085 -0
| @@ -0,0 +1,1085 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 14 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 15 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 16 | + | "github.com/bnema/dumber/internal/ui/focus" | |||
| 17 | + | "github.com/bnema/dumber/internal/ui/input" | |||
| 18 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 19 | + | "github.com/bnema/dumber/internal/ui/layout/mocks" | |||
| 20 | + | "github.com/bnema/dumber/internal/ui/theme" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // --------------------------------------------------------------------------- | |||
| 24 | + | // Mock-based PaneView and WorkspaceView creation helpers | |||
| 25 | + | // | |||
| 26 | + | // These are used by pageModeTestFixture to build a minimal test environment | |||
| 27 | + | // without any GTK infrastructure. | |||
| 28 | + | // --------------------------------------------------------------------------- | |||
| 29 | + | ||||
| 30 | + | // newLoadingSkeletonMocks sets up the mock expectations for the loading | |||
| 31 | + | // skeleton that NewPaneView creates internally. | |||
| 32 | + | func newLoadingSkeletonMocks( | |||
| 33 | + | t *testing.T, | |||
| 34 | + | factory *mocks.MockWidgetFactory, | |||
| 35 | + | overlay *mocks.MockOverlayWidget, | |||
| 36 | + | ) { | |||
| 37 | + | t.Helper() | |||
| 38 | + | ||||
| 39 | + | loadingContainer := mocks.NewMockBoxWidget(t) | |||
| 40 | + | loadingContent := mocks.NewMockBoxWidget(t) | |||
| 41 | + | spinner := mocks.NewMockSpinnerWidget(t) | |||
| 42 | + | logo := mocks.NewMockImageWidget(t) | |||
| 43 | + | version := mocks.NewMockLabelWidget(t) | |||
| 44 | + | ||||
| 45 | + | factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(loadingContainer).Once() | |||
| 46 | + | loadingContainer.EXPECT().SetHexpand(true).Maybe() | |||
| 47 | + | loadingContainer.EXPECT().SetVexpand(true).Maybe() | |||
| 48 | + | loadingContainer.EXPECT().SetHalign(mock.Anything).Maybe() | |||
| 49 | + | loadingContainer.EXPECT().SetValign(mock.Anything).Maybe() | |||
| 50 | + | loadingContainer.EXPECT().SetCanFocus(false).Maybe() | |||
| 51 | + | loadingContainer.EXPECT().SetCanTarget(false).Maybe() | |||
| 52 | + | loadingContainer.EXPECT().AddCssClass(mock.Anything).Maybe() | |||
| 53 | + | loadingContainer.EXPECT().SetVisible(mock.Anything).Maybe() | |||
| 54 | + | ||||
| 55 | + | factory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(loadingContent).Once() | |||
| 56 | + | loadingContent.EXPECT().SetHalign(mock.Anything).Maybe() | |||
| 57 | + | loadingContent.EXPECT().SetValign(mock.Anything).Maybe() | |||
| 58 | + | loadingContent.EXPECT().SetCanFocus(false).Maybe() | |||
| 59 | + | loadingContent.EXPECT().SetCanTarget(false).Maybe() | |||
| 60 | + | loadingContent.EXPECT().AddCssClass(mock.Anything).Maybe() | |||
| 61 | + | ||||
| 62 | + | factory.EXPECT().NewImage().Return(logo).Once() | |||
| 63 | + | logo.EXPECT().SetHalign(mock.Anything).Maybe() | |||
| 64 | + | logo.EXPECT().SetValign(mock.Anything).Maybe() | |||
| 65 | + | logo.EXPECT().SetCanFocus(false).Maybe() | |||
| 66 | + | logo.EXPECT().SetCanTarget(false).Maybe() | |||
| 67 | + | logo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe() | |||
| 68 | + | logo.EXPECT().SetPixelSize(mock.Anything).Maybe() | |||
| 69 | + | logo.EXPECT().AddCssClass(mock.Anything).Maybe() | |||
| 70 | + | logo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | |||
| 71 | + | ||||
| 72 | + | factory.EXPECT().NewSpinner().Return(spinner).Once() | |||
| 73 | + | spinner.EXPECT().SetHalign(mock.Anything).Maybe() | |||
| 74 | + | spinner.EXPECT().SetValign(mock.Anything).Maybe() | |||
| 75 | + | spinner.EXPECT().SetCanFocus(false).Maybe() | |||
| 76 | + | spinner.EXPECT().SetCanTarget(false).Maybe() | |||
| 77 | + | spinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe() | |||
| 78 | + | spinner.EXPECT().AddCssClass(mock.Anything).Maybe() | |||
| 79 | + | spinner.EXPECT().Start().Maybe() | |||
| 80 | + | spinner.EXPECT().Stop().Maybe() | |||
| 81 | + | ||||
| 82 | + | factory.EXPECT().NewLabel(mock.Anything).Return(version).Once() | |||
| 83 | + | version.EXPECT().SetHalign(mock.Anything).Maybe() | |||
| 84 | + | version.EXPECT().SetValign(mock.Anything).Maybe() | |||
| 85 | + | version.EXPECT().SetCanFocus(false).Maybe() | |||
| 86 | + | version.EXPECT().SetCanTarget(false).Maybe() | |||
| 87 | + | version.EXPECT().SetMaxWidthChars(mock.Anything).Maybe() | |||
| 88 | + | version.EXPECT().SetEllipsize(mock.Anything).Maybe() | |||
| 89 | + | version.EXPECT().AddCssClass(mock.Anything).Maybe() | |||
| 90 | + | ||||
| 91 | + | loadingContent.EXPECT().Append(logo).Maybe() | |||
| 92 | + | loadingContent.EXPECT().Append(spinner).Maybe() | |||
| 93 | + | loadingContent.EXPECT().Append(version).Maybe() | |||
| 94 | + | loadingContainer.EXPECT().Append(loadingContent).Maybe() | |||
| 95 | + | ||||
| 96 | + | overlay.EXPECT().AddOverlay(loadingContainer).Once() | |||
| 97 | + | overlay.EXPECT().SetClipOverlay(loadingContainer, false).Once() | |||
| 98 | + | overlay.EXPECT().SetMeasureOverlay(loadingContainer, false).Once() | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // createTestPaneView builds a mock-backed PaneView. Returns the PaneView and | |||
| 102 | + | // its overlay mock so subsequent page-mode indicator / pulse expectations can | |||
| 103 | + | // be set up by the caller. | |||
| 104 | + | func createTestPaneView( | |||
| 105 | + | t *testing.T, | |||
| 106 | + | factory *mocks.MockWidgetFactory, | |||
| 107 | + | paneID entity.PaneID, | |||
| 108 | + | ) (*component.PaneView, *mocks.MockOverlayWidget) { | |||
| 109 | + | t.Helper() | |||
| 110 | + | ||||
| 111 | + | overlay := mocks.NewMockOverlayWidget(t) | |||
| 112 | + | borderBox := mocks.NewMockBoxWidget(t) | |||
| 113 | + | ||||
| 114 | + | // Overlay | |||
| 115 | + | factory.EXPECT().NewOverlay().Return(overlay).Once() | |||
| 116 | + | overlay.EXPECT().SetHexpand(true).Once() | |||
| 117 | + | overlay.EXPECT().SetVexpand(true).Once() | |||
| 118 | + | overlay.EXPECT().SetVisible(true).Once() | |||
| 119 | + | overlay.EXPECT().AddCssClass("pane-overlay").Once() | |||
| 120 | + | ||||
| 121 | + | // Loading skeleton | |||
| 122 | + | newLoadingSkeletonMocks(t, factory, overlay) | |||
| 123 | + | ||||
| 124 | + | // Border box | |||
| 125 | + | factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(borderBox).Once() | |||
| 126 | + | borderBox.EXPECT().SetCanFocus(false).Once() | |||
| 127 | + | borderBox.EXPECT().SetCanTarget(false).Once() | |||
| 128 | + | borderBox.EXPECT().AddCssClass("pane-border").Once() | |||
| 129 | + | borderBox.EXPECT().SetHexpand(true).Once() | |||
| 130 | + | borderBox.EXPECT().SetVexpand(true).Once() | |||
| 131 | + | overlay.EXPECT().AddOverlay(borderBox).Once() | |||
| 132 | + | overlay.EXPECT().SetClipOverlay(borderBox, false).Once() | |||
| 133 | + | overlay.EXPECT().SetMeasureOverlay(borderBox, false).Once() | |||
| 134 | + | ||||
| 135 | + | pv := component.NewPaneView(context.Background(), factory, paneID, nil) | |||
| 136 | + | require.NotNil(t, pv) | |||
| 137 | + | ||||
| 138 | + | return pv, overlay | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // enterPageMode marks the owning pane with the pane-local visual accent. | |||
| 142 | + | func enterPageMode( | |||
| 143 | + | t *testing.T, | |||
| 144 | + | app *App, | |||
| 145 | + | overlay *mocks.MockOverlayWidget, | |||
| 146 | + | bw *browserWindow, | |||
| 147 | + | ) { | |||
| 148 | + | t.Helper() | |||
| 149 | + | overlay.EXPECT().AddCssClass("page-mode-active").Once() | |||
| 150 | + | app.handlePageModeOwnership(context.Background(), bw, input.ModePage, input.ModeNormal) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func expectPageModeAccentHidden(overlay *mocks.MockOverlayWidget) { | |||
| 154 | + | overlay.EXPECT().RemoveCssClass("page-mode-active").Once() | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // setupWorkspaceViewMocks creates a mock-backed WorkspaceView. | |||
| 158 | + | func setupWorkspaceViewMocks( | |||
| 159 | + | t *testing.T, | |||
| 160 | + | factory *mocks.MockWidgetFactory, | |||
| 161 | + | ) *component.WorkspaceView { | |||
| 162 | + | t.Helper() | |||
| 163 | + | ||||
| 164 | + | box := mocks.NewMockBoxWidget(t) | |||
| 165 | + | overlay := mocks.NewMockOverlayWidget(t) | |||
| 166 | + | ||||
| 167 | + | factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(box).Once() | |||
| 168 | + | box.EXPECT().SetHexpand(true).Once() | |||
| 169 | + | box.EXPECT().SetVexpand(true).Once() | |||
| 170 | + | box.EXPECT().SetVisible(true).Once() | |||
| 171 | + | ||||
| 172 | + | factory.EXPECT().NewOverlay().Return(overlay).Once() | |||
| 173 | + | overlay.EXPECT().SetHexpand(true).Once() | |||
| 174 | + | overlay.EXPECT().SetVexpand(true).Once() | |||
| 175 | + | overlay.EXPECT().SetChild(box).Once() | |||
| 176 | + | overlay.EXPECT().SetVisible(true).Once() | |||
| 177 | + | ||||
| 178 | + | wv := component.NewWorkspaceView(context.Background(), factory) | |||
| 179 | + | require.NotNil(t, wv) | |||
| 180 | + | ||||
| 181 | + | return wv | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // newKeyboardHandlerInPageMode creates a KeyboardHandler whose Mode() returns | |||
| 185 | + | // ModePage — no GTK widgets required. | |||
| 186 | + | func newKeyboardHandlerInPageMode(t *testing.T) *input.KeyboardHandler { | |||
| 187 | + | t.Helper() | |||
| 188 | + | kh := input.NewKeyboardHandler( | |||
| 189 | + | context.Background(), | |||
| 190 | + | &entity.WorkspaceConfig{}, | |||
| 191 | + | &entity.SessionConfig{}, | |||
| 192 | + | ) | |||
| 193 | + | kh.EnterPageMode() | |||
| 194 | + | return kh | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func bindPageModeKeyboardHandler(t *testing.T, app *App, bw *browserWindow) *input.KeyboardHandler { | |||
| 198 | + | t.Helper() | |||
| 199 | + | kh := newKeyboardHandlerInPageMode(t) | |||
| 200 | + | kh.SetOnModeChange(func(from, to input.Mode) { | |||
| 201 | + | app.handleModeChange(context.Background(), bw, from, to) | |||
| 202 | + | }) | |||
| 203 | + | bw.keyboardHandler = kh | |||
| 204 | + | return kh | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // --------------------------------------------------------------------------- | |||
| 208 | + | // pageModeTestFixture — complete, minimal App + workspace environment | |||
| 209 | + | // | |||
| 210 | + | // Provides two browser windows, each with a two-pane workspace. The active | |||
| 211 | + | // pane on both workspaces starts at "pane-a". | |||
| 212 | + | // --------------------------------------------------------------------------- | |||
| 213 | + | ||||
| 214 | + | type pageModeTestFixture struct { | |||
| 215 | + | app *App | |||
| 216 | + | bw1 *browserWindow | |||
| 217 | + | bw2 *browserWindow | |||
| 218 | + | pv1A *component.PaneView // bw1 / pane-a (active) | |||
| 219 | + | pv1B *component.PaneView // bw1 / pane-b | |||
| 220 | + | pv2A *component.PaneView // bw2 / pane-a (active) | |||
| 221 | + | pv2B *component.PaneView // bw2 / pane-b | |||
| 222 | + | ||||
| 223 | + | factory *mocks.MockWidgetFactory | |||
| 224 | + | overlay1A *mocks.MockOverlayWidget | |||
| 225 | + | overlay1B *mocks.MockOverlayWidget | |||
| 226 | + | overlay2A *mocks.MockOverlayWidget | |||
| 227 | + | overlay2B *mocks.MockOverlayWidget | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func newPageModeTestFixture(t *testing.T) *pageModeTestFixture { | |||
| 231 | + | t.Helper() | |||
| 232 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 233 | + | ||||
| 234 | + | // ---- Browser window 1 ---- | |||
| 235 | + | pv1A, ol1A := createTestPaneView(t, factory, "pane-a") | |||
| 236 | + | pv1B, ol1B := createTestPaneView(t, factory, "pane-b") | |||
| 237 | + | ||||
| 238 | + | ws1 := &entity.Workspace{ | |||
| 239 | + | ID: "ws-1", | |||
| 240 | + | ActivePaneID: "pane-a", | |||
| 241 | + | Root: &entity.PaneNode{ | |||
| 242 | + | ID: "ws-1-root", | |||
| 243 | + | Children: []*entity.PaneNode{ | |||
| 244 | + | {ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")}, | |||
| 245 | + | {ID: "ws-1-pane-b", Pane: entity.NewPane("pane-b")}, | |||
| 246 | + | }, | |||
| 247 | + | SplitDir: entity.SplitHorizontal, | |||
| 248 | + | }, | |||
| 249 | + | } | |||
| 250 | + | tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1} | |||
| 251 | + | tabs1 := entity.NewTabList() | |||
| 252 | + | tabs1.Add(tab1) | |||
| 253 | + | tabs1.SetActive(tab1.ID) | |||
| 254 | + | ||||
| 255 | + | wv1 := setupWorkspaceViewMocks(t, factory) | |||
| 256 | + | wv1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{ | |||
| 257 | + | "pane-a": pv1A, "pane-b": pv1B, | |||
| 258 | + | }) | |||
| 259 | + | ||||
| 260 | + | bw1 := &browserWindow{id: "win-1", tabs: tabs1} | |||
| 261 | + | ||||
| 262 | + | // ---- Browser window 2 ---- | |||
| 263 | + | pv2A, ol2A := createTestPaneView(t, factory, "pane-a") | |||
| 264 | + | pv2B, ol2B := createTestPaneView(t, factory, "pane-b") | |||
| 265 | + | ||||
| 266 | + | ws2 := &entity.Workspace{ | |||
| 267 | + | ID: "ws-2", | |||
| 268 | + | ActivePaneID: "pane-a", | |||
| 269 | + | Root: &entity.PaneNode{ | |||
| 270 | + | ID: "ws-2-root", | |||
| 271 | + | Children: []*entity.PaneNode{ | |||
| 272 | + | {ID: "ws-2-pane-a", Pane: entity.NewPane("pane-a")}, | |||
| 273 | + | {ID: "ws-2-pane-b", Pane: entity.NewPane("pane-b")}, | |||
| 274 | + | }, | |||
| 275 | + | SplitDir: entity.SplitHorizontal, | |||
| 276 | + | }, | |||
| 277 | + | } | |||
| 278 | + | tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2} | |||
| 279 | + | tabs2 := entity.NewTabList() | |||
| 280 | + | tabs2.Add(tab2) | |||
| 281 | + | tabs2.SetActive(tab2.ID) | |||
| 282 | + | ||||
| 283 | + | wv2 := setupWorkspaceViewMocks(t, factory) | |||
| 284 | + | wv2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{ | |||
| 285 | + | "pane-a": pv2A, "pane-b": pv2B, | |||
| 286 | + | }) | |||
| 287 | + | ||||
| 288 | + | bw2 := &browserWindow{id: "win-2", tabs: tabs2} | |||
| 289 | + | ||||
| 290 | + | app := &App{ | |||
| 291 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{ | |||
| 292 | + | "tab-1": wv1, "tab-2": wv2, | |||
| 293 | + | }, | |||
| 294 | + | browserWindows: map[string]*browserWindow{ | |||
| 295 | + | "win-1": bw1, "win-2": bw2, | |||
| 296 | + | }, | |||
| 297 | + | lastFocusedWindowID: "win-1", | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | return &pageModeTestFixture{ | |||
| 301 | + | app: app, bw1: bw1, bw2: bw2, | |||
| 302 | + | pv1A: pv1A, pv1B: pv1B, pv2A: pv2A, pv2B: pv2B, | |||
| 303 | + | factory: factory, | |||
| 304 | + | overlay1A: ol1A, overlay1B: ol1B, | |||
| 305 | + | overlay2A: ol2A, overlay2B: ol2B, | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func newSingleWindowPageModeFixture(t *testing.T) *pageModeTestFixture { | |||
| 310 | + | t.Helper() | |||
| 311 | + | f := newPageModeTestFixture(t) | |||
| 312 | + | delete(f.app.browserWindows, "win-2") | |||
| 313 | + | delete(f.app.workspaceViews, entity.TabID("tab-2")) | |||
| 314 | + | f.bw2 = nil | |||
| 315 | + | f.pv2A = nil | |||
| 316 | + | f.pv2B = nil | |||
| 317 | + | f.overlay2A = nil | |||
| 318 | + | f.overlay2B = nil | |||
| 319 | + | return f | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // ============================================================================ | |||
| 323 | + | // 1. Entering Page mode marks only the active pane | |||
| 324 | + | // ============================================================================ | |||
| 325 | + | ||||
| 326 | + | func TestPageMode_Enter_SetsOwnershipOnActivePane(t *testing.T) { | |||
| 327 | + | f := newPageModeTestFixture(t) | |||
| 328 | + | ||||
| 329 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 330 | + | ||||
| 331 | + | assert.Equal(t, entity.PaneID("pane-a"), f.bw1.pageModePaneID, | |||
| 332 | + | "entering page mode sets ownership on the active pane") | |||
| 333 | + | assert.True(t, f.pv1A.IsPageMode(), | |||
| 334 | + | "the active pane view enters page mode") | |||
| 335 | + | assert.False(t, f.pv1B.IsPageMode(), | |||
| 336 | + | "the inactive pane is NOT in page mode") | |||
| 337 | + | assert.Empty(t, f.bw2.pageModePaneID, | |||
| 338 | + | "second window is untouched") | |||
| 339 | + | assert.False(t, f.pv2A.IsPageMode(), | |||
| 340 | + | "second window active pane is NOT in page mode") | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | func TestPageMode_Enter_MarksCorrectWindow(t *testing.T) { | |||
| 344 | + | f := newPageModeTestFixture(t) | |||
| 345 | + | ||||
| 346 | + | enterPageMode(t, f.app, f.overlay2A, f.bw2) | |||
| 347 | + | ||||
| 348 | + | assert.Equal(t, entity.PaneID("pane-a"), f.bw2.pageModePaneID) | |||
| 349 | + | assert.True(t, f.pv2A.IsPageMode()) | |||
| 350 | + | assert.Empty(t, f.bw1.pageModePaneID) | |||
| 351 | + | assert.False(t, f.pv1A.IsPageMode()) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | // ============================================================================ | |||
| 355 | + | // 2. Leaving Page mode clears ownership | |||
| 356 | + | // ============================================================================ | |||
| 357 | + | ||||
| 358 | + | func TestPageMode_Leave_ClearsOwnershipFromOwningPane(t *testing.T) { | |||
| 359 | + | f := newPageModeTestFixture(t) | |||
| 360 | + | ||||
| 361 | + | // Enter first | |||
| 362 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 363 | + | require.Equal(t, entity.PaneID("pane-a"), f.bw1.pageModePaneID) | |||
| 364 | + | ||||
| 365 | + | // Leave — SetPageMode(false) hides indicator and removes the CSS class | |||
| 366 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 367 | + | f.app.handlePageModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModePage) | |||
| 368 | + | ||||
| 369 | + | assert.Empty(t, f.bw1.pageModePaneID, | |||
| 370 | + | "leaving page mode clears ownership field") | |||
| 371 | + | assert.False(t, f.pv1A.IsPageMode(), | |||
| 372 | + | "the previously owning pane exits page mode") | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // ============================================================================ | |||
| 376 | + | // 3. Transfer within the same window | |||
| 377 | + | // ============================================================================ | |||
| 378 | + | ||||
| 379 | + | func TestPageMode_Transfer_MovesOwnershipToNewPane(t *testing.T) { | |||
| 380 | + | f := newPageModeTestFixture(t) | |||
| 381 | + | ||||
| 382 | + | // Enter page mode on bw1 pane-a — save label for hide expectation | |||
| 383 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 384 | + | require.Equal(t, entity.PaneID("pane-a"), f.bw1.pageModePaneID) | |||
| 385 | + | ||||
| 386 | + | // Give bw1 a keyboard handler in page mode so transfer performs the switch | |||
| 387 | + | f.bw1.keyboardHandler = newKeyboardHandlerInPageMode(t) | |||
| 388 | + | ||||
| 389 | + | // transferPageModeOwnershipToPane calls SetPageMode(false) on the old | |||
| 390 | + | // pane before SetPageMode(true) on the new pane. Set up both. | |||
| 391 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 392 | + | ||||
| 393 | + | // Set up the pane-local accent for pane-b. | |||
| 394 | + | f.overlay1B.EXPECT().AddCssClass("page-mode-active").Once() | |||
| 395 | + | ||||
| 396 | + | // Transfer ownership to pane-b | |||
| 397 | + | f.app.transferPageModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b")) | |||
| 398 | + | ||||
| 399 | + | assert.Equal(t, entity.PaneID("pane-b"), f.bw1.pageModePaneID, | |||
| 400 | + | "ownership transferred to new pane") | |||
| 401 | + | assert.False(t, f.pv1A.IsPageMode(), | |||
| 402 | + | "previous owning pane deactivated") | |||
| 403 | + | assert.True(t, f.pv1B.IsPageMode(), | |||
| 404 | + | "new pane is now in page mode") | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | func TestPageMode_Transfer_NoPageModeClearsStale(t *testing.T) { | |||
| 408 | + | f := newPageModeTestFixture(t) | |||
| 409 | + | ||||
| 410 | + | f.bw1.pageModePaneID = entity.PaneID("pane-a") | |||
| 411 | + | f.bw1.keyboardHandler = input.NewKeyboardHandler( | |||
| 412 | + | context.Background(), | |||
| 413 | + | &entity.WorkspaceConfig{}, | |||
| 414 | + | &entity.SessionConfig{}, | |||
| 415 | + | ) | |||
| 416 | + | // No EnterPageMode — handler stays in ModeNormal. | |||
| 417 | + | ||||
| 418 | + | f.app.transferPageModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b")) | |||
| 419 | + | ||||
| 420 | + | assert.Empty(t, f.bw1.pageModePaneID, | |||
| 421 | + | "stale ownership cleared when window is not in page mode") | |||
| 422 | + | assert.False(t, f.pv1A.IsPageMode()) | |||
| 423 | + | assert.False(t, f.pv1B.IsPageMode()) | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | func TestPageMode_Transfer_NilBwDoesNotCrash(t *testing.T) { | |||
| 427 | + | app := &App{} | |||
| 428 | + | app.transferPageModeOwnershipToPane(context.Background(), nil, entity.PaneID("pane-1")) | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | func TestPageMode_Transfer_SamePaneIsNoop(t *testing.T) { | |||
| 432 | + | app := &App{} | |||
| 433 | + | paneID := entity.PaneID("pane-a") | |||
| 434 | + | bw := &browserWindow{pageModePaneID: paneID} | |||
| 435 | + | app.transferPageModeOwnershipToPane(context.Background(), bw, paneID) | |||
| 436 | + | assert.Equal(t, paneID, bw.pageModePaneID) | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | func TestPageMode_Transfer_EmptyOwnershipIsNoop(t *testing.T) { | |||
| 440 | + | app := &App{} | |||
| 441 | + | bw := &browserWindow{} | |||
| 442 | + | app.transferPageModeOwnershipToPane(context.Background(), bw, entity.PaneID("pane-a")) | |||
| 443 | + | assert.Empty(t, bw.pageModePaneID) | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | // ============================================================================ | |||
| 447 | + | // 4. Pulse targeting — normal vs fast | |||
| 448 | + | // ============================================================================ | |||
| 449 | + | ||||
| 450 | + | func setUpNormalPulse(overlay *mocks.MockOverlayWidget) { | |||
| 451 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse").Once() | |||
| 452 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse-fast").Once() | |||
| 453 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse-cycle-a").Once() | |||
| 454 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse-cycle-b").Once() | |||
| 455 | + | overlay.EXPECT().AddCssClass("page-mode-pulse").Once() | |||
| 456 | + | overlay.EXPECT().AddCssClass("page-mode-pulse-cycle-a").Once() | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | func setUpFastPulse(overlay *mocks.MockOverlayWidget) { | |||
| 460 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse").Once() | |||
| 461 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse-fast").Once() | |||
| 462 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse-cycle-a").Once() | |||
| 463 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse-cycle-b").Once() | |||
| 464 | + | overlay.EXPECT().AddCssClass("page-mode-pulse-fast").Once() | |||
| 465 | + | overlay.EXPECT().AddCssClass("page-mode-pulse-cycle-a").Once() | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | func TestPageMode_Pulse_NormalTriggersOnOwningPane(t *testing.T) { | |||
| 469 | + | f := newPageModeTestFixture(t) | |||
| 470 | + | ||||
| 471 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 472 | + | require.Equal(t, entity.PaneID("pane-a"), f.bw1.pageModePaneID) | |||
| 473 | + | ||||
| 474 | + | // Pulse needs the indicator to exist — it was created lazily during | |||
| 475 | + | // enterPageMode, but we also need to set up pulse expectations. | |||
| 476 | + | // (enterPageMode created it, so we just set up pulse mocks.) | |||
| 477 | + | setUpNormalPulse(f.overlay1A) | |||
| 478 | + | ||||
| 479 | + | f.app.triggerPageModePulse(context.Background(), false) | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | func TestPageMode_Pulse_FastTriggersOnOwningPane(t *testing.T) { | |||
| 483 | + | f := newPageModeTestFixture(t) | |||
| 484 | + | ||||
| 485 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 486 | + | require.Equal(t, entity.PaneID("pane-a"), f.bw1.pageModePaneID) | |||
| 487 | + | ||||
| 488 | + | setUpFastPulse(f.overlay1A) | |||
| 489 | + | ||||
| 490 | + | f.app.triggerPageModePulse(context.Background(), true) | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | func TestPageMode_Pulse_NoOwnerIsNoop(t *testing.T) { | |||
| 494 | + | f := newPageModeTestFixture(t) | |||
| 495 | + | f.app.triggerPageModePulse(context.Background(), false) | |||
| 496 | + | f.app.triggerPageModePulse(context.Background(), true) | |||
| 497 | + | // No pane has ownership — no pulse expectations needed. | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | // ============================================================================ | |||
| 501 | + | // 5. Multi-window isolation | |||
| 502 | + | // ============================================================================ | |||
| 503 | + | ||||
| 504 | + | func TestPageMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) { | |||
| 505 | + | f := newPageModeTestFixture(t) | |||
| 506 | + | ||||
| 507 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 508 | + | enterPageMode(t, f.app, f.overlay2A, f.bw2) | |||
| 509 | + | ||||
| 510 | + | assert.Equal(t, entity.PaneID("pane-a"), f.bw1.pageModePaneID) | |||
| 511 | + | assert.Equal(t, entity.PaneID("pane-a"), f.bw2.pageModePaneID) | |||
| 512 | + | ||||
| 513 | + | // Leave bw1 only | |||
| 514 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 515 | + | f.app.handlePageModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModePage) | |||
| 516 | + | ||||
| 517 | + | assert.Empty(t, f.bw1.pageModePaneID) | |||
| 518 | + | assert.False(t, f.pv1A.IsPageMode()) | |||
| 519 | + | ||||
| 520 | + | // bw2 unchanged | |||
| 521 | + | assert.Equal(t, entity.PaneID("pane-a"), f.bw2.pageModePaneID) | |||
| 522 | + | assert.True(t, f.pv2A.IsPageMode()) | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | func TestPageMode_MultiWindow_TransferIsolated(t *testing.T) { | |||
| 526 | + | f := newPageModeTestFixture(t) | |||
| 527 | + | ||||
| 528 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 529 | + | enterPageMode(t, f.app, f.overlay2A, f.bw2) | |||
| 530 | + | ||||
| 531 | + | f.bw1.keyboardHandler = newKeyboardHandlerInPageMode(t) | |||
| 532 | + | ||||
| 533 | + | // transferPageModeOwnershipToPane calls SetPageMode(false) on old pane first | |||
| 534 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 535 | + | ||||
| 536 | + | // Transfer bw1 to pane-b. | |||
| 537 | + | f.overlay1B.EXPECT().AddCssClass("page-mode-active").Once() | |||
| 538 | + | f.app.transferPageModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b")) | |||
| 539 | + | ||||
| 540 | + | assert.Equal(t, entity.PaneID("pane-b"), f.bw1.pageModePaneID) | |||
| 541 | + | assert.True(t, f.pv1B.IsPageMode()) | |||
| 542 | + | assert.False(t, f.pv1A.IsPageMode()) | |||
| 543 | + | ||||
| 544 | + | // bw2 unchanged | |||
| 545 | + | assert.Equal(t, entity.PaneID("pane-a"), f.bw2.pageModePaneID) | |||
| 546 | + | assert.True(t, f.pv2A.IsPageMode()) | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | func TestPageMode_MultiWindow_PulseIsolated(t *testing.T) { | |||
| 550 | + | f := newPageModeTestFixture(t) | |||
| 551 | + | ||||
| 552 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 553 | + | enterPageMode(t, f.app, f.overlay2A, f.bw2) | |||
| 554 | + | ||||
| 555 | + | // Pulse on bw1 (lastFocusedWindowID = "win-1") | |||
| 556 | + | setUpNormalPulse(f.overlay1A) | |||
| 557 | + | f.app.triggerPageModePulse(context.Background(), false) | |||
| 558 | + | ||||
| 559 | + | // bw2 pane still in page mode | |||
| 560 | + | assert.True(t, f.pv2A.IsPageMode()) | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | // ============================================================================ | |||
| 564 | + | // Edge cases — nil / no-workspace / startup guards | |||
| 565 | + | // ============================================================================ | |||
| 566 | + | ||||
| 567 | + | func TestPageMode_Enter_NoWorkspaceIsNoop(t *testing.T) { | |||
| 568 | + | app := &App{} | |||
| 569 | + | bw := &browserWindow{id: "win-1"} | |||
| 570 | + | app.handlePageModeOwnership(context.Background(), bw, input.ModePage, input.ModeNormal) | |||
| 571 | + | assert.Empty(t, bw.pageModePaneID) | |||
| 572 | + | } | |||
| 573 | + | ||||
| 574 | + | func TestPageMode_Clear_NilBwDoesNotCrash(t *testing.T) { | |||
| 575 | + | (&App{}).clearPageModeOwnership(context.Background(), nil) | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | func TestPageMode_Clear_NoPaneID(t *testing.T) { | |||
| 579 | + | app := &App{} | |||
| 580 | + | bw := &browserWindow{id: "test-win"} | |||
| 581 | + | app.clearPageModeOwnership(context.Background(), bw) | |||
| 582 | + | assert.Empty(t, bw.pageModePaneID) | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | func TestPageMode_Clear_StalePaneWithoutWorkspace(t *testing.T) { | |||
| 586 | + | app := &App{} | |||
| 587 | + | bw := &browserWindow{id: "test-win", pageModePaneID: entity.PaneID("stale-pane")} | |||
| 588 | + | app.clearPageModeOwnership(context.Background(), bw) | |||
| 589 | + | assert.Empty(t, bw.pageModePaneID) | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | func TestPageMode_HandleModeChange_NilBwDoesNotCrash(t *testing.T) { | |||
| 593 | + | (&App{}).handleModeChange(context.Background(), nil, input.ModeNormal, input.ModePage) | |||
| 594 | + | } | |||
| 595 | + | ||||
| 596 | + | func TestPageMode_EditableFocusOnActivePaneExitsPageMode(t *testing.T) { | |||
| 597 | + | f := newSingleWindowPageModeFixture(t) | |||
| 598 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 599 | + | kh := bindPageModeKeyboardHandler(t, f.app, f.bw1) | |||
| 600 | + | ||||
| 601 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 602 | + | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true) | |||
| 603 | + | ||||
| 604 | + | assert.Equal(t, input.ModeNormal, kh.Mode()) | |||
| 605 | + | assert.Empty(t, f.bw1.pageModePaneID) | |||
| 606 | + | assert.False(t, f.pv1A.IsPageMode()) | |||
| 607 | + | } | |||
| 608 | + | ||||
| 609 | + | func TestPageMode_EditableFocusOnInactivePaneDoesNotExitPageMode(t *testing.T) { | |||
| 610 | + | f := newSingleWindowPageModeFixture(t) | |||
| 611 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 612 | + | kh := bindPageModeKeyboardHandler(t, f.app, f.bw1) | |||
| 613 | + | ||||
| 614 | + | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-b"), true) | |||
| 615 | + | ||||
| 616 | + | assert.Equal(t, input.ModePage, kh.Mode()) | |||
| 617 | + | assert.Equal(t, entity.PaneID("pane-a"), f.bw1.pageModePaneID) | |||
| 618 | + | assert.True(t, f.pv1A.IsPageMode()) | |||
| 619 | + | } | |||
| 620 | + | ||||
| 621 | + | func TestPageMode_PaneSwitchToEditablePaneExitsPageMode(t *testing.T) { | |||
| 622 | + | f := newSingleWindowPageModeFixture(t) | |||
| 623 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 624 | + | kh := bindPageModeKeyboardHandler(t, f.app, f.bw1) | |||
| 625 | + | f.app.pageEditableFocusByPane = map[entity.PaneID]bool{ | |||
| 626 | + | "pane-b": true, | |||
| 627 | + | } | |||
| 628 | + | ||||
| 629 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 630 | + | f.app.transferPageModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b")) | |||
| 631 | + | ||||
| 632 | + | assert.Equal(t, input.ModeNormal, kh.Mode()) | |||
| 633 | + | assert.Empty(t, f.bw1.pageModePaneID) | |||
| 634 | + | assert.False(t, f.pv1A.IsPageMode()) | |||
| 635 | + | assert.False(t, f.pv1B.IsPageMode()) | |||
| 636 | + | } | |||
| 637 | + | ||||
| 638 | + | func TestPageMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowPageMode(t *testing.T) { | |||
| 639 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 640 | + | pv1, overlay1 := createTestPaneView(t, factory, "pane-a") | |||
| 641 | + | pv2, _ := createTestPaneView(t, factory, "pane-x") | |||
| 642 | + | ||||
| 643 | + | ws1 := &entity.Workspace{ID: "ws-1", ActivePaneID: "pane-a", Root: &entity.PaneNode{ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")}} | |||
| 644 | + | ws2 := &entity.Workspace{ID: "ws-2", ActivePaneID: "pane-x", Root: &entity.PaneNode{ID: "ws-2-pane-x", Pane: entity.NewPane("pane-x")}} | |||
| 645 | + | bw1 := &browserWindow{id: "win-1", tabs: entity.NewTabList()} | |||
| 646 | + | bw2 := &browserWindow{id: "win-2", tabs: entity.NewTabList()} | |||
| 647 | + | tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1} | |||
| 648 | + | tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2} | |||
| 649 | + | bw1.tabs.Add(tab1) | |||
| 650 | + | bw1.tabs.SetActive(tab1.ID) | |||
| 651 | + | bw2.tabs.Add(tab2) | |||
| 652 | + | bw2.tabs.SetActive(tab2.ID) | |||
| 653 | + | view1 := setupWorkspaceViewMocks(t, factory) | |||
| 654 | + | view1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-a": pv1}) | |||
| 655 | + | view2 := setupWorkspaceViewMocks(t, factory) | |||
| 656 | + | view2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-x": pv2}) | |||
| 657 | + | ||||
| 658 | + | app := &App{ | |||
| 659 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{"tab-1": view1, "tab-2": view2}, | |||
| 660 | + | browserWindows: map[string]*browserWindow{"win-1": bw1, "win-2": bw2}, | |||
| 661 | + | lastFocusedWindowID: "win-1", | |||
| 662 | + | } | |||
| 663 | + | ||||
| 664 | + | enterPageMode(t, app, overlay1, bw1) | |||
| 665 | + | kh := bindPageModeKeyboardHandler(t, app, bw1) | |||
| 666 | + | ||||
| 667 | + | app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-x"), true) | |||
| 668 | + | ||||
| 669 | + | assert.Equal(t, input.ModePage, kh.Mode()) | |||
| 670 | + | assert.Equal(t, entity.PaneID("pane-a"), bw1.pageModePaneID) | |||
| 671 | + | assert.True(t, pv1.IsPageMode()) | |||
| 672 | + | } | |||
| 673 | + | ||||
| 674 | + | func TestPageMode_OmniboxFocusExitsPageMode(t *testing.T) { | |||
| 675 | + | f := newSingleWindowPageModeFixture(t) | |||
| 676 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 677 | + | kh := bindPageModeKeyboardHandler(t, f.app, f.bw1) | |||
| 678 | + | ||||
| 679 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 680 | + | f.app.handlePageModeFocusTrigger(context.Background(), f.bw1, usecase.PageModePolicyTriggerOmniboxFocus) | |||
| 681 | + | ||||
| 682 | + | assert.Equal(t, input.ModeNormal, kh.Mode()) | |||
| 683 | + | assert.Empty(t, f.bw1.pageModePaneID) | |||
| 684 | + | } | |||
| 685 | + | ||||
| 686 | + | func TestPageMode_FindBarFocusExitsPageMode(t *testing.T) { | |||
| 687 | + | f := newSingleWindowPageModeFixture(t) | |||
| 688 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 689 | + | kh := bindPageModeKeyboardHandler(t, f.app, f.bw1) | |||
| 690 | + | ||||
| 691 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 692 | + | f.app.handlePageModeFocusTrigger(context.Background(), f.bw1, usecase.PageModePolicyTriggerFindBarFocus) | |||
| 693 | + | ||||
| 694 | + | assert.Equal(t, input.ModeNormal, kh.Mode()) | |||
| 695 | + | assert.Empty(t, f.bw1.pageModePaneID) | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | func TestPageMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) { | |||
| 699 | + | f := newSingleWindowPageModeFixture(t) | |||
| 700 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 701 | + | kh := bindPageModeKeyboardHandler(t, f.app, f.bw1) | |||
| 702 | + | ||||
| 703 | + | tab2 := &entity.Tab{ID: "tab-2b", Workspace: &entity.Workspace{ | |||
| 704 | + | ID: "ws-2b", | |||
| 705 | + | ActivePaneID: "pane-c", | |||
| 706 | + | Root: &entity.PaneNode{ID: "ws-2b-pane-c", Pane: entity.NewPane("pane-c")}, | |||
| 707 | + | }} | |||
| 708 | + | f.bw1.tabs.Add(tab2) | |||
| 709 | + | f.bw1.tabs.SetActive(tab2.ID) | |||
| 710 | + | f.app.workspaceViews[tab2.ID] = setupWorkspaceViewMocks(t, f.factory) | |||
| 711 | + | ||||
| 712 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 713 | + | f.app.handlePageModeTabSwitch(context.Background(), f.bw1) | |||
| 714 | + | ||||
| 715 | + | assert.Equal(t, input.ModeNormal, kh.Mode()) | |||
| 716 | + | assert.Empty(t, f.bw1.pageModePaneID) | |||
| 717 | + | assert.False(t, f.pv1A.IsPageMode()) | |||
| 718 | + | } | |||
| 719 | + | ||||
| 720 | + | func TestPageMode_ActivationBypassWhenActivePageIsEditable(t *testing.T) { | |||
| 721 | + | f := newSingleWindowPageModeFixture(t) | |||
| 722 | + | f.app.pageEditableFocusByPane = map[entity.PaneID]bool{ | |||
| 723 | + | "pane-a": true, | |||
| 724 | + | } | |||
| 725 | + | ||||
| 726 | + | assert.True(t, f.app.shouldBypassPageModeActivation(f.bw1)) | |||
| 727 | + | } | |||
| 728 | + | ||||
| 729 | + | func TestPageMode_ActivationBypassClearsWhenEditableFocusLeaves(t *testing.T) { | |||
| 730 | + | f := newSingleWindowPageModeFixture(t) | |||
| 731 | + | ||||
| 732 | + | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true) | |||
| 733 | + | assert.True(t, f.app.shouldBypassPageModeActivation(f.bw1)) | |||
| 734 | + | ||||
| 735 | + | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), false) | |||
| 736 | + | assert.False(t, f.app.shouldBypassPageModeActivation(f.bw1)) | |||
| 737 | + | } | |||
| 738 | + | ||||
| 739 | + | func TestPageMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) { | |||
| 740 | + | f := newSingleWindowPageModeFixture(t) | |||
| 741 | + | f.app.pageEditableFocusByPane = map[entity.PaneID]bool{"pane-a": true} | |||
| 742 | + | ||||
| 743 | + | f.app.clearPageEditableFocusState(entity.PaneID("pane-a")) | |||
| 744 | + | ||||
| 745 | + | assert.False(t, f.app.shouldBypassPageModeActivation(f.bw1)) | |||
| 746 | + | } | |||
| 747 | + | ||||
| 748 | + | // ============================================================================ | |||
| 749 | + | // CSS class constants | |||
| 750 | + | // ============================================================================ | |||
| 751 | + | ||||
| 752 | + | func TestPageMode_CSSClassConstants(t *testing.T) { | |||
| 753 | + | assert.Equal(t, "page-mode-active", component.PageModeActiveClass) | |||
| 754 | + | assert.Equal(t, "page-mode-pulse", component.PageModePulseClass) | |||
| 755 | + | assert.Equal(t, "page-mode-pulse-fast", component.PageModeFastPulseClass) | |||
| 756 | + | } | |||
| 757 | + | ||||
| 758 | + | // ============================================================================ | |||
| 759 | + | // Mode toaster | |||
| 760 | + | // ============================================================================ | |||
| 761 | + | ||||
| 762 | + | func newModeToasterForPageModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) { | |||
| 763 | + | t.Helper() | |||
| 764 | + | ||||
| 765 | + | container := mocks.NewMockBoxWidget(t) | |||
| 766 | + | label := mocks.NewMockLabelWidget(t) | |||
| 767 | + | factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once() | |||
| 768 | + | container.EXPECT().AddCssClass("toast").Once() | |||
| 769 | + | container.EXPECT().AddCssClass("toast-info").Once() | |||
| 770 | + | container.EXPECT().SetHalign(mock.Anything).Once() | |||
| 771 | + | container.EXPECT().SetValign(mock.Anything).Once() | |||
| 772 | + | container.EXPECT().SetHexpand(false).Once() | |||
| 773 | + | container.EXPECT().SetVexpand(false).Once() | |||
| 774 | + | container.EXPECT().SetCanTarget(false).Once() | |||
| 775 | + | container.EXPECT().SetCanFocus(false).Once() | |||
| 776 | + | container.EXPECT().SetVisible(false).Once() | |||
| 777 | + | factory.EXPECT().NewLabel("").Return(label).Once() | |||
| 778 | + | label.EXPECT().SetCanTarget(false).Once() | |||
| 779 | + | label.EXPECT().SetCanFocus(false).Once() | |||
| 780 | + | container.EXPECT().Append(label).Once() | |||
| 781 | + | ||||
| 782 | + | return component.NewToaster(factory), container, label | |||
| 783 | + | } | |||
| 784 | + | ||||
| 785 | + | func expectPageModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) { | |||
| 786 | + | container.EXPECT().RemoveCssClass("toast-info").Once() | |||
| 787 | + | container.EXPECT().AddCssClass("toast-pane-mode").Once() | |||
| 788 | + | container.EXPECT().SetHalign(mock.Anything).Once() | |||
| 789 | + | container.EXPECT().SetValign(mock.Anything).Once() | |||
| 790 | + | label.EXPECT().SetText("PAGE MODE").Once() | |||
| 791 | + | container.EXPECT().SetVisible(true).Once() | |||
| 792 | + | } | |||
| 793 | + | ||||
| 794 | + | func TestPageMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) { | |||
| 795 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 796 | + | toaster, container, label := newModeToasterForPageModeTest(t, factory) | |||
| 797 | + | expectPageModeToastShown(container, label) | |||
| 798 | + | // These optional calls are made only by the old brief auto-dismiss path. | |||
| 799 | + | container.EXPECT().RemoveCssClass("toast-pane-mode").Maybe() | |||
| 800 | + | container.EXPECT().SetVisible(false).Maybe() | |||
| 801 | + | ||||
| 802 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |||
| 803 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |||
| 804 | + | ModeIndicatorToasterEnabled: true, | |||
| 805 | + | }}}, | |||
| 806 | + | })} | |||
| 807 | + | bw := &browserWindow{id: "win-1", modeToaster: toaster} | |||
| 808 | + | app.browserWindows = map[string]*browserWindow{bw.id: bw} | |||
| 809 | + | app.lastFocusedWindowID = bw.id | |||
| 810 | + | ||||
| 811 | + | app.updateModeIndicatorToaster(context.Background(), bw, input.ModePage) | |||
| 812 | + | require.True(t, toaster.IsVisible()) | |||
| 813 | + | ||||
| 814 | + | deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond) | |||
| 815 | + | mainContext := glib.MainContextDefault() | |||
| 816 | + | for time.Now().Before(deadline) { | |||
| 817 | + | time.Sleep(10 * time.Millisecond) | |||
| 818 | + | for mainContext.Pending() { | |||
| 819 | + | mainContext.Iteration(false) | |||
| 820 | + | } | |||
| 821 | + | } | |||
| 822 | + | ||||
| 823 | + | assert.True(t, toaster.IsVisible(), "Page Mode toast must remain visible until mode exit") | |||
| 824 | + | } | |||
| 825 | + | ||||
| 826 | + | func TestPageMode_ModeExitHidesToaster(t *testing.T) { | |||
| 827 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 828 | + | toaster, container, label := newModeToasterForPageModeTest(t, factory) | |||
| 829 | + | expectPageModeToastShown(container, label) | |||
| 830 | + | container.EXPECT().RemoveCssClass("toast-custom").Once() | |||
| 831 | + | container.EXPECT().RemoveCssClass("toast-pane-mode").Once() | |||
| 832 | + | container.EXPECT().SetVisible(false).Once() | |||
| 833 | + | ||||
| 834 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |||
| 835 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |||
| 836 | + | ModeIndicatorToasterEnabled: true, | |||
| 837 | + | }}}, | |||
| 838 | + | })} | |||
| 839 | + | bw := &browserWindow{id: "win-1", modeToaster: toaster} | |||
| 840 | + | ||||
| 841 | + | app.updateModeIndicatorToaster(context.Background(), bw, input.ModePage) | |||
| 842 | + | require.True(t, toaster.IsVisible()) | |||
| 843 | + | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal) | |||
| 844 | + | ||||
| 845 | + | assert.False(t, toaster.IsVisible(), "leaving Page Mode must hide its persistent toast") | |||
| 846 | + | } | |||
| 847 | + | ||||
| 848 | + | func TestPageMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) { | |||
| 849 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 850 | + | toaster, _, _ := newModeToasterForPageModeTest(t, factory) | |||
| 851 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})} | |||
| 852 | + | bw := &browserWindow{id: "win-1", modeToaster: toaster} | |||
| 853 | + | ||||
| 854 | + | app.updateModeIndicatorToaster(context.Background(), bw, input.ModePage) | |||
| 855 | + | ||||
| 856 | + | assert.False(t, toaster.IsVisible()) | |||
| 857 | + | } | |||
| 858 | + | ||||
| 859 | + | func TestPageMode_MultiWindowToastersExitIndependently(t *testing.T) { | |||
| 860 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 861 | + | toaster1, container1, label1 := newModeToasterForPageModeTest(t, factory) | |||
| 862 | + | toaster2, container2, label2 := newModeToasterForPageModeTest(t, factory) | |||
| 863 | + | expectPageModeToastShown(container1, label1) | |||
| 864 | + | expectPageModeToastShown(container2, label2) | |||
| 865 | + | container1.EXPECT().RemoveCssClass("toast-custom").Once() | |||
| 866 | + | container1.EXPECT().RemoveCssClass("toast-pane-mode").Once() | |||
| 867 | + | container1.EXPECT().SetVisible(false).Once() | |||
| 868 | + | ||||
| 869 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |||
| 870 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |||
| 871 | + | ModeIndicatorToasterEnabled: true, | |||
| 872 | + | }}}, | |||
| 873 | + | })} | |||
| 874 | + | bw1 := &browserWindow{id: "win-1", modeToaster: toaster1} | |||
| 875 | + | bw2 := &browserWindow{id: "win-2", modeToaster: toaster2} | |||
| 876 | + | ||||
| 877 | + | app.updateModeIndicatorToaster(context.Background(), bw1, input.ModePage) | |||
| 878 | + | app.updateModeIndicatorToaster(context.Background(), bw2, input.ModePage) | |||
| 879 | + | app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal) | |||
| 880 | + | ||||
| 881 | + | assert.False(t, toaster1.IsVisible()) | |||
| 882 | + | assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Page Mode toast") | |||
| 883 | + | } | |||
| 884 | + | ||||
| 885 | + | func TestPageMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) { | |||
| 886 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 887 | + | toaster, container, label := newModeToasterForPageModeTest(t, factory) | |||
| 888 | + | expectPageModeToastShown(container, label) | |||
| 889 | + | container.EXPECT().RemoveCssClass("toast-custom").Once() | |||
| 890 | + | container.EXPECT().RemoveCssClass("toast-pane-mode").Once() | |||
| 891 | + | container.EXPECT().SetVisible(false).Once() | |||
| 892 | + | ||||
| 893 | + | enabled := entity.RuntimeConfigSnapshot{ | |||
| 894 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |||
| 895 | + | ModeIndicatorToasterEnabled: true, | |||
| 896 | + | }}}, | |||
| 897 | + | } | |||
| 898 | + | keyboardHandler := newKeyboardHandlerInPageMode(t) | |||
| 899 | + | bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler} | |||
| 900 | + | app := &App{ | |||
| 901 | + | runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabled), | |||
| 902 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |||
| 903 | + | } | |||
| 904 | + | ||||
| 905 | + | app.updateModeIndicatorToaster(context.Background(), bw, input.ModePage) | |||
| 906 | + | require.True(t, toaster.IsVisible()) | |||
| 907 | + | app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{}) | |||
| 908 | + | ||||
| 909 | + | assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast") | |||
| 910 | + | } | |||
| 911 | + | ||||
| 912 | + | func TestPageMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) { | |||
| 913 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 914 | + | toaster, container, label := newModeToasterForPageModeTest(t, factory) | |||
| 915 | + | expectPageModeToastShown(container, label) | |||
| 916 | + | ||||
| 917 | + | keyboardHandler := newKeyboardHandlerInPageMode(t) | |||
| 918 | + | bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler} | |||
| 919 | + | app := &App{ | |||
| 920 | + | runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}), | |||
| 921 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |||
| 922 | + | } | |||
| 923 | + | enabled := entity.RuntimeConfigSnapshot{ | |||
| 924 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |||
| 925 | + | ModeIndicatorToasterEnabled: true, | |||
| 926 | + | }}}, | |||
| 927 | + | } | |||
| 928 | + | ||||
| 929 | + | app.applyRuntimeConfigChange(context.Background(), enabled) | |||
| 930 | + | ||||
| 931 | + | assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Page Mode") | |||
| 932 | + | } | |||
| 933 | + | ||||
| 934 | + | // ============================================================================ | |||
| 935 | + | // BorderManager — Page mode must NOT use the global border overlay | |||
| 936 | + | // ============================================================================ | |||
| 937 | + | ||||
| 938 | + | func TestPageMode_GlobalBorderOverlayStaysOff(t *testing.T) { | |||
| 939 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 940 | + | mockBox := mocks.NewMockBoxWidget(t) | |||
| 941 | + | ||||
| 942 | + | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once() | |||
| 943 | + | mockBox.EXPECT().SetCanFocus(false).Once() | |||
| 944 | + | mockBox.EXPECT().SetCanTarget(false).Once() | |||
| 945 | + | mockBox.EXPECT().SetHexpand(true).Once() | |||
| 946 | + | mockBox.EXPECT().SetVexpand(true).Once() | |||
| 947 | + | mockBox.EXPECT().SetVisible(false).Once() | |||
| 948 | + | ||||
| 949 | + | bm := focus.NewBorderManager(mockFactory) | |||
| 950 | + | ||||
| 951 | + | mockBox.EXPECT().SetVisible(false).Once() | |||
| 952 | + | bm.OnModeChange(context.Background(), input.ModeNormal, input.ModePage) | |||
| 953 | + | ||||
| 954 | + | mockBox.EXPECT().SetVisible(false).Once() | |||
| 955 | + | bm.OnModeChange(context.Background(), input.ModePage, input.ModeNormal) | |||
| 956 | + | } | |||
| 957 | + | ||||
| 958 | + | // ============================================================================ | |||
| 959 | + | // Pulse Debounce | |||
| 960 | + | // ============================================================================ | |||
| 961 | + | ||||
| 962 | + | func TestPageMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) { | |||
| 963 | + | f := newSingleWindowPageModeFixture(t) | |||
| 964 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 965 | + | ||||
| 966 | + | // Set up expectations for exactly ONE normal pulse. | |||
| 967 | + | setUpNormalPulse(f.overlay1A) | |||
| 968 | + | ||||
| 969 | + | // First call fires the pulse (debounce timer is zero). | |||
| 970 | + | f.app.triggerPageModePulse(context.Background(), false) | |||
| 971 | + | ||||
| 972 | + | // Second call within the same frame should be debounced — no | |||
| 973 | + | // mock expectations set up for it, so the test fails if it fires. | |||
| 974 | + | f.app.triggerPageModePulse(context.Background(), false) | |||
| 975 | + | } | |||
| 976 | + | ||||
| 977 | + | func TestPageMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) { | |||
| 978 | + | // Verify that the debounce timer starts at zero on a fresh App, | |||
| 979 | + | // so the first pulse after entering page mode always fires. | |||
| 980 | + | // The exit-and-re-enter path is covered by the | |||
| 981 | + | // TestPageMode_Pulse_DebounceResetOnClearOwnership test below. | |||
| 982 | + | ||||
| 983 | + | f := newSingleWindowPageModeFixture(t) | |||
| 984 | + | ||||
| 985 | + | // Fresh app: debounce timer is zero. | |||
| 986 | + | assert.True(t, f.app.pageModePulseLastTime.IsZero(), | |||
| 987 | + | "fresh app should have zero pulse timer") | |||
| 988 | + | ||||
| 989 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 990 | + | ||||
| 991 | + | // Pulse #1 fires because timer is zero (time.Since(zero) is huge). | |||
| 992 | + | setUpNormalPulse(f.overlay1A) | |||
| 993 | + | f.app.triggerPageModePulse(context.Background(), false) | |||
| 994 | + | ||||
| 995 | + | // Timer is now set, proving the first pulse registered. | |||
| 996 | + | assert.False(t, f.app.pageModePulseLastTime.IsZero(), | |||
| 997 | + | "pulse timer should be non-zero after first pulse") | |||
| 998 | + | } | |||
| 999 | + | ||||
| 1000 | + | func TestPageMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) { | |||
| 1001 | + | // Verify that clearPageModeOwnership resets the debounce timer | |||
| 1002 | + | // so a subsequent pulse in a new page mode session is not skipped. | |||
| 1003 | + | // We check the timer state directly rather than triggering a second | |||
| 1004 | + | // pulse (which would need pulse-cycle alternation handling). | |||
| 1005 | + | ||||
| 1006 | + | f := newSingleWindowPageModeFixture(t) | |||
| 1007 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 1008 | + | ||||
| 1009 | + | // Pulse #1 fires and sets the timer. | |||
| 1010 | + | setUpNormalPulse(f.overlay1A) | |||
| 1011 | + | f.app.triggerPageModePulse(context.Background(), false) | |||
| 1012 | + | assert.False(t, f.app.pageModePulseLastTime.IsZero(), | |||
| 1013 | + | "pulse timer should be non-zero after first pulse") | |||
| 1014 | + | ||||
| 1015 | + | // Clear ownership — this resets the debounce timer. | |||
| 1016 | + | expectPageModeAccentHidden(f.overlay1A) | |||
| 1017 | + | f.app.clearPageModeOwnership(context.Background(), f.bw1) | |||
| 1018 | + | assert.Empty(t, f.bw1.pageModePaneID) | |||
| 1019 | + | ||||
| 1020 | + | // Debounce timer should now be back to zero. | |||
| 1021 | + | assert.True(t, f.app.pageModePulseLastTime.IsZero(), | |||
| 1022 | + | "pulse timer should be reset after clearPageModeOwnership") | |||
| 1023 | + | } | |||
| 1024 | + | ||||
| 1025 | + | func TestPageMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) { | |||
| 1026 | + | // Even if triggerPageModePulse is called rapidly on a fresh | |||
| 1027 | + | // App (e.g., first keystroke after page mode entry), the first | |||
| 1028 | + | // call must always fire. This is guaranteed by the zero value | |||
| 1029 | + | // of time.Time producing a time.Since result much larger than | |||
| 1030 | + | // the debounce interval. | |||
| 1031 | + | f := newSingleWindowPageModeFixture(t) | |||
| 1032 | + | enterPageMode(t, f.app, f.overlay1A, f.bw1) | |||
| 1033 | + | ||||
| 1034 | + | // Rapid double pulse — only first should fire regardless of order. | |||
| 1035 | + | // Set up just one set of expectations. | |||
| 1036 | + | setUpNormalPulse(f.overlay1A) | |||
| 1037 | + | f.app.triggerPageModePulse(context.Background(), false) | |||
| 1038 | + | f.app.triggerPageModePulse(context.Background(), false) | |||
| 1039 | + | } | |||
| 1040 | + | ||||
| 1041 | + | // ============================================================================ | |||
| 1042 | + | // UI Scale: Page mode CSS uses relative (em) units | |||
| 1043 | + | // ============================================================================ | |||
| 1044 | + | ||||
| 1045 | + | func TestPageMode_CSSUsesEms(t *testing.T) { | |||
| 1046 | + | css := theme.GenerateCSS(theme.DefaultDarkPalette()) | |||
| 1047 | + | ||||
| 1048 | + | assert.Contains(t, css, "box-shadow: inset 0 0 0 0.125em", | |||
| 1049 | + | "page mode overlay box-shadow should use em for scaling") | |||
| 1050 | + | assert.Contains(t, css, "0.2em", | |||
| 1051 | + | "page mode overlay pulse glow radius uses em") | |||
| 1052 | + | assert.Contains(t, css, "0.26em", | |||
| 1053 | + | "page mode overlay fast-pulse glow radius uses em") | |||
| 1054 | + | assert.NotContains(t, css, "page-mode-indicator", | |||
| 1055 | + | "removed PAGE label styling must not remain") | |||
| 1056 | + | } | |||
| 1057 | + | ||||
| 1058 | + | func TestPageMode_CSSPulseTimingUsesDefaultTransitionDuration(t *testing.T) { | |||
| 1059 | + | // The pulse animation durations should be derived from the | |||
| 1060 | + | // default transition duration (normal = 3x, fast = 6x). | |||
| 1061 | + | // Default = 120ms → normal = 360ms, fast = 720ms. | |||
| 1062 | + | css := theme.GenerateCSS(theme.DefaultDarkPalette()) | |||
| 1063 | + | ||||
| 1064 | + | assert.Contains(t, css, "360ms", | |||
| 1065 | + | "normal pulse duration should be 3x transition duration (360ms for 120ms base)") | |||
| 1066 | + | assert.Contains(t, css, "720ms", | |||
| 1067 | + | "fast pulse duration should be 6x transition duration (720ms for 120ms base)") | |||
| 1068 | + | } | |||
| 1069 | + | ||||
| 1070 | + | func TestPageMode_CSSWithCustomTransitionDuration(t *testing.T) { | |||
| 1071 | + | // Custom transition duration (e.g., 200ms) should produce | |||
| 1072 | + | // adjusted pulse timings: normal = 600ms, fast = 1200ms. | |||
| 1073 | + | css := theme.GenerateCSSFullWithTiming( | |||
| 1074 | + | theme.DefaultDarkPalette(), | |||
| 1075 | + | 1.0, | |||
| 1076 | + | theme.DefaultFontConfig(), | |||
| 1077 | + | theme.DefaultModeColors(), | |||
| 1078 | + | 200, | |||
| 1079 | + | ) | |||
| 1080 | + | ||||
| 1081 | + | assert.Contains(t, css, "600ms", | |||
| 1082 | + | "normal pulse should be 3x custom transition duration (200ms -> 600ms)") | |||
| 1083 | + | assert.Contains(t, css, "1200ms", | |||
| 1084 | + | "fast pulse should be 6x custom transition duration (200ms -> 1200ms)") | |||
| 1085 | + | } | |||
M internal/ui/browser_window.go +4 -0
| @@ -52,6 +52,9 @@ type browserWindow struct { | |||||
| 52 | historySidebarReloader historySidebarReloader | 52 | historySidebarReloader historySidebarReloader | ||
| 53 | sidebarVisible bool | 53 | sidebarVisible bool | ||
| 54 | activeSidebarKind nativeSidebarKind | 54 | activeSidebarKind nativeSidebarKind | ||
| 55 | + | ||||
| 56 | + | // pageModePaneID tracks this window's pane-local Page Mode accent/pulse owner. | |||
| 57 | + | pageModePaneID entity.PaneID | |||
| 55 | } | 58 | } | ||
| 56 | 59 | ||||
| 57 | func (bw *browserWindow) detachInputForDestroy() { | 60 | func (bw *browserWindow) detachInputForDestroy() { | ||
| @@ -108,6 +111,7 @@ func (bw *browserWindow) clearShellState() { | |||||
| 108 | bw.favoritesSidebar = nil | 111 | bw.favoritesSidebar = nil | ||
| 109 | bw.historySidebarReloader = nil | 112 | bw.historySidebarReloader = nil | ||
| 110 | bw.activeSidebarKind = nativeSidebarNone | 113 | bw.activeSidebarKind = nativeSidebarNone | ||
| 114 | + | bw.pageModePaneID = "" | |||
| 111 | } | 115 | } | ||
| 112 | 116 | ||||
| 113 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | 117 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | ||
M internal/ui/component/pane_view.go +86 -0
| @@ -14,6 +14,27 @@ import ( | |||||
| 14 | const ( | 14 | const ( | ||
| 15 | // CSS class applied to active pane's border overlay | 15 | // CSS class applied to active pane's border overlay | ||
| 16 | activePaneClass = "pane-active" | 16 | activePaneClass = "pane-active" | ||
| 17 | + | ||||
| 18 | + | // pageModeActiveClass is added to the pane overlay when Page mode is active. | |||
| 19 | + | // It produces a subtle local border accent using the pane mode color. | |||
| 20 | + | pageModeActiveClass = "page-mode-active" | |||
| 21 | + | ||||
| 22 | + | // pageModePulseClass triggers a normal scroll pulse on the pane overlay. | |||
| 23 | + | // Repeated calls must remove-then-add to restart the CSS animation. | |||
| 24 | + | pageModePulseClass = "page-mode-pulse" | |||
| 25 | + | ||||
| 26 | + | // pageModeFastPulseClass triggers a fast/stronger scroll pulse. | |||
| 27 | + | pageModeFastPulseClass = "page-mode-pulse-fast" | |||
| 28 | + | ||||
| 29 | + | // Alternate equivalent animations so repeated scrolls reliably restart | |||
| 30 | + | // the GTK CSS animation. | |||
| 31 | + | pageModePulseCycleClassA = "page-mode-pulse-cycle-a" | |||
| 32 | + | pageModePulseCycleClassB = "page-mode-pulse-cycle-b" | |||
| 33 | + | ||||
| 34 | + | // Exported aliases for tests and external inspection. | |||
| 35 | + | PageModeActiveClass = pageModeActiveClass | |||
| 36 | + | PageModePulseClass = pageModePulseClass | |||
| 37 | + | PageModeFastPulseClass = pageModeFastPulseClass | |||
| 17 | ) | 38 | ) | ||
| 18 | 39 | ||||
| 19 | // PaneView is a container for a single WebView with active state indication. | 40 | // PaneView is a container for a single WebView with active state indication. | ||
| @@ -31,6 +52,10 @@ type PaneView struct { | |||||
| 31 | paneID entity.PaneID | 52 | paneID entity.PaneID | ||
| 32 | isActive bool | 53 | isActive bool | ||
| 33 | 54 | ||||
| 55 | + | // Page Mode pane-local visual state. | |||
| 56 | + | pageMode bool | |||
| 57 | + | pulseCycle bool | |||
| 58 | + | ||||
| 34 | onFocusIn func(paneID entity.PaneID) | 59 | onFocusIn func(paneID entity.PaneID) | ||
| 35 | onFocusOut func(paneID entity.PaneID) | 60 | onFocusOut func(paneID entity.PaneID) | ||
| 36 | onHover func(paneID entity.PaneID) | 61 | onHover func(paneID entity.PaneID) | ||
| @@ -92,6 +117,7 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit | |||||
| 92 | loading: loading, | 117 | loading: loading, | ||
| 93 | paneID: paneID, | 118 | paneID: paneID, | ||
| 94 | isActive: false, | 119 | isActive: false, | ||
| 120 | + | pageMode: false, | |||
| 95 | } | 121 | } | ||
| 96 | } | 122 | } | ||
| 97 | 123 | ||||
| @@ -520,6 +546,66 @@ func (pv *PaneView) HideLinkStatus() { | |||||
| 520 | } | 546 | } | ||
| 521 | } | 547 | } | ||
| 522 | 548 | ||||
| 549 | + | // SetPageMode activates or deactivates the pane-local Page Mode accent. | |||
| 550 | + | func (pv *PaneView) SetPageMode(active bool) { | |||
| 551 | + | pv.mu.Lock() | |||
| 552 | + | defer pv.mu.Unlock() | |||
| 553 | + | ||||
| 554 | + | if pv.pageMode == active { | |||
| 555 | + | return | |||
| 556 | + | } | |||
| 557 | + | ||||
| 558 | + | pv.pageMode = active | |||
| 559 | + | ||||
| 560 | + | if active { | |||
| 561 | + | pv.overlay.AddCssClass(pageModeActiveClass) | |||
| 562 | + | } else { | |||
| 563 | + | pv.overlay.RemoveCssClass(pageModeActiveClass) | |||
| 564 | + | } | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | // IsPageMode returns whether Page mode is currently active on this pane. | |||
| 568 | + | func (pv *PaneView) IsPageMode() bool { | |||
| 569 | + | pv.mu.RLock() | |||
| 570 | + | defer pv.mu.RUnlock() | |||
| 571 | + | ||||
| 572 | + | return pv.pageMode | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | // TriggerPageModePulse triggers a normal scroll pulse on the pane overlay. | |||
| 576 | + | func (pv *PaneView) TriggerPageModePulse() { | |||
| 577 | + | pv.triggerOverlayPulse(false) | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | // TriggerPageModePulseFast triggers a stronger, longer pane-overlay pulse. | |||
| 581 | + | func (pv *PaneView) TriggerPageModePulseFast() { | |||
| 582 | + | pv.triggerOverlayPulse(true) | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // triggerOverlayPulse adds a transient pulse CSS class to the pane overlay. | |||
| 586 | + | // fast=true uses the stronger/longer pulse animation. Both pulse classes are | |||
| 587 | + | // removed first to reliably re-trigger the animation. | |||
| 588 | + | func (pv *PaneView) triggerOverlayPulse(fast bool) { | |||
| 589 | + | pv.mu.Lock() | |||
| 590 | + | defer pv.mu.Unlock() | |||
| 591 | + | ||||
| 592 | + | pv.overlay.RemoveCssClass(pageModePulseClass) | |||
| 593 | + | pv.overlay.RemoveCssClass(pageModeFastPulseClass) | |||
| 594 | + | pv.overlay.RemoveCssClass(pageModePulseCycleClassA) | |||
| 595 | + | pv.overlay.RemoveCssClass(pageModePulseCycleClassB) | |||
| 596 | + | if fast { | |||
| 597 | + | pv.overlay.AddCssClass(pageModeFastPulseClass) | |||
| 598 | + | } else { | |||
| 599 | + | pv.overlay.AddCssClass(pageModePulseClass) | |||
| 600 | + | } | |||
| 601 | + | cycle := pageModePulseCycleClassA | |||
| 602 | + | if pv.pulseCycle { | |||
| 603 | + | cycle = pageModePulseCycleClassB | |||
| 604 | + | } | |||
| 605 | + | pv.pulseCycle = !pv.pulseCycle | |||
| 606 | + | pv.overlay.AddCssClass(cycle) | |||
| 607 | + | } | |||
| 608 | + | ||||
| 523 | // Cleanup removes the WebView widget from the overlay and clears references. | 609 | // Cleanup removes the WebView widget from the overlay and clears references. | ||
| 524 | // This must be called before destroying the WebView to ensure proper GTK cleanup. | 610 | // This must be called before destroying the WebView to ensure proper GTK cleanup. | ||
| 525 | // After calling Cleanup, the PaneView should not be reused. | 611 | // After calling Cleanup, the PaneView should not be reused. | ||
M internal/ui/component/pane_view_test.go +139 -0
| @@ -681,3 +681,142 @@ func setupPaneViewMocksNoWebView( | |||||
| 681 | mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once() | 681 | mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once() | ||
| 682 | mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once() | 682 | mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once() | ||
| 683 | } | 683 | } | ||
| 684 | + | ||||
| 685 | + | func TestSetPageMode_TrueAddsPaneAccentWithoutCreatingLabel(t *testing.T) { | |||
| 686 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 687 | + | mockOverlay := mocks.NewMockOverlayWidget(t) | |||
| 688 | + | mockBorderBox := mocks.NewMockBoxWidget(t) | |||
| 689 | + | mockWebView := mocks.NewMockWidget(t) | |||
| 690 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |||
| 691 | + | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |||
| 692 | + | ||||
| 693 | + | mockOverlay.EXPECT().AddCssClass("page-mode-active").Once() | |||
| 694 | + | pv.SetPageMode(true) | |||
| 695 | + | ||||
| 696 | + | require.True(t, pv.IsPageMode()) | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | func TestSetPageMode_FalseRemovesPaneAccent(t *testing.T) { | |||
| 700 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 701 | + | mockOverlay := mocks.NewMockOverlayWidget(t) | |||
| 702 | + | mockBorderBox := mocks.NewMockBoxWidget(t) | |||
| 703 | + | mockWebView := mocks.NewMockWidget(t) | |||
| 704 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |||
| 705 | + | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |||
| 706 | + | ||||
| 707 | + | mockOverlay.EXPECT().AddCssClass("page-mode-active").Once() | |||
| 708 | + | pv.SetPageMode(true) | |||
| 709 | + | mockOverlay.EXPECT().RemoveCssClass("page-mode-active").Once() | |||
| 710 | + | pv.SetPageMode(false) | |||
| 711 | + | ||||
| 712 | + | require.False(t, pv.IsPageMode()) | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | func TestSetPageMode_NoChangeWhenSameState(t *testing.T) { | |||
| 716 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 717 | + | mockOverlay := mocks.NewMockOverlayWidget(t) | |||
| 718 | + | mockBorderBox := mocks.NewMockBoxWidget(t) | |||
| 719 | + | mockWebView := mocks.NewMockWidget(t) | |||
| 720 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |||
| 721 | + | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |||
| 722 | + | ||||
| 723 | + | pv.SetPageMode(false) | |||
| 724 | + | ||||
| 725 | + | require.False(t, pv.IsPageMode()) | |||
| 726 | + | } | |||
| 727 | + | ||||
| 728 | + | func TestSetPageMode_TrueThenTrueIsIdempotent(t *testing.T) { | |||
| 729 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 730 | + | mockOverlay := mocks.NewMockOverlayWidget(t) | |||
| 731 | + | mockBorderBox := mocks.NewMockBoxWidget(t) | |||
| 732 | + | mockWebView := mocks.NewMockWidget(t) | |||
| 733 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |||
| 734 | + | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |||
| 735 | + | ||||
| 736 | + | mockOverlay.EXPECT().AddCssClass("page-mode-active").Once() | |||
| 737 | + | pv.SetPageMode(true) | |||
| 738 | + | pv.SetPageMode(true) | |||
| 739 | + | ||||
| 740 | + | require.True(t, pv.IsPageMode()) | |||
| 741 | + | } | |||
| 742 | + | ||||
| 743 | + | func expectPageModeOverlayPulse(overlay *mocks.MockOverlayWidget, fast bool, cycle string) { | |||
| 744 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse").Once() | |||
| 745 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse-fast").Once() | |||
| 746 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse-cycle-a").Once() | |||
| 747 | + | overlay.EXPECT().RemoveCssClass("page-mode-pulse-cycle-b").Once() | |||
| 748 | + | if fast { | |||
| 749 | + | overlay.EXPECT().AddCssClass("page-mode-pulse-fast").Once() | |||
| 750 | + | } else { | |||
| 751 | + | overlay.EXPECT().AddCssClass("page-mode-pulse").Once() | |||
| 752 | + | } | |||
| 753 | + | overlay.EXPECT().AddCssClass(cycle).Once() | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | func TestTriggerPageModePulse_PulsesOnlyOverlay(t *testing.T) { | |||
| 757 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 758 | + | mockOverlay := mocks.NewMockOverlayWidget(t) | |||
| 759 | + | mockBorderBox := mocks.NewMockBoxWidget(t) | |||
| 760 | + | mockWebView := mocks.NewMockWidget(t) | |||
| 761 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |||
| 762 | + | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |||
| 763 | + | ||||
| 764 | + | expectPageModeOverlayPulse(mockOverlay, false, "page-mode-pulse-cycle-a") | |||
| 765 | + | pv.TriggerPageModePulse() | |||
| 766 | + | } | |||
| 767 | + | ||||
| 768 | + | func TestTriggerPageModePulseFast_PulsesOnlyOverlay(t *testing.T) { | |||
| 769 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 770 | + | mockOverlay := mocks.NewMockOverlayWidget(t) | |||
| 771 | + | mockBorderBox := mocks.NewMockBoxWidget(t) | |||
| 772 | + | mockWebView := mocks.NewMockWidget(t) | |||
| 773 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |||
| 774 | + | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |||
| 775 | + | ||||
| 776 | + | expectPageModeOverlayPulse(mockOverlay, true, "page-mode-pulse-cycle-a") | |||
| 777 | + | pv.TriggerPageModePulseFast() | |||
| 778 | + | } | |||
| 779 | + | ||||
| 780 | + | func TestTriggerPageModePulse_RepeatedCallsReTriggerOverlayAnimation(t *testing.T) { | |||
| 781 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 782 | + | mockOverlay := mocks.NewMockOverlayWidget(t) | |||
| 783 | + | mockBorderBox := mocks.NewMockBoxWidget(t) | |||
| 784 | + | mockWebView := mocks.NewMockWidget(t) | |||
| 785 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |||
| 786 | + | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |||
| 787 | + | ||||
| 788 | + | expectPageModeOverlayPulse(mockOverlay, false, "page-mode-pulse-cycle-a") | |||
| 789 | + | pv.TriggerPageModePulse() | |||
| 790 | + | expectPageModeOverlayPulse(mockOverlay, true, "page-mode-pulse-cycle-b") | |||
| 791 | + | pv.TriggerPageModePulseFast() | |||
| 792 | + | expectPageModeOverlayPulse(mockOverlay, false, "page-mode-pulse-cycle-a") | |||
| 793 | + | pv.TriggerPageModePulse() | |||
| 794 | + | } | |||
| 795 | + | ||||
| 796 | + | func TestPageMode_CleanupHasNoIndicatorOverlay(t *testing.T) { | |||
| 797 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 798 | + | mockOverlay := mocks.NewMockOverlayWidget(t) | |||
| 799 | + | mockBorderBox := mocks.NewMockBoxWidget(t) | |||
| 800 | + | mockWebView := mocks.NewMockWidget(t) | |||
| 801 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |||
| 802 | + | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |||
| 803 | + | ||||
| 804 | + | mockOverlay.EXPECT().AddCssClass("page-mode-active").Once() | |||
| 805 | + | pv.SetPageMode(true) | |||
| 806 | + | mockOverlay.EXPECT().RemoveOverlay(mock.Anything).Once() | |||
| 807 | + | mockOverlay.EXPECT().SetChild(nil).Once() | |||
| 808 | + | ||||
| 809 | + | pv.Cleanup() | |||
| 810 | + | } | |||
| 811 | + | ||||
| 812 | + | func TestPageMode_NewPaneViewInactiveByDefault(t *testing.T) { | |||
| 813 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 814 | + | mockOverlay := mocks.NewMockOverlayWidget(t) | |||
| 815 | + | mockBorderBox := mocks.NewMockBoxWidget(t) | |||
| 816 | + | mockWebView := mocks.NewMockWidget(t) | |||
| 817 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |||
| 818 | + | ||||
| 819 | + | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |||
| 820 | + | ||||
| 821 | + | require.False(t, pv.IsPageMode()) | |||
| 822 | + | } | |||
M internal/ui/component/workspace_view.go +10 -0
| @@ -73,6 +73,16 @@ type WorkspaceView struct { | |||||
| 73 | mu sync.RWMutex | 73 | mu sync.RWMutex | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | // SetPaneViewsForTest injects pane views into the workspace view for testing. | |||
| 77 | + | // This is a narrow testability seam; production code populates pane views via | |||
| 78 | + | // SetWorkspace and the tree renderer. The workspace field must be set | |||
| 79 | + | // separately on the entity if the tests exercise code paths that read it. | |||
| 80 | + | func (wv *WorkspaceView) SetPaneViewsForTest(paneViews map[entity.PaneID]*PaneView) { | |||
| 81 | + | wv.mu.Lock() | |||
| 82 | + | defer wv.mu.Unlock() | |||
| 83 | + | wv.paneViews = paneViews | |||
| 84 | + | } | |||
| 85 | + | ||||
| 76 | // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface | 86 | // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface | ||
| 77 | // so TreeRenderer can create PaneViews. | 87 | // so TreeRenderer can create PaneViews. | ||
| 78 | type paneViewFactoryAdapter struct { | 88 | type paneViewFactoryAdapter struct { | ||
M internal/ui/coordinator/content/callbacks.go +6 -0
| @@ -179,6 +179,12 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||||
| 179 | } | 179 | } | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | + | callbacks.OnEditableFocusChanged = func(editable bool) { | |||
| 183 | + | if c.onEditableFocusChanged != nil { | |||
| 184 | + | c.onEditableFocusChanged(paneID, editable) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 182 | // Add popup create handler if popup handling is configured | 188 | // Add popup create handler if popup handling is configured | ||
| 183 | callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv) | 189 | callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv) | ||
| 184 | 190 | ||||
M internal/ui/coordinator/content/coordinator.go +8 -0
| @@ -109,6 +109,9 @@ type Coordinator struct { | |||||
| 109 | // Callback when WebView gains focus (for accent picker text input targeting) | 109 | // Callback when WebView gains focus (for accent picker text input targeting) | ||
| 110 | onWebViewFocused func(paneID entity.PaneID, wv port.WebView) | 110 | onWebViewFocused func(paneID entity.PaneID, wv port.WebView) | ||
| 111 | 111 | ||||
| 112 | + | // Callback when a page editable target gains or loses focus. | |||
| 113 | + | onEditableFocusChanged func(paneID entity.PaneID, editable bool) | |||
| 114 | + | ||||
| 112 | // Callback for first load_started event (triggers deferred initialization) | 115 | // Callback for first load_started event (triggers deferred initialization) | ||
| 113 | onFirstLoadStarted func() | 116 | onFirstLoadStarted func() | ||
| 114 | loadStartedOnce sync.Once | 117 | loadStartedOnce sync.Once | ||
| @@ -243,6 +246,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port. | |||||
| 243 | c.onWebViewFocused = fn | 246 | c.onWebViewFocused = fn | ||
| 244 | } | 247 | } | ||
| 245 | 248 | ||||
| 249 | + | // SetOnEditableFocusChanged sets the callback for page editable focus changes. | |||
| 250 | + | func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) { | |||
| 251 | + | c.onEditableFocusChanged = fn | |||
| 252 | + | } | |||
| 253 | + | ||||
| 246 | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | 254 | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | ||
| 247 | // This is used to trigger deferred initialization after the initial load_uri() | 255 | // This is used to trigger deferred initialization after the initial load_uri() | ||
| 248 | // has been processed by the GTK main loop. | 256 | // has been processed by the GTK main loop. | ||
A internal/ui/coordinator/content/editable_focus_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package content | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/mock" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestEditableFocusCallback_ForwardsPaneID(t *testing.T) { | |||
| 17 | + | c := newMinimalCoordinator() | |||
| 18 | + | wv := mocks.NewMockWebView(t) | |||
| 19 | + | ||||
| 20 | + | var callbacks *port.WebViewCallbacks | |||
| 21 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |||
| 22 | + | wv.EXPECT().Generation().Return(uint64(0)).Twice() | |||
| 23 | + | wv.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) { | |||
| 24 | + | callbacks = cb | |||
| 25 | + | }).Once() | |||
| 26 | + | ||||
| 27 | + | var gotPaneID entity.PaneID | |||
| 28 | + | var gotEditable bool | |||
| 29 | + | c.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) { | |||
| 30 | + | gotPaneID = paneID | |||
| 31 | + | gotEditable = editable | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | c.setupWebViewCallbacks(context.Background(), entity.PaneID("pane-1"), wv) | |||
| 35 | + | require.NotNil(t, callbacks) | |||
| 36 | + | require.NotNil(t, callbacks.OnEditableFocusChanged) | |||
| 37 | + | ||||
| 38 | + | callbacks.OnEditableFocusChanged(true) | |||
| 39 | + | ||||
| 40 | + | assert.Equal(t, entity.PaneID("pane-1"), gotPaneID) | |||
| 41 | + | assert.True(t, gotEditable) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestEditableFocusCallback_BackgroundPaneDoesNotConfusePaneOwnership(t *testing.T) { | |||
| 45 | + | c := newMinimalCoordinator() | |||
| 46 | + | c.SetActivePaneOverride(entity.PaneID("pane-a")) | |||
| 47 | + | ||||
| 48 | + | wvA := mocks.NewMockWebView(t) | |||
| 49 | + | wvB := mocks.NewMockWebView(t) | |||
| 50 | + | ||||
| 51 | + | var callbacksA *port.WebViewCallbacks | |||
| 52 | + | var callbacksB *port.WebViewCallbacks | |||
| 53 | + | wvA.EXPECT().ID().Return(port.WebViewID(1)).Once() | |||
| 54 | + | wvA.EXPECT().Generation().Return(uint64(0)).Twice() | |||
| 55 | + | wvA.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) { | |||
| 56 | + | callbacksA = cb | |||
| 57 | + | }).Once() | |||
| 58 | + | wvB.EXPECT().ID().Return(port.WebViewID(2)).Once() | |||
| 59 | + | wvB.EXPECT().Generation().Return(uint64(0)).Twice() | |||
| 60 | + | wvB.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) { | |||
| 61 | + | callbacksB = cb | |||
| 62 | + | }).Once() | |||
| 63 | + | ||||
| 64 | + | type event struct { | |||
| 65 | + | paneID entity.PaneID | |||
| 66 | + | editable bool | |||
| 67 | + | } | |||
| 68 | + | var events []event | |||
| 69 | + | c.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) { | |||
| 70 | + | events = append(events, event{paneID: paneID, editable: editable}) | |||
| 71 | + | }) | |||
| 72 | + | ||||
| 73 | + | c.setupWebViewCallbacks(context.Background(), entity.PaneID("pane-a"), wvA) | |||
| 74 | + | c.setupWebViewCallbacks(context.Background(), entity.PaneID("pane-b"), wvB) | |||
| 75 | + | require.NotNil(t, callbacksA) | |||
| 76 | + | require.NotNil(t, callbacksB) | |||
| 77 | + | ||||
| 78 | + | callbacksB.OnEditableFocusChanged(true) | |||
| 79 | + | callbacksA.OnEditableFocusChanged(false) | |||
| 80 | + | ||||
| 81 | + | require.Len(t, events, 2) | |||
| 82 | + | assert.Equal(t, event{paneID: entity.PaneID("pane-b"), editable: true}, events[0]) | |||
| 83 | + | assert.Equal(t, event{paneID: entity.PaneID("pane-a"), editable: false}, events[1]) | |||
| 84 | + | } | |||
M internal/ui/coordinator/navigation.go +51 -0
| @@ -23,6 +23,7 @@ type NavigationCoordinator struct { | |||||
| 23 | contextProvider func() context.Context | 23 | contextProvider func() context.Context | ||
| 24 | navigateUC *usecase.NavigateUseCase | 24 | navigateUC *usecase.NavigateUseCase | ||
| 25 | historyRecorder *usecase.HistoryRecorderUseCase | 25 | historyRecorder *usecase.HistoryRecorderUseCase | ||
| 26 | + | pageScrollUC *usecase.PageScrollUseCase | |||
| 26 | contentCoord *content.Coordinator | 27 | contentCoord *content.Coordinator | ||
| 27 | omniboxProvider OmniboxProvider | 28 | omniboxProvider OmniboxProvider | ||
| 28 | } | 29 | } | ||
| @@ -275,6 +276,56 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) { | |||||
| 275 | c.historyRecorder.ClearPaneHistory(string(paneID)) | 276 | c.historyRecorder.ClearPaneHistory(string(paneID)) | ||
| 276 | } | 277 | } | ||
| 277 | 278 | ||||
| 279 | + | // SetPageScrollUseCase sets the PageScrollUseCase for page scrolling operations. | |||
| 280 | + | // This is an optional dependency: ScrollWebView will return an error if not set. | |||
| 281 | + | func (c *NavigationCoordinator) SetPageScrollUseCase(uc *usecase.PageScrollUseCase) { | |||
| 282 | + | c.pageScrollUC = uc | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // ScrollWebView applies a semantic page scroll command to the provided WebView. | |||
| 286 | + | // It delegates to PageScrollUseCase for delta computation and Scrollable dispatch. | |||
| 287 | + | // The PageScrollUseCase must be set via SetPageScrollUseCase before calling this. | |||
| 288 | + | func (c *NavigationCoordinator) ScrollWebView(ctx context.Context, wv port.WebView, cmd usecase.PageScrollCommand) error { | |||
| 289 | + | log := logging.FromContext(ctx) | |||
| 290 | + | ||||
| 291 | + | if err := requireWebView(wv); err != nil { | |||
| 292 | + | log.Warn().Str("command", cmd.String()).Msg("ScrollWebView called with nil webview") | |||
| 293 | + | return err | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | log.Debug().Str("command", cmd.String()).Uint64("webview_id", uint64(wv.ID())).Msg("scrolling webview") | |||
| 297 | + | ||||
| 298 | + | if c.pageScrollUC == nil { | |||
| 299 | + | err := fmt.Errorf("page scroll usecase not set") | |||
| 300 | + | log.Error().Msg(err.Error()) | |||
| 301 | + | return err | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | return c.pageScrollUC.Scroll(ctx, wv, cmd) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // ScrollWebViewContinuous applies one autonomous held-key page-scroll step. | |||
| 308 | + | func (c *NavigationCoordinator) ScrollWebViewContinuous(ctx context.Context, wv port.WebView, cmd usecase.PageScrollCommand) error { | |||
| 309 | + | if err := requireWebView(wv); err != nil { | |||
| 310 | + | return err | |||
| 311 | + | } | |||
| 312 | + | if c.pageScrollUC == nil { | |||
| 313 | + | return fmt.Errorf("page scroll usecase not set") | |||
| 314 | + | } | |||
| 315 | + | return c.pageScrollUC.ScrollContinuous(ctx, wv, cmd) | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // StopPageScroll discards queued continuous deltas for adapters that support it. | |||
| 319 | + | func (c *NavigationCoordinator) StopPageScroll(ctx context.Context, wv port.WebView) error { | |||
| 320 | + | if err := requireWebView(wv); err != nil { | |||
| 321 | + | return err | |||
| 322 | + | } | |||
| 323 | + | if c.pageScrollUC == nil { | |||
| 324 | + | return fmt.Errorf("page scroll usecase not set") | |||
| 325 | + | } | |||
| 326 | + | return c.pageScrollUC.Stop(ctx, wv) | |||
| 327 | + | } | |||
| 328 | + | ||||
| 278 | // NotifyZoomChanged updates the omnibox zoom indicator. | 329 | // NotifyZoomChanged updates the omnibox zoom indicator. | ||
| 279 | func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) { | 330 | func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) { | ||
| 280 | if c.omniboxProvider != nil { | 331 | if c.omniboxProvider != nil { | ||
M internal/ui/coordinator/navigation_webview_targeted_test.go +74 -0
| @@ -6,9 +6,15 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/application/port/mocks" | 8 | "github.com/bnema/dumber/internal/application/port/mocks" | ||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 13 | + | type mockScrollableWebView struct { | |||
| 14 | + | *mocks.MockWebView | |||
| 15 | + | *mocks.MockPageScrollable | |||
| 16 | + | } | |||
| 17 | + | ||||
| 12 | type mockDevToolsWebView struct { | 18 | type mockDevToolsWebView struct { | ||
| 13 | *mocks.MockWebView | 19 | *mocks.MockWebView | ||
| 14 | *mocks.MockDevToolsOpener | 20 | *mocks.MockDevToolsOpener | ||
| @@ -76,6 +82,15 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi | |||||
| 76 | t.Run("PrintWebView calls PrintPage once", func(t *testing.T) { | 82 | t.Run("PrintWebView calls PrintPage once", func(t *testing.T) { | ||
| 77 | testPrintWebViewTargetsProvidedWebView(t, ctx) | 83 | testPrintWebViewTargetsProvidedWebView(t, ctx) | ||
| 78 | }) | 84 | }) | ||
| 85 | + | t.Run("ScrollWebView with nil webview returns error", func(t *testing.T) { | |||
| 86 | + | testScrollWebViewNilWebView(t, ctx) | |||
| 87 | + | }) | |||
| 88 | + | t.Run("ScrollWebView delegates each scroll command through usecase", func(t *testing.T) { | |||
| 89 | + | testScrollWebViewDelegatesEachCommand(t, ctx) | |||
| 90 | + | }) | |||
| 91 | + | t.Run("ScrollWebView usecase not set returns error", func(t *testing.T) { | |||
| 92 | + | testScrollWebViewUsecaseNotSet(t, ctx) | |||
| 93 | + | }) | |||
| 79 | } | 94 | } | ||
| 80 | 95 | ||||
| 81 | func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) { | 96 | func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) { | ||
| @@ -268,3 +283,62 @@ func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |||||
| 268 | t.Fatalf("unexpected error: %v", err) | 283 | t.Fatalf("unexpected error: %v", err) | ||
| 269 | } | 284 | } | ||
| 270 | } | 285 | } | ||
| 286 | + | ||||
| 287 | + | func testScrollWebViewNilWebView(t *testing.T, ctx context.Context) { | |||
| 288 | + | t.Helper() | |||
| 289 | + | c := &NavigationCoordinator{} | |||
| 290 | + | c.SetPageScrollUseCase(usecase.NewPageScrollUseCase()) | |||
| 291 | + | err := c.ScrollWebView(ctx, nil, usecase.PageScrollDown) | |||
| 292 | + | if err == nil { | |||
| 293 | + | t.Fatal("expected error for nil webview, got nil") | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func testScrollWebViewDelegatesEachCommand(t *testing.T, ctx context.Context) { | |||
| 298 | + | t.Helper() | |||
| 299 | + | commands := []struct { | |||
| 300 | + | cmd usecase.PageScrollCommand | |||
| 301 | + | dx int | |||
| 302 | + | dy int | |||
| 303 | + | }{ | |||
| 304 | + | {usecase.PageScrollLeft, -80, 0}, | |||
| 305 | + | {usecase.PageScrollRight, 80, 0}, | |||
| 306 | + | {usecase.PageScrollUp, 0, -80}, | |||
| 307 | + | {usecase.PageScrollDown, 0, 80}, | |||
| 308 | + | {usecase.PageScrollUpFast, 0, -320}, | |||
| 309 | + | {usecase.PageScrollDownFast, 0, 320}, | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | for _, tc := range commands { | |||
| 313 | + | t.Run(tc.cmd.String(), func(t *testing.T) { | |||
| 314 | + | base := mocks.NewMockWebView(t) | |||
| 315 | + | scroller := mocks.NewMockPageScrollable(t) | |||
| 316 | + | wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller} | |||
| 317 | + | base.EXPECT().ID().Return(port.WebViewID(42)).Once() | |||
| 318 | + | req := port.PageScrollRequest{Command: port.PageScrollCommand(tc.cmd), FallbackDX: tc.dx, FallbackDY: tc.dy} | |||
| 319 | + | scroller.EXPECT().ScrollPage(ctx, req).Return(nil).Once() | |||
| 320 | + | ||||
| 321 | + | c := &NavigationCoordinator{} | |||
| 322 | + | c.SetPageScrollUseCase(usecase.NewPageScrollUseCase()) | |||
| 323 | + | err := c.ScrollWebView(ctx, wv, tc.cmd) | |||
| 324 | + | if err != nil { | |||
| 325 | + | t.Fatalf("unexpected error for %s: %v", tc.cmd, err) | |||
| 326 | + | } | |||
| 327 | + | }) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func testScrollWebViewUsecaseNotSet(t *testing.T, ctx context.Context) { | |||
| 332 | + | t.Helper() | |||
| 333 | + | base := mocks.NewMockWebView(t) | |||
| 334 | + | wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: mocks.NewMockPageScrollable(t)} | |||
| 335 | + | base.EXPECT().ID().Return(port.WebViewID(1)).Once() | |||
| 336 | + | c := &NavigationCoordinator{} | |||
| 337 | + | err := c.ScrollWebView(ctx, wv, usecase.PageScrollDown) | |||
| 338 | + | if err == nil { | |||
| 339 | + | t.Fatal("expected error for missing usecase, got nil") | |||
| 340 | + | } | |||
| 341 | + | if got, want := err.Error(), "page scroll usecase not set"; got != want { | |||
| 342 | + | t.Fatalf("error = %q, want %q", got, want) | |||
| 343 | + | } | |||
| 344 | + | } | |||
M internal/ui/dispatcher/keyboard.go +91 -0
| @@ -52,6 +52,7 @@ type KeyboardDispatcher struct { | |||||
| 52 | onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error | 52 | onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error | ||
| 53 | onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error | 53 | onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error | ||
| 54 | onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error | 54 | onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error | ||
| 55 | + | onPageModePulse func(ctx context.Context, fast bool) | |||
| 55 | onToggleHistorySidebar func(ctx context.Context) error | 56 | onToggleHistorySidebar func(ctx context.Context) error | ||
| 56 | onToggleFavoritesSidebar func(ctx context.Context) error | 57 | onToggleFavoritesSidebar func(ctx context.Context) error | ||
| 57 | onToggleCurrentFavorite func(ctx context.Context) error | 58 | onToggleCurrentFavorite func(ctx context.Context) error | ||
| @@ -156,6 +157,22 @@ func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context | |||||
| 156 | d.onOpenFloating = fn | 157 | d.onOpenFloating = fn | ||
| 157 | } | 158 | } | ||
| 158 | 159 | ||||
| 160 | + | // SetOnPageModePulse registers a callback for pane-local Page mode pulse | |||
| 161 | + | // visual feedback triggered after a page scroll action. | |||
| 162 | + | // The cb receives a bool indicating whether the pulse should be "fast" | |||
| 163 | + | // (stronger/longer for up-fast/down-fast). | |||
| 164 | + | // If nil, pulse callbacks are no-ops. | |||
| 165 | + | func (d *KeyboardDispatcher) SetOnPageModePulse(cb func(ctx context.Context, fast bool)) { | |||
| 166 | + | d.onPageModePulse = cb | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // triggerPulse calls the registered pulse callback if set, or no-ops otherwise. | |||
| 170 | + | func (d *KeyboardDispatcher) triggerPulse(ctx context.Context, fast bool) { | |||
| 171 | + | if d.onPageModePulse != nil { | |||
| 172 | + | d.onPageModePulse(ctx, fast) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 159 | func (d *KeyboardDispatcher) initActionHandlers() { | 176 | func (d *KeyboardDispatcher) initActionHandlers() { | ||
| 160 | const ( | 177 | const ( | ||
| 161 | firstTabIndex = 0 | 178 | firstTabIndex = 0 | ||
| @@ -249,6 +266,13 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||||
| 249 | input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") }, | 266 | input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") }, | ||
| 250 | input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") }, | 267 | input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") }, | ||
| 251 | input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") }, | 268 | input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") }, | ||
| 269 | + | // Page mode scroll actions — single mapping drives tap pulse + command. | |||
| 270 | + | input.ActionPageScrollLeft: d.pageScrollTapHandler(input.ActionPageScrollLeft), | |||
| 271 | + | input.ActionPageScrollRight: d.pageScrollTapHandler(input.ActionPageScrollRight), | |||
| 272 | + | input.ActionPageScrollUp: d.pageScrollTapHandler(input.ActionPageScrollUp), | |||
| 273 | + | input.ActionPageScrollDown: d.pageScrollTapHandler(input.ActionPageScrollDown), | |||
| 274 | + | input.ActionPageScrollUpFast: d.pageScrollTapHandler(input.ActionPageScrollUpFast), | |||
| 275 | + | input.ActionPageScrollDownFast: d.pageScrollTapHandler(input.ActionPageScrollDownFast), | |||
| 252 | // UI | 276 | // UI | ||
| 253 | input.ActionOpenOmnibox: d.navCoord.OpenOmnibox, | 277 | input.ActionOpenOmnibox: d.navCoord.OpenOmnibox, | ||
| 254 | input.ActionOpenFind: d.handleFindOpen, | 278 | input.ActionOpenFind: d.handleFindOpen, | ||
| @@ -298,6 +322,73 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||||
| 298 | } | 322 | } | ||
| 299 | } | 323 | } | ||
| 300 | 324 | ||||
| 325 | + | // DispatchPageScrollLifecycle routes autonomous held-key page scroll events. | |||
| 326 | + | // Stop deliberately avoids the normal action map so it emits neither another | |||
| 327 | + | // scroll tap nor visual pulse. | |||
| 328 | + | func (d *KeyboardDispatcher) DispatchPageScrollLifecycle(ctx context.Context, action input.Action, phase input.PageScrollPhase) error { | |||
| 329 | + | spec, ok := pageScrollSpec(action) | |||
| 330 | + | if !ok { | |||
| 331 | + | return nil | |||
| 332 | + | } | |||
| 333 | + | return d.withActiveWebView(ctx, "page scroll lifecycle", func(wv port.WebView) error { | |||
| 334 | + | if phase == input.PageScrollStop { | |||
| 335 | + | return d.navCoord.StopPageScroll(ctx, wv) | |||
| 336 | + | } | |||
| 337 | + | return d.navCoord.ScrollWebViewContinuous(ctx, wv, spec.cmd) | |||
| 338 | + | }) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | type pageScrollActionSpec struct { | |||
| 342 | + | cmd usecase.PageScrollCommand | |||
| 343 | + | fast bool | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // pageScrollSpec is the single source of truth for Page Mode action→command | |||
| 347 | + | // and fast/slow pulse used by tap handlers and held lifecycle routing. | |||
| 348 | + | func pageScrollSpec(action input.Action) (pageScrollActionSpec, bool) { | |||
| 349 | + | switch action { | |||
| 350 | + | case input.ActionPageScrollLeft: | |||
| 351 | + | return pageScrollActionSpec{cmd: usecase.PageScrollLeft, fast: false}, true | |||
| 352 | + | case input.ActionPageScrollRight: | |||
| 353 | + | return pageScrollActionSpec{cmd: usecase.PageScrollRight, fast: false}, true | |||
| 354 | + | case input.ActionPageScrollUp: | |||
| 355 | + | return pageScrollActionSpec{cmd: usecase.PageScrollUp, fast: false}, true | |||
| 356 | + | case input.ActionPageScrollDown: | |||
| 357 | + | return pageScrollActionSpec{cmd: usecase.PageScrollDown, fast: false}, true | |||
| 358 | + | case input.ActionPageScrollUpFast: | |||
| 359 | + | return pageScrollActionSpec{cmd: usecase.PageScrollUpFast, fast: true}, true | |||
| 360 | + | case input.ActionPageScrollDownFast: | |||
| 361 | + | return pageScrollActionSpec{cmd: usecase.PageScrollDownFast, fast: true}, true | |||
| 362 | + | default: | |||
| 363 | + | return pageScrollActionSpec{}, false | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func (d *KeyboardDispatcher) pageScrollTapHandler(action input.Action) func(context.Context) error { | |||
| 368 | + | spec, ok := pageScrollSpec(action) | |||
| 369 | + | if !ok { | |||
| 370 | + | return func(context.Context) error { return nil } | |||
| 371 | + | } | |||
| 372 | + | label := "page scroll " + string(action) | |||
| 373 | + | return func(ctx context.Context) error { | |||
| 374 | + | return d.withActiveWebView(ctx, label, func(wv port.WebView) error { | |||
| 375 | + | if err := d.navCoord.ScrollWebView(ctx, wv, spec.cmd); err != nil { | |||
| 376 | + | return err | |||
| 377 | + | } | |||
| 378 | + | d.triggerPulse(ctx, spec.fast) | |||
| 379 | + | return nil | |||
| 380 | + | }) | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | func pageScrollCommand(action input.Action) (usecase.PageScrollCommand, bool) { | |||
| 385 | + | spec, ok := pageScrollSpec(action) | |||
| 386 | + | if !ok { | |||
| 387 | + | return 0, false | |||
| 388 | + | } | |||
| 389 | + | return spec.cmd, true | |||
| 390 | + | } | |||
| 391 | + | ||||
| 301 | // Dispatch routes a keyboard action to the appropriate coordinator. | 392 | // Dispatch routes a keyboard action to the appropriate coordinator. | ||
| 302 | func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error { | 393 | func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error { | ||
| 303 | log := logging.FromContext(ctx) | 394 | log := logging.FromContext(ctx) | ||
M internal/ui/dispatcher/keyboard_test.go +182 -0
| @@ -5,6 +5,9 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/ui/coordinator" | 12 | "github.com/bnema/dumber/internal/ui/coordinator" | ||
| 10 | "github.com/bnema/dumber/internal/ui/input" | 13 | "github.com/bnema/dumber/internal/ui/input" | ||
| @@ -202,3 +205,182 @@ func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) { | |||||
| 202 | }) | 205 | }) | ||
| 203 | } | 206 | } | ||
| 204 | } | 207 | } | ||
| 208 | + | ||||
| 209 | + | type mockScrollableWebView struct { | |||
| 210 | + | *mocks.MockWebView | |||
| 211 | + | *mocks.MockPageScrollable | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | type lifecycleScrollableWebView struct { | |||
| 215 | + | *mockScrollableWebView | |||
| 216 | + | cancelCalls int | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func (wv *lifecycleScrollableWebView) CancelPageScroll(context.Context) { | |||
| 220 | + | wv.cancelCalls++ | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func TestKeyboardDispatcher_PageModeActionsRouteToCorrectScrollCommand(t *testing.T) { | |||
| 224 | + | ctx := context.Background() | |||
| 225 | + | ||||
| 226 | + | tests := []struct { | |||
| 227 | + | action input.Action | |||
| 228 | + | cmd usecase.PageScrollCommand | |||
| 229 | + | expectedDx int | |||
| 230 | + | expectedDy int | |||
| 231 | + | }{ | |||
| 232 | + | {input.ActionPageScrollLeft, usecase.PageScrollLeft, -80, 0}, | |||
| 233 | + | {input.ActionPageScrollRight, usecase.PageScrollRight, 80, 0}, | |||
| 234 | + | {input.ActionPageScrollUp, usecase.PageScrollUp, 0, -80}, | |||
| 235 | + | {input.ActionPageScrollDown, usecase.PageScrollDown, 0, 80}, | |||
| 236 | + | {input.ActionPageScrollUpFast, usecase.PageScrollUpFast, 0, -320}, | |||
| 237 | + | {input.ActionPageScrollDownFast, usecase.PageScrollDownFast, 0, 320}, | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | for _, tc := range tests { | |||
| 241 | + | t.Run(string(tc.action), func(t *testing.T) { | |||
| 242 | + | base := mocks.NewMockWebView(t) | |||
| 243 | + | scroller := mocks.NewMockPageScrollable(t) | |||
| 244 | + | wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller} | |||
| 245 | + | ||||
| 246 | + | navCoord := &coordinator.NavigationCoordinator{} | |||
| 247 | + | navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase()) | |||
| 248 | + | ||||
| 249 | + | d := NewKeyboardDispatcher( | |||
| 250 | + | ctx, | |||
| 251 | + | &coordinator.WorkspaceCoordinator{}, | |||
| 252 | + | navCoord, | |||
| 253 | + | nil, | |||
| 254 | + | nil, | |||
| 255 | + | KeyboardActions{ | |||
| 256 | + | ActiveWebView: func(context.Context) port.WebView { return wv }, | |||
| 257 | + | }, | |||
| 258 | + | func(context.Context) entity.PaneID { return "" }, | |||
| 259 | + | ) | |||
| 260 | + | ||||
| 261 | + | base.EXPECT().ID().Return(port.WebViewID(42)).Once() | |||
| 262 | + | req := port.PageScrollRequest{Command: port.PageScrollCommand(tc.cmd), FallbackDX: tc.expectedDx, FallbackDY: tc.expectedDy} | |||
| 263 | + | scroller.EXPECT().ScrollPage(ctx, req).Return(nil).Once() | |||
| 264 | + | ||||
| 265 | + | err := d.Dispatch(ctx, tc.action) | |||
| 266 | + | require.NoError(t, err) | |||
| 267 | + | }) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func TestKeyboardDispatcher_PageScrollLifecycleRoutesContinuousAndStop(t *testing.T) { | |||
| 272 | + | ctx := context.Background() | |||
| 273 | + | base := mocks.NewMockWebView(t) | |||
| 274 | + | scroller := mocks.NewMockPageScrollable(t) | |||
| 275 | + | wv := &lifecycleScrollableWebView{mockScrollableWebView: &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}} | |||
| 276 | + | navCoord := &coordinator.NavigationCoordinator{} | |||
| 277 | + | navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase()) | |||
| 278 | + | d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, navCoord, nil, nil, KeyboardActions{ | |||
| 279 | + | ActiveWebView: func(context.Context) port.WebView { return wv }, | |||
| 280 | + | }, func(context.Context) entity.PaneID { return "" }) | |||
| 281 | + | ||||
| 282 | + | scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{ | |||
| 283 | + | Command: port.PageScrollCommandDown, FallbackDY: 80, Continuous: true, | |||
| 284 | + | }).Return(nil).Once() | |||
| 285 | + | require.NoError(t, d.DispatchPageScrollLifecycle(ctx, input.ActionPageScrollDown, input.PageScrollContinuous)) | |||
| 286 | + | require.NoError(t, d.DispatchPageScrollLifecycle(ctx, input.ActionPageScrollDown, input.PageScrollStop)) | |||
| 287 | + | assert.Equal(t, 1, wv.cancelCalls) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func TestKeyboardDispatcher_PageModeNoopWhenNoActiveWebView(t *testing.T) { | |||
| 291 | + | ctx := context.Background() | |||
| 292 | + | ||||
| 293 | + | navCoord := &coordinator.NavigationCoordinator{} | |||
| 294 | + | navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase()) | |||
| 295 | + | ||||
| 296 | + | d := NewKeyboardDispatcher( | |||
| 297 | + | ctx, | |||
| 298 | + | &coordinator.WorkspaceCoordinator{}, | |||
| 299 | + | navCoord, | |||
| 300 | + | nil, | |||
| 301 | + | nil, | |||
| 302 | + | KeyboardActions{}, | |||
| 303 | + | func(context.Context) entity.PaneID { return "" }, | |||
| 304 | + | ) | |||
| 305 | + | ||||
| 306 | + | // No ActiveWebView set — dispatcher should no-op cleanly | |||
| 307 | + | err := d.Dispatch(ctx, input.ActionPageScrollDown) | |||
| 308 | + | require.NoError(t, err) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func TestKeyboardDispatcher_PageModeNoopWhenActiveWebViewReturnsNil(t *testing.T) { | |||
| 312 | + | ctx := context.Background() | |||
| 313 | + | ||||
| 314 | + | navCoord := &coordinator.NavigationCoordinator{} | |||
| 315 | + | navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase()) | |||
| 316 | + | ||||
| 317 | + | d := NewKeyboardDispatcher( | |||
| 318 | + | ctx, | |||
| 319 | + | &coordinator.WorkspaceCoordinator{}, | |||
| 320 | + | navCoord, | |||
| 321 | + | nil, | |||
| 322 | + | nil, | |||
| 323 | + | KeyboardActions{ | |||
| 324 | + | ActiveWebView: func(context.Context) port.WebView { return nil }, | |||
| 325 | + | }, | |||
| 326 | + | func(context.Context) entity.PaneID { return "" }, | |||
| 327 | + | ) | |||
| 328 | + | ||||
| 329 | + | err := d.Dispatch(ctx, input.ActionPageScrollDown) | |||
| 330 | + | require.NoError(t, err) | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func TestPageScrollSpec_IsSingleSourceForCommandAndPulse(t *testing.T) { | |||
| 334 | + | tests := []struct { | |||
| 335 | + | action input.Action | |||
| 336 | + | cmd usecase.PageScrollCommand | |||
| 337 | + | fast bool | |||
| 338 | + | }{ | |||
| 339 | + | {input.ActionPageScrollLeft, usecase.PageScrollLeft, false}, | |||
| 340 | + | {input.ActionPageScrollRight, usecase.PageScrollRight, false}, | |||
| 341 | + | {input.ActionPageScrollUp, usecase.PageScrollUp, false}, | |||
| 342 | + | {input.ActionPageScrollDown, usecase.PageScrollDown, false}, | |||
| 343 | + | {input.ActionPageScrollUpFast, usecase.PageScrollUpFast, true}, | |||
| 344 | + | {input.ActionPageScrollDownFast, usecase.PageScrollDownFast, true}, | |||
| 345 | + | } | |||
| 346 | + | for _, tc := range tests { | |||
| 347 | + | spec, ok := pageScrollSpec(tc.action) | |||
| 348 | + | require.True(t, ok, string(tc.action)) | |||
| 349 | + | assert.Equal(t, tc.cmd, spec.cmd) | |||
| 350 | + | assert.Equal(t, tc.fast, spec.fast) | |||
| 351 | + | cmd, ok := pageScrollCommand(tc.action) | |||
| 352 | + | require.True(t, ok) | |||
| 353 | + | assert.Equal(t, tc.cmd, cmd) | |||
| 354 | + | } | |||
| 355 | + | _, ok := pageScrollSpec(input.ActionQuit) | |||
| 356 | + | assert.False(t, ok) | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | func TestKeyboardDispatcher_PageScrollTapUsesSharedSpecPulse(t *testing.T) { | |||
| 360 | + | ctx := context.Background() | |||
| 361 | + | base := mocks.NewMockWebView(t) | |||
| 362 | + | scroller := mocks.NewMockPageScrollable(t) | |||
| 363 | + | wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller} | |||
| 364 | + | navCoord := &coordinator.NavigationCoordinator{} | |||
| 365 | + | navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase()) | |||
| 366 | + | d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, navCoord, nil, nil, KeyboardActions{ | |||
| 367 | + | ActiveWebView: func(context.Context) port.WebView { return wv }, | |||
| 368 | + | }, func(context.Context) entity.PaneID { return "" }) | |||
| 369 | + | ||||
| 370 | + | var pulses []bool | |||
| 371 | + | d.SetOnPageModePulse(func(_ context.Context, fast bool) { | |||
| 372 | + | pulses = append(pulses, fast) | |||
| 373 | + | }) | |||
| 374 | + | ||||
| 375 | + | base.EXPECT().ID().Return(port.WebViewID(7)).Twice() | |||
| 376 | + | scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{ | |||
| 377 | + | Command: port.PageScrollCommandDown, FallbackDY: 80, | |||
| 378 | + | }).Return(nil).Once() | |||
| 379 | + | scroller.EXPECT().ScrollPage(ctx, port.PageScrollRequest{ | |||
| 380 | + | Command: port.PageScrollCommandDownFast, FallbackDY: 320, | |||
| 381 | + | }).Return(nil).Once() | |||
| 382 | + | ||||
| 383 | + | require.NoError(t, d.Dispatch(ctx, input.ActionPageScrollDown)) | |||
| 384 | + | require.NoError(t, d.Dispatch(ctx, input.ActionPageScrollDownFast)) | |||
| 385 | + | assert.Equal(t, []bool{false, true}, pulses) | |||
| 386 | + | } | |||
M internal/ui/focus/borders.go +8 -0
| @@ -61,6 +61,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) | |||||
| 61 | bm.borderOverlay.RemoveCssClass(tabModeClass) | 61 | bm.borderOverlay.RemoveCssClass(tabModeClass) | ||
| 62 | case input.ModeSession: | 62 | case input.ModeSession: | ||
| 63 | bm.borderOverlay.RemoveCssClass(sessionModeClass) | 63 | bm.borderOverlay.RemoveCssClass(sessionModeClass) | ||
| 64 | + | case input.ModePage: | |||
| 65 | + | // Page mode never activates the global border overlay. | |||
| 66 | + | // No class to remove. | |||
| 64 | } | 67 | } | ||
| 65 | 68 | ||||
| 66 | // Add new mode class and set visibility | 69 | // Add new mode class and set visibility | ||
| @@ -74,6 +77,11 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) | |||||
| 74 | case input.ModeSession: | 77 | case input.ModeSession: | ||
| 75 | bm.borderOverlay.AddCssClass(sessionModeClass) | 78 | bm.borderOverlay.AddCssClass(sessionModeClass) | ||
| 76 | bm.borderOverlay.SetVisible(true) | 79 | bm.borderOverlay.SetVisible(true) | ||
| 80 | + | case input.ModePage: | |||
| 81 | + | // Page mode must not activate the workspace-wide border overlay. | |||
| 82 | + | // The pane-local page-mode-active CSS class and indicator are | |||
| 83 | + | // managed by PaneView.SetPageMode / app handleModeChange. | |||
| 84 | + | bm.borderOverlay.SetVisible(false) | |||
| 77 | case input.ModeNormal: | 85 | case input.ModeNormal: | ||
| 78 | bm.borderOverlay.SetVisible(false) | 86 | bm.borderOverlay.SetVisible(false) | ||
| 79 | default: | 87 | default: | ||
M internal/ui/input/global_shortcuts.go +28 -4
| @@ -113,10 +113,16 @@ func NewGlobalShortcutHandler( | |||||
| 113 | } | 113 | } | ||
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | - | // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.) | 116 | + | // Register app-reserved shortcuts (Ctrl+L, Ctrl+F, other mode activations, etc.) |
| 117 | // so they work even when WebView has focus. | 117 | // so they work even when WebView has focus. | ||
| 118 | + | // Page mode activation is intentionally excluded here: it must flow through | |||
| 119 | + | // the normal KeyboardHandler path so editable page contexts can pass Ctrl+Y | |||
| 120 | + | // through instead of having a global shortcut controller consume it first. | |||
| 118 | shortcuts := NewShortcutSet(ctx, workspace, session) | 121 | shortcuts := NewShortcutSet(ctx, workspace, session) | ||
| 119 | for binding, action := range shortcuts.Global { | 122 | for binding, action := range shortcuts.Global { | ||
| 123 | + | if !shouldRegisterGTKGlobalShortcut(action) { | |||
| 124 | + | continue | |||
| 125 | + | } | |||
| 120 | if _, exists := h.registered[binding]; exists { | 126 | if _, exists := h.registered[binding]; exists { | ||
| 121 | continue | 127 | continue | ||
| 122 | } | 128 | } | ||
| @@ -292,6 +298,14 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, | |||||
| 292 | Msg("inactive window global shortcut callback ignored") | 298 | Msg("inactive window global shortcut callback ignored") | ||
| 293 | return false | 299 | return false | ||
| 294 | } | 300 | } | ||
| 301 | + | if h.kbHandler != nil && shouldIgnoreGlobalShortcutInMode(h.kbHandler.Mode(), actionToDispatch) { | |||
| 302 | + | log.Trace(). | |||
| 303 | + | Str("action", string(actionToDispatch)). | |||
| 304 | + | Str("shortcut", formatBinding(bindingForLog)). | |||
| 305 | + | Str("mode", h.kbHandler.Mode().String()). | |||
| 306 | + | Msg("global shortcut ignored in current modal mode") | |||
| 307 | + | return true | |||
| 308 | + | } | |||
| 295 | eventInfo := h.inspectCurrentShortcutEvent() | 309 | eventInfo := h.inspectCurrentShortcutEvent() | ||
| 296 | if !shouldDispatchGlobalShortcutEvent(eventInfo) { | 310 | if !shouldDispatchGlobalShortcutEvent(eventInfo) { | ||
| 297 | appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo). | 311 | appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo). | ||
| @@ -318,8 +332,7 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, | |||||
| 318 | // Mode-enter/exit actions go through KeyboardHandler for modal state. | 332 | // Mode-enter/exit actions go through KeyboardHandler for modal state. | ||
| 319 | if isModeAction(actionToDispatch) { | 333 | if isModeAction(actionToDispatch) { | ||
| 320 | if h.kbHandler != nil { | 334 | if h.kbHandler != nil { | ||
| 321 | - | h.kbHandler.DispatchAction(actionToDispatch) | 335 | + | return h.kbHandler.DispatchAction(actionToDispatch) |
| 322 | - | return true | |||
| 323 | } | 336 | } | ||
| 324 | log.Warn(). | 337 | log.Warn(). | ||
| 325 | Str("action", string(actionToDispatch)). | 338 | Str("action", string(actionToDispatch)). | ||
| @@ -481,6 +494,14 @@ func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, act | |||||
| 481 | return evt | 494 | return evt | ||
| 482 | } | 495 | } | ||
| 483 | 496 | ||||
| 497 | + | func shouldRegisterGTKGlobalShortcut(action Action) bool { | |||
| 498 | + | return action != ActionEnterPageMode | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool { | |||
| 502 | + | return mode == ModePage && action != ActionEnterPageMode | |||
| 503 | + | } | |||
| 504 | + | ||||
| 484 | func globalShortcutActionMap() map[string]Action { | 505 | func globalShortcutActionMap() map[string]Action { | ||
| 485 | return map[string]Action{ | 506 | return map[string]Action{ | ||
| 486 | "toggle_floating_pane": ActionToggleFloatingPane, | 507 | "toggle_floating_pane": ActionToggleFloatingPane, | ||
| @@ -563,6 +584,9 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||||
| 563 | 584 | ||||
| 564 | shortcuts := NewShortcutSet(ctx, workspace, session) | 585 | shortcuts := NewShortcutSet(ctx, workspace, session) | ||
| 565 | for binding, action := range shortcuts.Global { | 586 | for binding, action := range shortcuts.Global { | ||
| 587 | + | if !shouldRegisterGTKGlobalShortcut(action) { | |||
| 588 | + | continue | |||
| 589 | + | } | |||
| 566 | if _, exists := h.registered[binding]; exists { | 590 | if _, exists := h.registered[binding]; exists { | ||
| 567 | continue | 591 | continue | ||
| 568 | } | 592 | } | ||
| @@ -816,7 +840,7 @@ func formatEventType(eventType gdk.EventType) string { | |||||
| 816 | 840 | ||||
| 817 | func isModeAction(action Action) bool { | 841 | func isModeAction(action Action) bool { | ||
| 818 | switch action { | 842 | switch action { | ||
| 819 | - | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode: | 843 | + | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterPageMode, ActionExitMode: |
| 820 | return true | 844 | return true | ||
| 821 | default: | 845 | default: | ||
| 822 | return false | 846 | return false | ||
M internal/ui/input/global_shortcuts_test.go +21 -0
| @@ -9,6 +9,27 @@ import ( | |||||
| 9 | "github.com/bnema/puregotk/v4/gtk" | 9 | "github.com/bnema/puregotk/v4/gtk" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | + | func TestShouldRegisterGTKGlobalShortcut_ExcludesPageModeActivation(t *testing.T) { | |||
| 13 | + | if shouldRegisterGTKGlobalShortcut(ActionEnterPageMode) { | |||
| 14 | + | t.Fatal("ActionEnterPageMode should stay off the GTK global shortcut controller") | |||
| 15 | + | } | |||
| 16 | + | if !shouldRegisterGTKGlobalShortcut(ActionOpenOmnibox) { | |||
| 17 | + | t.Fatal("non-page-mode global shortcuts should remain registerable") | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestShouldIgnoreGlobalShortcutInMode_PageModeBlocksNonToggleActions(t *testing.T) { | |||
| 22 | + | if !shouldIgnoreGlobalShortcutInMode(ModePage, ActionOpenOmnibox) { | |||
| 23 | + | t.Fatal("Page mode should ignore app-global shortcuts like omnibox") | |||
| 24 | + | } | |||
| 25 | + | if shouldIgnoreGlobalShortcutInMode(ModeNormal, ActionOpenOmnibox) { | |||
| 26 | + | t.Fatal("normal mode should not ignore app-global shortcuts") | |||
| 27 | + | } | |||
| 28 | + | if shouldIgnoreGlobalShortcutInMode(ModePage, ActionEnterPageMode) { | |||
| 29 | + | t.Fatal("page mode toggle should not be blocked by the mode guard") | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 12 | func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) { | 33 | func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) { | ||
| 13 | h := &GlobalShortcutHandler{ | 34 | h := &GlobalShortcutHandler{ | ||
| 14 | registered: make(map[KeyBinding]Action), | 35 | registered: make(map[KeyBinding]Action), | ||
M internal/ui/input/keyboard.go +279 -10
| @@ -36,6 +36,24 @@ import ( | |||||
| 36 | // Return an error if the action fails. | 36 | // Return an error if the action fails. | ||
| 37 | type ActionHandler func(ctx context.Context, action Action) error | 37 | type ActionHandler func(ctx context.Context, action Action) error | ||
| 38 | 38 | ||||
| 39 | + | // PageScrollPhase identifies held-scroll lifecycle events. It is internal input | |||
| 40 | + | // state, not a configurable action or shortcut. | |||
| 41 | + | type PageScrollPhase uint8 | |||
| 42 | + | ||||
| 43 | + | const ( | |||
| 44 | + | PageScrollContinuous PageScrollPhase = iota | |||
| 45 | + | PageScrollStop | |||
| 46 | + | ) | |||
| 47 | + | ||||
| 48 | + | // PageScrollLifecycleHandler receives autonomous held-scroll ticks and the | |||
| 49 | + | // matching stop event without changing the normal configurable action API. | |||
| 50 | + | type PageScrollLifecycleHandler func(ctx context.Context, action Action, phase PageScrollPhase) error | |||
| 51 | + | ||||
| 52 | + | const ( | |||
| 53 | + | pageScrollHoldDelay = 250 * time.Millisecond | |||
| 54 | + | pageScrollCadence = 16 * time.Millisecond | |||
| 55 | + | ) | |||
| 56 | + | ||||
| 39 | // AccentHandler handles long-press accent detection. | 57 | // AccentHandler handles long-press accent detection. | ||
| 40 | // Called for character keys that may have accent variants. | 58 | // Called for character keys that may have accent variants. | ||
| 41 | type AccentHandler interface { | 59 | type AccentHandler interface { | ||
| @@ -58,8 +76,9 @@ type KeyboardHandler struct { | |||||
| 58 | workspace *entity.WorkspaceConfig | 76 | workspace *entity.WorkspaceConfig | ||
| 59 | session *entity.SessionConfig | 77 | session *entity.SessionConfig | ||
| 60 | 78 | ||||
| 61 | - | // Action handler callback | 79 | + | // Action handler callbacks. |
| 62 | - | onAction ActionHandler | 80 | + | onAction ActionHandler |
| 81 | + | onPageScrollLifecycle PageScrollLifecycleHandler | |||
| 63 | // Optional routing callback that determines how a key should be handled. | 82 | // Optional routing callback that determines how a key should be handled. | ||
| 64 | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | 83 | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | ||
| 65 | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | 84 | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | ||
| @@ -68,6 +87,9 @@ type KeyboardHandler struct { | |||||
| 68 | accentHandler AccentHandler | 87 | accentHandler AccentHandler | ||
| 69 | // Optional escape hook for app-level overlays | 88 | // Optional escape hook for app-level overlays | ||
| 70 | onEscape func(ctx context.Context) bool | 89 | onEscape func(ctx context.Context) bool | ||
| 90 | + | // Optional hook that blocks Page mode activation and lets the original key | |||
| 91 | + | // event pass through to the focused widget/page instead. | |||
| 92 | + | pageModeActivationPassthrough func() bool | |||
| 71 | 93 | ||||
| 72 | // GTK controller (nil until attached) | 94 | // GTK controller (nil until attached) | ||
| 73 | controller *gtk.EventControllerKey | 95 | controller *gtk.EventControllerKey | ||
| @@ -82,6 +104,16 @@ type KeyboardHandler struct { | |||||
| 82 | activePressedActions map[Action]uint | 104 | activePressedActions map[Action]uint | ||
| 83 | ctx context.Context | 105 | ctx context.Context | ||
| 84 | mu sync.RWMutex | 106 | mu sync.RWMutex | ||
| 107 | + | ||||
| 108 | + | pageScrollRepeatAction Action | |||
| 109 | + | pageScrollRepeatKeyval uint | |||
| 110 | + | pageScrollRepeatTimer uint | |||
| 111 | + | pageScrollRepeatGeneration uint64 | |||
| 112 | + | pageScrollRepeatPressedAt time.Time | |||
| 113 | + | pageScrollRepeatCb glib.SourceFunc | |||
| 114 | + | pageScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint | |||
| 115 | + | pageScrollRepeatRemove func(id uint) bool | |||
| 116 | + | pageScrollNow func() time.Time | |||
| 85 | } | 117 | } | ||
| 86 | 118 | ||||
| 87 | // NewKeyboardHandler creates a new keyboard handler. | 119 | // NewKeyboardHandler creates a new keyboard handler. | ||
| @@ -97,7 +129,13 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, | |||||
| 97 | session: session, | 129 | session: session, | ||
| 98 | activePressedActions: make(map[Action]uint), | 130 | activePressedActions: make(map[Action]uint), | ||
| 99 | ctx: ctx, | 131 | ctx: ctx, | ||
| 132 | + | pageScrollRepeatAdd: func(intervalMS uint, cb *glib.SourceFunc) uint { | |||
| 133 | + | return glib.TimeoutAdd(intervalMS, cb, 0) | |||
| 134 | + | }, | |||
| 135 | + | pageScrollRepeatRemove: glib.SourceRemove, | |||
| 136 | + | pageScrollNow: time.Now, | |||
| 100 | } | 137 | } | ||
| 138 | + | h.SetOnModeChange(nil) | |||
| 101 | 139 | ||||
| 102 | return h | 140 | return h | ||
| 103 | } | 141 | } | ||
| @@ -109,9 +147,26 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) { | |||||
| 109 | h.onAction = fn | 147 | h.onAction = fn | ||
| 110 | } | 148 | } | ||
| 111 | 149 | ||||
| 150 | + | // SetOnPageScrollLifecycle sets the callback for autonomous held-scroll ticks | |||
| 151 | + | // and stop notification. Tap actions continue through SetOnAction. | |||
| 152 | + | func (h *KeyboardHandler) SetOnPageScrollLifecycle(fn PageScrollLifecycleHandler) { | |||
| 153 | + | h.mu.Lock() | |||
| 154 | + | defer h.mu.Unlock() | |||
| 155 | + | h.onPageScrollLifecycle = fn | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // SetPageModeActivationPassthrough sets the callback that can block Page mode | |||
| 159 | + | // activation and let the original key event propagate instead. | |||
| 160 | + | func (h *KeyboardHandler) SetPageModeActivationPassthrough(fn func() bool) { | |||
| 161 | + | h.mu.Lock() | |||
| 162 | + | defer h.mu.Unlock() | |||
| 163 | + | h.pageModeActivationPassthrough = fn | |||
| 164 | + | } | |||
| 165 | + | ||||
| 112 | // ReloadShortcuts rebuilds the shortcut set from new config values. | 166 | // ReloadShortcuts rebuilds the shortcut set from new config values. | ||
| 113 | // This enables hot-reloading of keybindings without restarting. | 167 | // This enables hot-reloading of keybindings without restarting. | ||
| 114 | func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | 168 | func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | ||
| 169 | + | h.stopPageScrollRepeat() | |||
| 115 | h.mu.Lock() | 170 | h.mu.Lock() | ||
| 116 | defer h.mu.Unlock() | 171 | defer h.mu.Unlock() | ||
| 117 | 172 | ||||
| @@ -134,6 +189,9 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |||||
| 134 | } else if from == ModeNormal { | 189 | } else if from == ModeNormal { | ||
| 135 | h.setControllerPhase(gtk.PhaseCaptureValue) | 190 | h.setControllerPhase(gtk.PhaseCaptureValue) | ||
| 136 | } | 191 | } | ||
| 192 | + | if to != ModePage { | |||
| 193 | + | h.stopPageScrollRepeat() | |||
| 194 | + | } | |||
| 137 | // Forward to app-level callback | 195 | // Forward to app-level callback | ||
| 138 | if fn != nil { | 196 | if fn != nil { | ||
| 139 | fn(from, to) | 197 | fn(from, to) | ||
| @@ -257,8 +315,8 @@ func (h *KeyboardHandler) DetachForDestroy() { | |||||
| 257 | } | 315 | } | ||
| 258 | 316 | ||||
| 259 | func (h *KeyboardHandler) detach(removeController bool) { | 317 | func (h *KeyboardHandler) detach(removeController bool) { | ||
| 318 | + | h.stopPageScrollRepeat() | |||
| 260 | h.mu.Lock() | 319 | h.mu.Lock() | ||
| 261 | - | defer h.mu.Unlock() | |||
| 262 | if h.controller != nil && h.keyPressedHandlerID != 0 { | 320 | if h.controller != nil && h.keyPressedHandlerID != 0 { | ||
| 263 | h.controller.DisconnectSignal(h.keyPressedHandlerID) | 321 | h.controller.DisconnectSignal(h.keyPressedHandlerID) | ||
| 264 | } | 322 | } | ||
| @@ -275,6 +333,7 @@ func (h *KeyboardHandler) detach(removeController bool) { | |||||
| 275 | h.keyPressedHandlerID = 0 | 333 | h.keyPressedHandlerID = 0 | ||
| 276 | h.keyReleasedHandlerID = 0 | 334 | h.keyReleasedHandlerID = 0 | ||
| 277 | h.activePressedActions = nil | 335 | h.activePressedActions = nil | ||
| 336 | + | h.mu.Unlock() | |||
| 278 | } | 337 | } | ||
| 279 | 338 | ||||
| 280 | // handleKeyPress processes a key press event. | 339 | // handleKeyPress processes a key press event. | ||
| @@ -294,17 +353,31 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 294 | } | 353 | } | ||
| 295 | 354 | ||||
| 296 | modifiers := Modifier(state) & modifierMask | 355 | modifiers := Modifier(state) & modifierMask | ||
| 356 | + | mode := h.modal.Mode() | |||
| 297 | 357 | ||||
| 298 | // Escape in normal mode: check app-level escape hook first | 358 | // Escape in normal mode: check app-level escape hook first | ||
| 299 | - | if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { | 359 | + | if mode == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { |
| 300 | if onEscape != nil && onEscape(h.ctx) { | 360 | if onEscape != nil && onEscape(h.ctx) { | ||
| 301 | return true | 361 | return true | ||
| 302 | } | 362 | } | ||
| 303 | } | 363 | } | ||
| 304 | 364 | ||||
| 365 | + | // Escape / Enter should always leave modal modes even if the current config | |||
| 366 | + | // omits explicit confirm/cancel bindings. | |||
| 367 | + | if mode != ModeNormal && modifiers == 0 { | |||
| 368 | + | switch keyval { | |||
| 369 | + | case uint(gdk.KEY_Escape), uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |||
| 370 | + | if mode == ModePage { | |||
| 371 | + | h.stopPageScrollRepeat() | |||
| 372 | + | } | |||
| 373 | + | h.modal.ExitMode(h.ctx) | |||
| 374 | + | return true | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 305 | // Determine routing for this key event | 378 | // Determine routing for this key event | ||
| 306 | route := RouteHandleShortcuts // default: process through shortcut system | 379 | route := RouteHandleShortcuts // default: process through shortcut system | ||
| 307 | - | if routeKey != nil && h.modal.Mode() == ModeNormal { | 380 | + | if routeKey != nil && mode == ModeNormal { |
| 308 | route = routeKey(KeyContext{ | 381 | route = routeKey(KeyContext{ | ||
| 309 | Keyval: keyval, | 382 | Keyval: keyval, | ||
| 310 | Keycode: keycode, | 383 | Keycode: keycode, | ||
| @@ -338,22 +411,142 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 338 | keyval = normalizeKeyval(keyval) | 411 | keyval = normalizeKeyval(keyval) | ||
| 339 | 412 | ||||
| 340 | binding := KeyBinding{Keyval: keyval, Modifiers: modifiers} | 413 | binding := KeyBinding{Keyval: keyval, Modifiers: modifiers} | ||
| 341 | - | mode := h.modal.Mode() | |||
| 342 | action, found := h.lookupAction(log, binding, mode, modifiers, keycode) | 414 | action, found := h.lookupAction(log, binding, mode, modifiers, keycode) | ||
| 415 | + | return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers) | |||
| 416 | + | } | |||
| 343 | 417 | ||||
| 418 | + | func (h *KeyboardHandler) handleShortcutLookupResult( | |||
| 419 | + | log *zerolog.Logger, | |||
| 420 | + | action Action, | |||
| 421 | + | found bool, | |||
| 422 | + | mode Mode, | |||
| 423 | + | keyval uint, | |||
| 424 | + | modifiers Modifier, | |||
| 425 | + | ) bool { | |||
| 344 | if !found { | 426 | if !found { | ||
| 427 | + | if shouldPassthroughNativePageModeNavigation(mode, keyval, modifiers) { | |||
| 428 | + | log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in page mode") | |||
| 429 | + | return false | |||
| 430 | + | } | |||
| 345 | return mode != ModeNormal // Consume unrecognized keys in modal mode | 431 | return mode != ModeNormal // Consume unrecognized keys in modal mode | ||
| 346 | } | 432 | } | ||
| 433 | + | if h.shouldPassthroughPageModeActivation(action, mode) { | |||
| 434 | + | return false | |||
| 435 | + | } | |||
| 436 | + | if mode == ModePage && isPageScrollAction(action) { | |||
| 437 | + | return h.handlePageScrollAction(log, action, mode, keyval) | |||
| 438 | + | } | |||
| 347 | if h.suppressHeldAction(action, keyval) { | 439 | if h.suppressHeldAction(action, keyval) { | ||
| 348 | log.Trace(). | 440 | log.Trace(). | ||
| 349 | Str("action", string(action)). | 441 | Str("action", string(action)). | ||
| 350 | Msg("held keyboard action repeat suppressed") | 442 | Msg("held keyboard action repeat suppressed") | ||
| 351 | return true | 443 | return true | ||
| 352 | } | 444 | } | ||
| 353 | - | ||||
| 354 | return h.dispatchAction(action, mode) | 445 | return h.dispatchAction(action, mode) | ||
| 355 | } | 446 | } | ||
| 356 | 447 | ||||
| 448 | + | func (h *KeyboardHandler) handlePageScrollAction(log *zerolog.Logger, action Action, mode Mode, keyval uint) bool { | |||
| 449 | + | h.mu.RLock() | |||
| 450 | + | sameHeldKey := h.pageScrollRepeatAction == action && | |||
| 451 | + | h.pageScrollRepeatKeyval == keyval && h.pageScrollRepeatTimer != 0 | |||
| 452 | + | h.mu.RUnlock() | |||
| 453 | + | if sameHeldKey { | |||
| 454 | + | log.Trace().Str("action", string(action)).Msg("page scroll OS repeat suppressed") | |||
| 455 | + | return true | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | h.stopPageScrollRepeat() | |||
| 459 | + | if !h.dispatchAction(action, mode) { | |||
| 460 | + | return false | |||
| 461 | + | } | |||
| 462 | + | h.startPageScrollRepeat(action, keyval) | |||
| 463 | + | return true | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | func (h *KeyboardHandler) startPageScrollRepeat(action Action, keyval uint) { | |||
| 467 | + | h.mu.Lock() | |||
| 468 | + | if h.pageScrollRepeatTimer != 0 || h.pageScrollRepeatAdd == nil { | |||
| 469 | + | h.mu.Unlock() | |||
| 470 | + | return | |||
| 471 | + | } | |||
| 472 | + | h.pageScrollRepeatGeneration++ | |||
| 473 | + | generation := h.pageScrollRepeatGeneration | |||
| 474 | + | h.pageScrollRepeatAction = action | |||
| 475 | + | h.pageScrollRepeatKeyval = keyval | |||
| 476 | + | h.pageScrollRepeatPressedAt = h.pageScrollNow() | |||
| 477 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 478 | + | return h.pageScrollRepeatTick(action, keyval, generation) | |||
| 479 | + | }) | |||
| 480 | + | h.pageScrollRepeatCb = cb | |||
| 481 | + | add := h.pageScrollRepeatAdd | |||
| 482 | + | id := add(uint(pageScrollCadence/time.Millisecond), &h.pageScrollRepeatCb) | |||
| 483 | + | if id == 0 { | |||
| 484 | + | h.clearPageScrollRepeatLocked() | |||
| 485 | + | h.mu.Unlock() | |||
| 486 | + | return | |||
| 487 | + | } | |||
| 488 | + | h.pageScrollRepeatTimer = id | |||
| 489 | + | h.mu.Unlock() | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func (h *KeyboardHandler) pageScrollRepeatTick(action Action, keyval uint, generation uint64) bool { | |||
| 493 | + | h.mu.RLock() | |||
| 494 | + | active := h.pageScrollRepeatAction == action && h.pageScrollRepeatKeyval == keyval && | |||
| 495 | + | h.pageScrollRepeatTimer != 0 && h.pageScrollRepeatGeneration == generation | |||
| 496 | + | pressedAt := h.pageScrollRepeatPressedAt | |||
| 497 | + | now := h.pageScrollNow | |||
| 498 | + | h.mu.RUnlock() | |||
| 499 | + | if !active || h.modal.Mode() != ModePage { | |||
| 500 | + | return false | |||
| 501 | + | } | |||
| 502 | + | if now().Sub(pressedAt) < pageScrollHoldDelay { | |||
| 503 | + | return true | |||
| 504 | + | } | |||
| 505 | + | // Continuous dispatch bypasses dispatchAction, so refresh Page Mode timeout here. | |||
| 506 | + | h.modal.ResetTimeout(h.ctx) | |||
| 507 | + | h.dispatchPageScrollLifecycle(action, PageScrollContinuous) | |||
| 508 | + | return true | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | func (h *KeyboardHandler) stopPageScrollRepeat() { | |||
| 512 | + | h.mu.Lock() | |||
| 513 | + | action := h.pageScrollRepeatAction | |||
| 514 | + | timerID := h.pageScrollRepeatTimer | |||
| 515 | + | remove := h.pageScrollRepeatRemove | |||
| 516 | + | h.clearPageScrollRepeatLocked() | |||
| 517 | + | h.mu.Unlock() | |||
| 518 | + | if timerID != 0 && remove != nil { | |||
| 519 | + | remove(timerID) | |||
| 520 | + | } | |||
| 521 | + | if action != "" { | |||
| 522 | + | h.dispatchPageScrollLifecycle(action, PageScrollStop) | |||
| 523 | + | } | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | func (h *KeyboardHandler) clearPageScrollRepeatLocked() { | |||
| 527 | + | h.pageScrollRepeatGeneration++ | |||
| 528 | + | h.pageScrollRepeatAction = "" | |||
| 529 | + | h.pageScrollRepeatKeyval = 0 | |||
| 530 | + | h.pageScrollRepeatTimer = 0 | |||
| 531 | + | h.pageScrollRepeatPressedAt = time.Time{} | |||
| 532 | + | h.pageScrollRepeatCb = nil | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | func (h *KeyboardHandler) dispatchPageScrollLifecycle(action Action, phase PageScrollPhase) { | |||
| 536 | + | h.mu.RLock() | |||
| 537 | + | handler := h.onPageScrollLifecycle | |||
| 538 | + | h.mu.RUnlock() | |||
| 539 | + | if handler == nil { | |||
| 540 | + | if phase == PageScrollContinuous { | |||
| 541 | + | h.dispatchAction(action, ModePage) | |||
| 542 | + | } | |||
| 543 | + | return | |||
| 544 | + | } | |||
| 545 | + | if err := handler(h.ctx, action, phase); err != nil { | |||
| 546 | + | logging.FromContext(h.ctx).Error().Err(err).Str("action", string(action)).Msg("page scroll lifecycle handler error") | |||
| 547 | + | } | |||
| 548 | + | } | |||
| 549 | + | ||||
| 357 | // tryAccentDetection starts long-press detection for accent-eligible keys. | 550 | // tryAccentDetection starts long-press detection for accent-eligible keys. | ||
| 358 | // It is used only for the RouteAccentDetection route (GTK Entry widgets such as | 551 | // It is used only for the RouteAccentDetection route (GTK Entry widgets such as | ||
| 359 | // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection; | 552 | // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection; | ||
| @@ -382,6 +575,18 @@ func normalizeKeyval(keyval uint) uint { | |||||
| 382 | return keyval | 575 | return keyval | ||
| 383 | } | 576 | } | ||
| 384 | 577 | ||||
| 578 | + | func shouldPassthroughNativePageModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool { | |||
| 579 | + | if mode != ModePage || modifiers != ModNone { | |||
| 580 | + | return false | |||
| 581 | + | } | |||
| 582 | + | switch keyval { | |||
| 583 | + | case uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down): | |||
| 584 | + | return true | |||
| 585 | + | default: | |||
| 586 | + | return false | |||
| 587 | + | } | |||
| 588 | + | } | |||
| 589 | + | ||||
| 385 | // dispatchAction dispatches the action and handles mode-related logic. | 590 | // dispatchAction dispatches the action and handles mode-related logic. | ||
| 386 | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | 591 | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | ||
| 387 | if h.handleModeAction(action) { | 592 | if h.handleModeAction(action) { | ||
| @@ -402,6 +607,9 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |||||
| 402 | if mode == ModeResize && isResizeAction(action) { | 607 | if mode == ModeResize && isResizeAction(action) { | ||
| 403 | h.modal.ResetTimeout(h.ctx) | 608 | h.modal.ResetTimeout(h.ctx) | ||
| 404 | } | 609 | } | ||
| 610 | + | if mode == ModePage && isPageScrollAction(action) { | |||
| 611 | + | h.modal.ResetTimeout(h.ctx) | |||
| 612 | + | } | |||
| 405 | 613 | ||||
| 406 | if mode != ModeNormal && ShouldAutoExitMode(action) { | 614 | if mode != ModeNormal && ShouldAutoExitMode(action) { | ||
| 407 | h.modal.ExitMode(h.ctx) | 615 | h.modal.ExitMode(h.ctx) | ||
| @@ -436,6 +644,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool { | |||||
| 436 | ActionEnterPaneMode, | 644 | ActionEnterPaneMode, | ||
| 437 | ActionEnterSessionMode, | 645 | ActionEnterSessionMode, | ||
| 438 | ActionEnterResizeMode, | 646 | ActionEnterResizeMode, | ||
| 647 | + | ActionEnterPageMode, | |||
| 439 | ActionNewTab, | 648 | ActionNewTab, | ||
| 440 | ActionRenameTab, | 649 | ActionRenameTab, | ||
| 441 | ActionSplitRight, | 650 | ActionSplitRight, | ||
| @@ -512,6 +721,20 @@ func isResizeAction(action Action) bool { | |||||
| 512 | } | 721 | } | ||
| 513 | } | 722 | } | ||
| 514 | 723 | ||||
| 724 | + | func isPageScrollAction(action Action) bool { | |||
| 725 | + | switch action { | |||
| 726 | + | case ActionPageScrollLeft, | |||
| 727 | + | ActionPageScrollDown, | |||
| 728 | + | ActionPageScrollUp, | |||
| 729 | + | ActionPageScrollRight, | |||
| 730 | + | ActionPageScrollDownFast, | |||
| 731 | + | ActionPageScrollUpFast: | |||
| 732 | + | return true | |||
| 733 | + | default: | |||
| 734 | + | return false | |||
| 735 | + | } | |||
| 736 | + | } | |||
| 737 | + | ||||
| 515 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | 738 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | ||
| 516 | h.mu.RLock() | 739 | h.mu.RLock() | ||
| 517 | workspace := h.workspace | 740 | workspace := h.workspace | ||
| @@ -551,7 +774,22 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||||
| 551 | } | 774 | } | ||
| 552 | h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond) | 775 | h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond) | ||
| 553 | return true | 776 | return true | ||
| 777 | + | case ActionEnterPageMode: | |||
| 778 | + | if h.modal.Mode() == ModePage { | |||
| 779 | + | h.stopPageScrollRepeat() | |||
| 780 | + | h.modal.ExitMode(h.ctx) | |||
| 781 | + | return true | |||
| 782 | + | } | |||
| 783 | + | var pgms int | |||
| 784 | + | if workspace != nil { | |||
| 785 | + | pgms = workspace.PageMode.TimeoutMilliseconds | |||
| 786 | + | } | |||
| 787 | + | h.modal.EnterPageMode(h.ctx, time.Duration(pgms)*time.Millisecond) | |||
| 788 | + | return true | |||
| 554 | case ActionExitMode: | 789 | case ActionExitMode: | ||
| 790 | + | if h.modal.Mode() == ModePage { | |||
| 791 | + | h.stopPageScrollRepeat() | |||
| 792 | + | } | |||
| 555 | h.modal.ExitMode(h.ctx) | 793 | h.modal.ExitMode(h.ctx) | ||
| 556 | return true | 794 | return true | ||
| 557 | default: | 795 | default: | ||
| @@ -598,33 +836,64 @@ func (h *KeyboardHandler) EnterSessionMode() { | |||||
| 598 | h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond) | 836 | h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond) | ||
| 599 | } | 837 | } | ||
| 600 | 838 | ||||
| 839 | + | // EnterPageMode programmatically enters page scrolling mode. | |||
| 840 | + | // Useful for testing or programmatic mode changes. | |||
| 841 | + | func (h *KeyboardHandler) EnterPageMode() { | |||
| 842 | + | h.mu.RLock() | |||
| 843 | + | workspace := h.workspace | |||
| 844 | + | h.mu.RUnlock() | |||
| 845 | + | var ms int | |||
| 846 | + | if workspace != nil { | |||
| 847 | + | ms = workspace.PageMode.TimeoutMilliseconds | |||
| 848 | + | } | |||
| 849 | + | h.modal.EnterPageMode(h.ctx, time.Duration(ms)*time.Millisecond) | |||
| 850 | + | } | |||
| 851 | + | ||||
| 601 | // ExitMode programmatically exits modal mode. | 852 | // ExitMode programmatically exits modal mode. | ||
| 602 | // Useful for testing or programmatic mode changes. | 853 | // Useful for testing or programmatic mode changes. | ||
| 603 | func (h *KeyboardHandler) ExitMode() { | 854 | func (h *KeyboardHandler) ExitMode() { | ||
| 604 | h.modal.ExitMode(h.ctx) | 855 | h.modal.ExitMode(h.ctx) | ||
| 605 | } | 856 | } | ||
| 606 | 857 | ||||
| 858 | + | func (h *KeyboardHandler) shouldPassthroughPageModeActivation(action Action, mode Mode) bool { | |||
| 859 | + | if action != ActionEnterPageMode || mode != ModeNormal { | |||
| 860 | + | return false | |||
| 861 | + | } | |||
| 862 | + | h.mu.RLock() | |||
| 863 | + | passthrough := h.pageModeActivationPassthrough | |||
| 864 | + | h.mu.RUnlock() | |||
| 865 | + | return passthrough != nil && passthrough() | |||
| 866 | + | } | |||
| 867 | + | ||||
| 607 | // DispatchAction processes an action externally triggered (e.g., by a global | 868 | // DispatchAction processes an action externally triggered (e.g., by a global | ||
| 608 | // shortcut). Mode-enter actions update modal state; other actions are forwarded | 869 | // shortcut). Mode-enter actions update modal state; other actions are forwarded | ||
| 609 | - | // to the registered action handler. | 870 | + | // to the registered action handler. It returns whether the action was consumed. |
| 610 | - | func (h *KeyboardHandler) DispatchAction(action Action) { | 871 | + | func (h *KeyboardHandler) DispatchAction(action Action) bool { |
| 611 | mode := h.modal.Mode() | 872 | mode := h.modal.Mode() | ||
| 873 | + | if h.shouldPassthroughPageModeActivation(action, mode) { | |||
| 874 | + | return false | |||
| 875 | + | } | |||
| 612 | h.dispatchAction(action, mode) | 876 | h.dispatchAction(action, mode) | ||
| 877 | + | return true | |||
| 613 | } | 878 | } | ||
| 614 | 879 | ||||
| 615 | // handleKeyRelease processes a key release event for accent detection. | 880 | // handleKeyRelease processes a key release event for accent detection. | ||
| 616 | func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | 881 | func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | ||
| 882 | + | keyval = normalizeKeyval(keyval) | |||
| 617 | h.mu.Lock() | 883 | h.mu.Lock() | ||
| 618 | accentHandler := h.accentHandler | 884 | accentHandler := h.accentHandler | ||
| 619 | if len(h.activePressedActions) > 0 { | 885 | if len(h.activePressedActions) > 0 { | ||
| 620 | - | keyval = normalizeKeyval(keyval) | |||
| 621 | for action, pressedKeyval := range h.activePressedActions { | 886 | for action, pressedKeyval := range h.activePressedActions { | ||
| 622 | if pressedKeyval == keyval { | 887 | if pressedKeyval == keyval { | ||
| 623 | delete(h.activePressedActions, action) | 888 | delete(h.activePressedActions, action) | ||
| 624 | } | 889 | } | ||
| 625 | } | 890 | } | ||
| 626 | } | 891 | } | ||
| 892 | + | stopPageScroll := h.pageScrollRepeatKeyval == keyval | |||
| 627 | h.mu.Unlock() | 893 | h.mu.Unlock() | ||
| 894 | + | if stopPageScroll { | |||
| 895 | + | h.stopPageScrollRepeat() | |||
| 896 | + | } | |||
| 628 | 897 | ||||
| 629 | if accentHandler == nil { | 898 | if accentHandler == nil { | ||
| 630 | return | 899 | return | ||
M internal/ui/input/keyboard_test.go +711 -0
| @@ -3,9 +3,11 @@ package input | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | "time" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | "github.com/bnema/puregotk/v4/gdk" | 9 | "github.com/bnema/puregotk/v4/gdk" | ||
| 10 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 9 | "github.com/bnema/puregotk/v4/gtk" | 11 | "github.com/bnema/puregotk/v4/gtk" | ||
| 10 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 11 | ) | 13 | ) | ||
| @@ -27,6 +29,10 @@ func newTestSession() *entity.SessionConfig { | |||||
| 27 | } | 29 | } | ||
| 28 | } | 30 | } | ||
| 29 | 31 | ||||
| 32 | + | type fakeModalTimer struct{} | |||
| 33 | + | ||||
| 34 | + | func (*fakeModalTimer) Stop() bool { return true } | |||
| 35 | + | ||||
| 30 | // stubAccentHandler is a test stub for the AccentHandler interface. | 36 | // stubAccentHandler is a test stub for the AccentHandler interface. | ||
| 31 | type stubAccentHandler struct { | 37 | type stubAccentHandler struct { | ||
| 32 | onKeyPressedResult bool | 38 | onKeyPressedResult bool | ||
| @@ -362,3 +368,708 @@ func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T | |||||
| 362 | t.Fatal("ActionResizeIncreaseLeft should not be suppressed") | 368 | t.Fatal("ActionResizeIncreaseLeft should not be suppressed") | ||
| 363 | } | 369 | } | ||
| 364 | } | 370 | } | ||
| 371 | + | ||||
| 372 | + | func TestHandleKeyPress_EnterPageMode(t *testing.T) { | |||
| 373 | + | ctx := context.Background() | |||
| 374 | + | workspace := newTestWorkspace() | |||
| 375 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 376 | + | ActivationShortcut: "ctrl+y", | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 380 | + | ||||
| 381 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 382 | + | return nil | |||
| 383 | + | }) | |||
| 384 | + | ||||
| 385 | + | // Ctrl+Y should enter page mode | |||
| 386 | + | result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 387 | + | if !result { | |||
| 388 | + | t.Fatal("Ctrl+Y should be consumed") | |||
| 389 | + | } | |||
| 390 | + | if h.Mode() != ModePage { | |||
| 391 | + | t.Fatalf("mode = %v, want ModePage", h.Mode()) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func TestHandleKeyPress_PageModeStaysActiveAfterScroll(t *testing.T) { | |||
| 396 | + | ctx := context.Background() | |||
| 397 | + | workspace := newTestWorkspace() | |||
| 398 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 399 | + | ActivationShortcut: "ctrl+y", | |||
| 400 | + | Actions: map[string]entity.ActionBinding{ | |||
| 401 | + | "page-scroll-down": {Keys: []string{"j"}}, | |||
| 402 | + | "page-scroll-up": {Keys: []string{"k"}}, | |||
| 403 | + | "cancel": {Keys: []string{"escape"}}, | |||
| 404 | + | }, | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 408 | + | actionCalls := 0 | |||
| 409 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 410 | + | actionCalls++ | |||
| 411 | + | return nil | |||
| 412 | + | }) | |||
| 413 | + | ||||
| 414 | + | // Enter page mode | |||
| 415 | + | result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 416 | + | if !result || h.Mode() != ModePage { | |||
| 417 | + | t.Fatal("failed to enter page mode") | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | var repeatTick glib.SourceFunc | |||
| 421 | + | repeatNow := time.Unix(2_000, 0) | |||
| 422 | + | h.pageScrollNow = func() time.Time { return repeatNow } | |||
| 423 | + | h.pageScrollRepeatAdd = func(_ uint, cb *glib.SourceFunc) uint { | |||
| 424 | + | repeatTick = *cb | |||
| 425 | + | return 1 | |||
| 426 | + | } | |||
| 427 | + | h.pageScrollRepeatRemove = func(uint) bool { return true } | |||
| 428 | + | ||||
| 429 | + | // Scroll actions stay in page mode; the first press arms the smooth | |||
| 430 | + | // repeater and later physical auto-repeat events are ignored. | |||
| 431 | + | for i := 0; i < 2; i++ { | |||
| 432 | + | scrolled := h.handleKeyPress(uint('j'), 0, 0) | |||
| 433 | + | if !scrolled { | |||
| 434 | + | t.Fatalf("scroll down iteration %d: key not consumed", i) | |||
| 435 | + | } | |||
| 436 | + | if h.Mode() != ModePage { | |||
| 437 | + | t.Fatalf("scroll down iteration %d: mode = %v, want ModePage", i, h.Mode()) | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | if repeatTick == nil { | |||
| 441 | + | t.Fatal("expected held page scroll to register smooth repeater") | |||
| 442 | + | } | |||
| 443 | + | repeatNow = repeatNow.Add(pageScrollHoldDelay) | |||
| 444 | + | for i := 0; i < 3; i++ { | |||
| 445 | + | if !repeatTick(0) { | |||
| 446 | + | t.Fatalf("repeat tick %d stopped unexpectedly", i) | |||
| 447 | + | } | |||
| 448 | + | if h.Mode() != ModePage { | |||
| 449 | + | t.Fatalf("repeat tick %d: mode = %v, want ModePage", i, h.Mode()) | |||
| 450 | + | } | |||
| 451 | + | } | |||
| 452 | + | h.handleKeyRelease(uint('j')) | |||
| 453 | + | ||||
| 454 | + | // Scroll up also stays in page mode | |||
| 455 | + | result = h.handleKeyPress(uint('k'), 0, 0) | |||
| 456 | + | if !result || h.Mode() != ModePage { | |||
| 457 | + | t.Fatal("scroll up should stay in page mode") | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | // Ensure scroll actions were dispatched | |||
| 461 | + | if actionCalls != 5 { | |||
| 462 | + | t.Fatalf("action calls = %d, want 5 (4 down + 1 up)", actionCalls) | |||
| 463 | + | } | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | func TestHandleKeyPress_PageModeTimeoutReset(t *testing.T) { | |||
| 467 | + | ctx := context.Background() | |||
| 468 | + | workspace := newTestWorkspace() | |||
| 469 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 470 | + | ActivationShortcut: "ctrl+y", | |||
| 471 | + | TimeoutMilliseconds: 100, | |||
| 472 | + | Actions: map[string]entity.ActionBinding{ | |||
| 473 | + | "page-scroll-down": {Keys: []string{"j"}}, | |||
| 474 | + | "cancel": {Keys: []string{"escape"}}, | |||
| 475 | + | }, | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 479 | + | timerStarts := 0 | |||
| 480 | + | h.modal.afterFunc = func(d time.Duration, fn func()) modalTimer { | |||
| 481 | + | timerStarts++ | |||
| 482 | + | return &fakeModalTimer{} | |||
| 483 | + | } | |||
| 484 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 485 | + | return nil | |||
| 486 | + | }) | |||
| 487 | + | ||||
| 488 | + | // Enter page mode with timeout. | |||
| 489 | + | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 490 | + | if h.Mode() != ModePage { | |||
| 491 | + | t.Fatal("failed to enter page mode") | |||
| 492 | + | } | |||
| 493 | + | if timerStarts != 1 { | |||
| 494 | + | t.Fatalf("timer starts after entering page mode = %d, want 1", timerStarts) | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | // Scroll action should reset the timeout by starting a replacement timer. | |||
| 498 | + | h.handleKeyPress(uint('j'), 0, 0) | |||
| 499 | + | ||||
| 500 | + | if h.Mode() != ModePage { | |||
| 501 | + | t.Fatalf("mode after scroll = %v, want ModePage", h.Mode()) | |||
| 502 | + | } | |||
| 503 | + | if timerStarts != 2 { | |||
| 504 | + | t.Fatalf("timer starts after scroll reset = %d, want 2", timerStarts) | |||
| 505 | + | } | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | func TestPageScrollRepeatTick_RefreshesConfiguredPageModeTimeout(t *testing.T) { | |||
| 509 | + | ctx := context.Background() | |||
| 510 | + | workspace := newTestWorkspace() | |||
| 511 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 512 | + | ActivationShortcut: "ctrl+y", | |||
| 513 | + | TimeoutMilliseconds: 100, | |||
| 514 | + | Actions: map[string]entity.ActionBinding{ | |||
| 515 | + | "page-scroll-down": {Keys: []string{"j"}}, | |||
| 516 | + | }, | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 520 | + | timerStarts := 0 | |||
| 521 | + | h.modal.afterFunc = func(time.Duration, func()) modalTimer { | |||
| 522 | + | timerStarts++ | |||
| 523 | + | return &fakeModalTimer{} | |||
| 524 | + | } | |||
| 525 | + | var continuous int | |||
| 526 | + | h.SetOnPageScrollLifecycle(func(_ context.Context, _ Action, phase PageScrollPhase) error { | |||
| 527 | + | if phase == PageScrollContinuous { | |||
| 528 | + | continuous++ | |||
| 529 | + | } | |||
| 530 | + | return nil | |||
| 531 | + | }) | |||
| 532 | + | ||||
| 533 | + | var repeatTick glib.SourceFunc | |||
| 534 | + | repeatNow := time.Unix(3_000, 0) | |||
| 535 | + | h.pageScrollNow = func() time.Time { return repeatNow } | |||
| 536 | + | h.pageScrollRepeatAdd = func(_ uint, cb *glib.SourceFunc) uint { | |||
| 537 | + | repeatTick = *cb | |||
| 538 | + | return 1 | |||
| 539 | + | } | |||
| 540 | + | h.pageScrollRepeatRemove = func(uint) bool { return true } | |||
| 541 | + | ||||
| 542 | + | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 543 | + | if h.Mode() != ModePage { | |||
| 544 | + | t.Fatal("failed to enter page mode") | |||
| 545 | + | } | |||
| 546 | + | enterStarts := timerStarts | |||
| 547 | + | h.handleKeyPress(uint('j'), 0, 0) | |||
| 548 | + | if repeatTick == nil { | |||
| 549 | + | t.Fatal("expected repeater registration") | |||
| 550 | + | } | |||
| 551 | + | afterTap := timerStarts | |||
| 552 | + | if afterTap <= enterStarts { | |||
| 553 | + | t.Fatalf("tap should refresh timeout: enter=%d afterTap=%d", enterStarts, afterTap) | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | repeatNow = repeatNow.Add(pageScrollHoldDelay) | |||
| 557 | + | if !repeatTick(0) { | |||
| 558 | + | t.Fatal("continuous tick stopped unexpectedly") | |||
| 559 | + | } | |||
| 560 | + | if continuous != 1 { | |||
| 561 | + | t.Fatalf("continuous dispatches=%d, want 1", continuous) | |||
| 562 | + | } | |||
| 563 | + | if timerStarts <= afterTap { | |||
| 564 | + | t.Fatalf("continuous tick must refresh page-mode timeout: afterTap=%d afterTick=%d", afterTap, timerStarts) | |||
| 565 | + | } | |||
| 566 | + | if h.Mode() != ModePage { | |||
| 567 | + | t.Fatalf("mode after continuous tick = %v, want ModePage", h.Mode()) | |||
| 568 | + | } | |||
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | func TestHandleKeyPress_PageModeEscapeExits(t *testing.T) { | |||
| 572 | + | ctx := context.Background() | |||
| 573 | + | workspace := newTestWorkspace() | |||
| 574 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 575 | + | ActivationShortcut: "ctrl+y", | |||
| 576 | + | Actions: map[string]entity.ActionBinding{ | |||
| 577 | + | "page-scroll-down": {Keys: []string{"j"}}, | |||
| 578 | + | "cancel": {Keys: []string{"escape"}}, | |||
| 579 | + | }, | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 583 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 584 | + | return nil | |||
| 585 | + | }) | |||
| 586 | + | ||||
| 587 | + | // Enter page mode | |||
| 588 | + | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 589 | + | if h.Mode() != ModePage { | |||
| 590 | + | t.Fatal("failed to enter page mode") | |||
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | // Escape should exit page mode | |||
| 594 | + | result := h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) | |||
| 595 | + | if !result { | |||
| 596 | + | t.Fatal("Escape should be consumed") | |||
| 597 | + | } | |||
| 598 | + | if h.Mode() != ModeNormal { | |||
| 599 | + | t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode()) | |||
| 600 | + | } | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | func TestHandleKeyPress_PageModeEnterExits(t *testing.T) { | |||
| 604 | + | ctx := context.Background() | |||
| 605 | + | workspace := newTestWorkspace() | |||
| 606 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 607 | + | ActivationShortcut: "ctrl+y", | |||
| 608 | + | Actions: map[string]entity.ActionBinding{ | |||
| 609 | + | "confirm": {Keys: []string{"enter"}}, | |||
| 610 | + | }, | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 614 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 615 | + | return nil | |||
| 616 | + | }) | |||
| 617 | + | ||||
| 618 | + | // Enter page mode | |||
| 619 | + | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 620 | + | if h.Mode() != ModePage { | |||
| 621 | + | t.Fatal("failed to enter page mode") | |||
| 622 | + | } | |||
| 623 | + | ||||
| 624 | + | // Enter should exit page mode | |||
| 625 | + | result := h.handleKeyPress(uint(gdk.KEY_Return), 0, 0) | |||
| 626 | + | if !result { | |||
| 627 | + | t.Fatal("Enter should be consumed") | |||
| 628 | + | } | |||
| 629 | + | if h.Mode() != ModeNormal { | |||
| 630 | + | t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode()) | |||
| 631 | + | } | |||
| 632 | + | } | |||
| 633 | + | ||||
| 634 | + | func TestHandleKeyPress_PageModeActivationTogglesOff(t *testing.T) { | |||
| 635 | + | ctx := context.Background() | |||
| 636 | + | workspace := newTestWorkspace() | |||
| 637 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 638 | + | ActivationShortcut: "ctrl+y", | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 642 | + | ||||
| 643 | + | if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) { | |||
| 644 | + | t.Fatal("first Ctrl+Y should be consumed") | |||
| 645 | + | } | |||
| 646 | + | if h.Mode() != ModePage { | |||
| 647 | + | t.Fatalf("mode after first Ctrl+Y = %v, want ModePage", h.Mode()) | |||
| 648 | + | } | |||
| 649 | + | ||||
| 650 | + | h.handleKeyRelease(uint('y')) | |||
| 651 | + | if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) { | |||
| 652 | + | t.Fatal("second Ctrl+Y should be consumed") | |||
| 653 | + | } | |||
| 654 | + | if h.Mode() != ModeNormal { | |||
| 655 | + | t.Fatalf("mode after second Ctrl+Y = %v, want ModeNormal", h.Mode()) | |||
| 656 | + | } | |||
| 657 | + | } | |||
| 658 | + | ||||
| 659 | + | func TestHandleKeyPress_PageModeEscapeExitsWithoutCancelBinding(t *testing.T) { | |||
| 660 | + | ctx := context.Background() | |||
| 661 | + | workspace := newTestWorkspace() | |||
| 662 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 663 | + | ActivationShortcut: "ctrl+y", | |||
| 664 | + | Actions: map[string]entity.ActionBinding{ | |||
| 665 | + | "page-scroll-down": {Keys: []string{"j"}}, | |||
| 666 | + | }, | |||
| 667 | + | } | |||
| 668 | + | ||||
| 669 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 670 | + | if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) { | |||
| 671 | + | t.Fatal("Ctrl+Y should be consumed") | |||
| 672 | + | } | |||
| 673 | + | if h.Mode() != ModePage { | |||
| 674 | + | t.Fatal("failed to enter page mode") | |||
| 675 | + | } | |||
| 676 | + | ||||
| 677 | + | if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) { | |||
| 678 | + | t.Fatal("Escape should still be consumed without explicit cancel binding") | |||
| 679 | + | } | |||
| 680 | + | if h.Mode() != ModeNormal { | |||
| 681 | + | t.Fatalf("mode after Escape fallback = %v, want ModeNormal", h.Mode()) | |||
| 682 | + | } | |||
| 683 | + | } | |||
| 684 | + | ||||
| 685 | + | func TestHandleKeyPress_PageModeActivationNotInPassThrough(t *testing.T) { | |||
| 686 | + | ctx := context.Background() | |||
| 687 | + | workspace := newTestWorkspace() | |||
| 688 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 689 | + | ActivationShortcut: "ctrl+y", | |||
| 690 | + | } | |||
| 691 | + | ||||
| 692 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 693 | + | ||||
| 694 | + | // Route ALL keys to widget (editable pass-through context) | |||
| 695 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 696 | + | return RoutePassToWidget | |||
| 697 | + | }) | |||
| 698 | + | ||||
| 699 | + | // Ctrl+Y should NOT activate page mode because route says pass to widget | |||
| 700 | + | result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 701 | + | if result { | |||
| 702 | + | t.Fatal("Ctrl+Y should NOT be consumed when routed to widget") | |||
| 703 | + | } | |||
| 704 | + | if h.Mode() != ModeNormal { | |||
| 705 | + | t.Fatalf("mode = %v, want ModeNormal (should not enter page mode)", h.Mode()) | |||
| 706 | + | } | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | func TestHandleKeyPress_PageModeActivationPassesThroughWhenBlocked(t *testing.T) { | |||
| 710 | + | ctx := context.Background() | |||
| 711 | + | workspace := newTestWorkspace() | |||
| 712 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 713 | + | ActivationShortcut: "ctrl+y", | |||
| 714 | + | } | |||
| 715 | + | ||||
| 716 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 717 | + | h.SetPageModeActivationPassthrough(func() bool { return true }) | |||
| 718 | + | ||||
| 719 | + | result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 720 | + | if result { | |||
| 721 | + | t.Fatal("Ctrl+Y should pass through when page mode activation is blocked") | |||
| 722 | + | } | |||
| 723 | + | if h.Mode() != ModeNormal { | |||
| 724 | + | t.Fatalf("mode = %v, want ModeNormal", h.Mode()) | |||
| 725 | + | } | |||
| 726 | + | } | |||
| 727 | + | ||||
| 728 | + | func TestDispatchAction_PageModeActivationPassesThroughWhenBlocked(t *testing.T) { | |||
| 729 | + | ctx := context.Background() | |||
| 730 | + | workspace := newTestWorkspace() | |||
| 731 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 732 | + | ActivationShortcut: "ctrl+y", | |||
| 733 | + | } | |||
| 734 | + | ||||
| 735 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 736 | + | h.SetPageModeActivationPassthrough(func() bool { return true }) | |||
| 737 | + | ||||
| 738 | + | consumed := h.DispatchAction(ActionEnterPageMode) | |||
| 739 | + | if consumed { | |||
| 740 | + | t.Fatal("DispatchAction should not consume blocked page mode activation") | |||
| 741 | + | } | |||
| 742 | + | if h.Mode() != ModeNormal { | |||
| 743 | + | t.Fatalf("mode = %v, want ModeNormal", h.Mode()) | |||
| 744 | + | } | |||
| 745 | + | } | |||
| 746 | + | ||||
| 747 | + | func TestHandleKeyPress_PageModeFastScrollLookup(t *testing.T) { | |||
| 748 | + | tests := []struct { | |||
| 749 | + | name string | |||
| 750 | + | configAction string | |||
| 751 | + | key uint | |||
| 752 | + | expected Action | |||
| 753 | + | }{ | |||
| 754 | + | { | |||
| 755 | + | name: "scroll down fast", | |||
| 756 | + | configAction: "page-scroll-down-fast", | |||
| 757 | + | key: uint('d'), | |||
| 758 | + | expected: ActionPageScrollDownFast, | |||
| 759 | + | }, | |||
| 760 | + | { | |||
| 761 | + | name: "scroll up fast", | |||
| 762 | + | configAction: "page-scroll-up-fast", | |||
| 763 | + | key: uint('u'), | |||
| 764 | + | expected: ActionPageScrollUpFast, | |||
| 765 | + | }, | |||
| 766 | + | } | |||
| 767 | + | ||||
| 768 | + | for _, tt := range tests { | |||
| 769 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 770 | + | ctx := context.Background() | |||
| 771 | + | workspace := newTestWorkspace() | |||
| 772 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 773 | + | ActivationShortcut: "ctrl+y", | |||
| 774 | + | Actions: map[string]entity.ActionBinding{ | |||
| 775 | + | tt.configAction: {Keys: []string{"ctrl+" + string(rune(tt.key))}}, | |||
| 776 | + | }, | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 780 | + | actionCalls := 0 | |||
| 781 | + | var lastAction Action | |||
| 782 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 783 | + | actionCalls++ | |||
| 784 | + | lastAction = action | |||
| 785 | + | return nil | |||
| 786 | + | }) | |||
| 787 | + | ||||
| 788 | + | // Enter page mode. | |||
| 789 | + | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 790 | + | if h.Mode() != ModePage { | |||
| 791 | + | t.Fatal("failed to enter page mode") | |||
| 792 | + | } | |||
| 793 | + | ||||
| 794 | + | // Fast-scroll shortcut in page mode should dispatch the expected action. | |||
| 795 | + | result := h.handleKeyPress(tt.key, 0, gdk.ControlMaskValue) | |||
| 796 | + | if !result { | |||
| 797 | + | t.Fatal("fast scroll shortcut in page mode should be consumed") | |||
| 798 | + | } | |||
| 799 | + | if actionCalls != 1 || lastAction != tt.expected { | |||
| 800 | + | t.Fatalf("expected %s, got action=%s calls=%d", tt.expected, lastAction, actionCalls) | |||
| 801 | + | } | |||
| 802 | + | if h.Mode() != ModePage { | |||
| 803 | + | t.Fatal("should stay in page mode after scroll action") | |||
| 804 | + | } | |||
| 805 | + | }) | |||
| 806 | + | } | |||
| 807 | + | } | |||
| 808 | + | ||||
| 809 | + | func TestHandleKeyPress_PageModeNoAutoExitForScrollActions(t *testing.T) { | |||
| 810 | + | ctx := context.Background() | |||
| 811 | + | workspace := newTestWorkspace() | |||
| 812 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 813 | + | ActivationShortcut: "ctrl+y", | |||
| 814 | + | Actions: map[string]entity.ActionBinding{ | |||
| 815 | + | "page-scroll-left": {Keys: []string{"h"}}, | |||
| 816 | + | "page-scroll-right": {Keys: []string{"l"}}, | |||
| 817 | + | }, | |||
| 818 | + | } | |||
| 819 | + | ||||
| 820 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 821 | + | actionCalls := 0 | |||
| 822 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 823 | + | actionCalls++ | |||
| 824 | + | return nil | |||
| 825 | + | }) | |||
| 826 | + | ||||
| 827 | + | // Enter page mode | |||
| 828 | + | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 829 | + | if h.Mode() != ModePage { | |||
| 830 | + | t.Fatal("failed to enter page mode") | |||
| 831 | + | } | |||
| 832 | + | ||||
| 833 | + | // Scroll left should not auto-exit | |||
| 834 | + | h.handleKeyPress(uint('h'), 0, 0) | |||
| 835 | + | if h.Mode() != ModePage { | |||
| 836 | + | t.Fatal("scroll left should not exit page mode") | |||
| 837 | + | } | |||
| 838 | + | ||||
| 839 | + | // Scroll right should not auto-exit | |||
| 840 | + | h.handleKeyPress(uint('l'), 0, 0) | |||
| 841 | + | if h.Mode() != ModePage { | |||
| 842 | + | t.Fatal("scroll right should not exit page mode") | |||
| 843 | + | } | |||
| 844 | + | ||||
| 845 | + | if actionCalls != 2 { | |||
| 846 | + | t.Fatalf("action calls = %d, want 2", actionCalls) | |||
| 847 | + | } | |||
| 848 | + | } | |||
| 849 | + | ||||
| 850 | + | func TestHandleKeyPress_PageModeArrowKeysPassThroughNatively(t *testing.T) { | |||
| 851 | + | ctx := context.Background() | |||
| 852 | + | workspace := newTestWorkspace() | |||
| 853 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 854 | + | ActivationShortcut: "ctrl+y", | |||
| 855 | + | Actions: map[string]entity.ActionBinding{ | |||
| 856 | + | "page-scroll-down": {Keys: []string{"j"}}, | |||
| 857 | + | }, | |||
| 858 | + | } | |||
| 859 | + | ||||
| 860 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 861 | + | actionCalls := 0 | |||
| 862 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 863 | + | actionCalls++ | |||
| 864 | + | return nil | |||
| 865 | + | }) | |||
| 866 | + | ||||
| 867 | + | h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) | |||
| 868 | + | if h.Mode() != ModePage { | |||
| 869 | + | t.Fatal("failed to enter page mode") | |||
| 870 | + | } | |||
| 871 | + | ||||
| 872 | + | for _, keyval := range []uint{uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down)} { | |||
| 873 | + | consumed := h.handleKeyPress(keyval, 0, 0) | |||
| 874 | + | if consumed { | |||
| 875 | + | t.Fatalf("arrow key %d should pass through to native page handling in page mode", keyval) | |||
| 876 | + | } | |||
| 877 | + | if h.Mode() != ModePage { | |||
| 878 | + | t.Fatalf("arrow key %d should keep page mode active", keyval) | |||
| 879 | + | } | |||
| 880 | + | } | |||
| 881 | + | ||||
| 882 | + | if actionCalls != 0 { | |||
| 883 | + | t.Fatalf("arrow key passthrough should not dispatch page-mode action, got %d calls", actionCalls) | |||
| 884 | + | } | |||
| 885 | + | } | |||
| 886 | + | ||||
| 887 | + | func TestHandleKeyPress_PageModeBlocksGlobalShortcutFallback(t *testing.T) { | |||
| 888 | + | ctx := context.Background() | |||
| 889 | + | workspace := newTestWorkspace() | |||
| 890 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 891 | + | ActivationShortcut: "ctrl+y", | |||
| 892 | + | Actions: map[string]entity.ActionBinding{ | |||
| 893 | + | "page-scroll-down": {Keys: []string{"j"}}, | |||
| 894 | + | }, | |||
| 895 | + | } | |||
| 896 | + | ||||
| 897 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 898 | + | actionCalls := 0 | |||
| 899 | + | var lastAction Action | |||
| 900 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 901 | + | actionCalls++ | |||
| 902 | + | lastAction = action | |||
| 903 | + | return nil | |||
| 904 | + | }) | |||
| 905 | + | ||||
| 906 | + | if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) { | |||
| 907 | + | t.Fatal("Ctrl+Y should be consumed") | |||
| 908 | + | } | |||
| 909 | + | if h.Mode() != ModePage { | |||
| 910 | + | t.Fatal("failed to enter page mode") | |||
| 911 | + | } | |||
| 912 | + | ||||
| 913 | + | if !h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue) { | |||
| 914 | + | t.Fatal("Ctrl+L should be consumed inside page mode") | |||
| 915 | + | } | |||
| 916 | + | if actionCalls != 0 { | |||
| 917 | + | t.Fatalf("Ctrl+L should not dispatch a global action in page mode, got %d calls (%s)", actionCalls, lastAction) | |||
| 918 | + | } | |||
| 919 | + | if h.Mode() != ModePage { | |||
| 920 | + | t.Fatalf("mode after blocked Ctrl+L = %v, want ModePage", h.Mode()) | |||
| 921 | + | } | |||
| 922 | + | } | |||
| 923 | + | ||||
| 924 | + | func TestPageScrollRepeat_FirstKeyDownDispatchesTapAndArmsHold(t *testing.T) { | |||
| 925 | + | h, now, tick := newPageScrollRepeatTestHandler(t) | |||
| 926 | + | actionCalls := 0 | |||
| 927 | + | h.SetOnAction(func(_ context.Context, action Action) error { | |||
| 928 | + | actionCalls++ | |||
| 929 | + | if action != ActionPageScrollDown { | |||
| 930 | + | t.Fatalf("action = %s, want %s", action, ActionPageScrollDown) | |||
| 931 | + | } | |||
| 932 | + | return nil | |||
| 933 | + | }) | |||
| 934 | + | ||||
| 935 | + | if !h.handleKeyPress(uint('j'), 0, 0) { | |||
| 936 | + | t.Fatal("first J press should be consumed") | |||
| 937 | + | } | |||
| 938 | + | if actionCalls != 1 { | |||
| 939 | + | t.Fatalf("tap dispatches = %d, want 1", actionCalls) | |||
| 940 | + | } | |||
| 941 | + | if *tick == nil { | |||
| 942 | + | t.Fatal("first keydown must arm the hold source without an OS repeat") | |||
| 943 | + | } | |||
| 944 | + | ||||
| 945 | + | *now = now.Add(pageScrollHoldDelay - time.Millisecond) | |||
| 946 | + | if !(*tick)(0) { | |||
| 947 | + | t.Fatal("hold source should remain armed before the delay") | |||
| 948 | + | } | |||
| 949 | + | if actionCalls != 1 { | |||
| 950 | + | t.Fatalf("dispatches before hold delay = %d, want 1 tap", actionCalls) | |||
| 951 | + | } | |||
| 952 | + | } | |||
| 953 | + | ||||
| 954 | + | func TestPageScrollRepeat_HoldStartsWithoutOSRepeat(t *testing.T) { | |||
| 955 | + | h, now, tick := newPageScrollRepeatTestHandler(t) | |||
| 956 | + | continuous := 0 | |||
| 957 | + | h.SetOnAction(func(context.Context, Action) error { return nil }) | |||
| 958 | + | h.SetOnPageScrollLifecycle(func(_ context.Context, action Action, phase PageScrollPhase) error { | |||
| 959 | + | if action != ActionPageScrollDown || phase != PageScrollContinuous { | |||
| 960 | + | t.Fatalf("lifecycle = (%s, %v), want (%s, continuous)", action, phase, ActionPageScrollDown) | |||
| 961 | + | } | |||
| 962 | + | continuous++ | |||
| 963 | + | return nil | |||
| 964 | + | }) | |||
| 965 | + | ||||
| 966 | + | h.handleKeyPress(uint('j'), 0, 0) | |||
| 967 | + | *now = now.Add(pageScrollHoldDelay) | |||
| 968 | + | if !(*tick)(0) { | |||
| 969 | + | t.Fatal("hold source stopped at the delay") | |||
| 970 | + | } | |||
| 971 | + | if continuous != 1 { | |||
| 972 | + | t.Fatalf("continuous dispatches = %d, want 1 without a second keydown", continuous) | |||
| 973 | + | } | |||
| 974 | + | } | |||
| 975 | + | ||||
| 976 | + | func TestPageScrollRepeat_FixedCadenceIgnoresDisplayAndOSRepeatRates(t *testing.T) { | |||
| 977 | + | const simulatedHold = time.Second | |||
| 978 | + | simulate := func(t *testing.T, externalHz int) int { | |||
| 979 | + | t.Helper() | |||
| 980 | + | h, now, tick := newPageScrollRepeatTestHandler(t) | |||
| 981 | + | tapDistance := 0 | |||
| 982 | + | continuousDistance := 0 | |||
| 983 | + | h.SetOnAction(func(context.Context, Action) error { | |||
| 984 | + | tapDistance += 80 | |||
| 985 | + | return nil | |||
| 986 | + | }) | |||
| 987 | + | h.SetOnPageScrollLifecycle(func(_ context.Context, _ Action, phase PageScrollPhase) error { | |||
| 988 | + | if phase == PageScrollContinuous { | |||
| 989 | + | continuousDistance += 80 | |||
| 990 | + | } | |||
| 991 | + | return nil | |||
| 992 | + | }) | |||
| 993 | + | h.handleKeyPress(uint('j'), 0, 0) | |||
| 994 | + | ||||
| 995 | + | start := *now | |||
| 996 | + | nextExternal := time.Second / time.Duration(externalHz) | |||
| 997 | + | for elapsed := pageScrollCadence; elapsed <= simulatedHold; elapsed += pageScrollCadence { | |||
| 998 | + | *now = start.Add(elapsed) | |||
| 999 | + | for elapsed >= nextExternal { | |||
| 1000 | + | h.handleKeyPress(uint('j'), 0, 0) // ignored OS auto-repeat | |||
| 1001 | + | nextExternal += time.Second / time.Duration(externalHz) | |||
| 1002 | + | } | |||
| 1003 | + | if !(*tick)(0) { | |||
| 1004 | + | t.Fatalf("source stopped at %s", elapsed) | |||
| 1005 | + | } | |||
| 1006 | + | } | |||
| 1007 | + | return tapDistance + continuousDistance | |||
| 1008 | + | } | |||
| 1009 | + | ||||
| 1010 | + | distance60 := simulate(t, 60) | |||
| 1011 | + | distance144 := simulate(t, 144) | |||
| 1012 | + | t.Logf("simulated one-second distances: 60 Hz=%d px, 144 Hz=%d px; tolerance=80 px", distance60, distance144) | |||
| 1013 | + | // A fixed 16 ms source makes the results exact in this deterministic model; | |||
| 1014 | + | // the documented runtime tolerance is one 80 px cadence step at timer edges. | |||
| 1015 | + | if diff := distance60 - distance144; diff < -80 || diff > 80 { | |||
| 1016 | + | t.Fatalf("60 Hz distance=%d, 144 Hz distance=%d; tolerance=80 px", distance60, distance144) | |||
| 1017 | + | } | |||
| 1018 | + | } | |||
| 1019 | + | ||||
| 1020 | + | func TestPageScrollRepeat_ReleaseAndEscapeStopImmediately(t *testing.T) { | |||
| 1021 | + | for _, tc := range []struct { | |||
| 1022 | + | name string | |||
| 1023 | + | stop func(*KeyboardHandler) | |||
| 1024 | + | }{ | |||
| 1025 | + | {name: "release", stop: func(h *KeyboardHandler) { h.handleKeyRelease(uint('j')) }}, | |||
| 1026 | + | {name: "escape", stop: func(h *KeyboardHandler) { h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) }}, | |||
| 1027 | + | } { | |||
| 1028 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1029 | + | h, now, tick := newPageScrollRepeatTestHandler(t) | |||
| 1030 | + | removed := 0 | |||
| 1031 | + | stops := 0 | |||
| 1032 | + | h.pageScrollRepeatRemove = func(uint) bool { removed++; return true } | |||
| 1033 | + | h.SetOnAction(func(context.Context, Action) error { return nil }) | |||
| 1034 | + | h.SetOnPageScrollLifecycle(func(_ context.Context, _ Action, phase PageScrollPhase) error { | |||
| 1035 | + | if phase == PageScrollStop { | |||
| 1036 | + | stops++ | |||
| 1037 | + | } | |||
| 1038 | + | return nil | |||
| 1039 | + | }) | |||
| 1040 | + | h.handleKeyPress(uint('j'), 0, 0) | |||
| 1041 | + | *now = now.Add(pageScrollHoldDelay) | |||
| 1042 | + | (*tick)(0) | |||
| 1043 | + | ||||
| 1044 | + | tc.stop(h) | |||
| 1045 | + | if removed != 1 || stops != 1 { | |||
| 1046 | + | t.Fatalf("removed=%d stops=%d, want 1/1", removed, stops) | |||
| 1047 | + | } | |||
| 1048 | + | if (*tick)(0) { | |||
| 1049 | + | t.Fatal("retained callback must stop immediately") | |||
| 1050 | + | } | |||
| 1051 | + | }) | |||
| 1052 | + | } | |||
| 1053 | + | } | |||
| 1054 | + | ||||
| 1055 | + | func newPageScrollRepeatTestHandler(t *testing.T) (*KeyboardHandler, *time.Time, *glib.SourceFunc) { | |||
| 1056 | + | t.Helper() | |||
| 1057 | + | workspace := newTestWorkspace() | |||
| 1058 | + | workspace.PageMode = entity.PageModeConfig{ | |||
| 1059 | + | Actions: map[string]entity.ActionBinding{"page-scroll-down": {Keys: []string{"j"}}}, | |||
| 1060 | + | } | |||
| 1061 | + | h := NewKeyboardHandler(context.Background(), workspace, newTestSession()) | |||
| 1062 | + | h.EnterPageMode() | |||
| 1063 | + | now := time.Unix(1_000, 0) | |||
| 1064 | + | var tick glib.SourceFunc | |||
| 1065 | + | h.pageScrollNow = func() time.Time { return now } | |||
| 1066 | + | h.pageScrollRepeatAdd = func(intervalMS uint, cb *glib.SourceFunc) uint { | |||
| 1067 | + | if intervalMS != uint(pageScrollCadence/time.Millisecond) { | |||
| 1068 | + | t.Fatalf("cadence = %dms, want %dms", intervalMS, pageScrollCadence/time.Millisecond) | |||
| 1069 | + | } | |||
| 1070 | + | tick = *cb | |||
| 1071 | + | return 99 | |||
| 1072 | + | } | |||
| 1073 | + | h.pageScrollRepeatRemove = func(uint) bool { return true } | |||
| 1074 | + | return h, &now, &tick | |||
| 1075 | + | } | |||
M internal/ui/input/modal.go +20 -2
| @@ -8,6 +8,10 @@ import ( | |||||
| 8 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | type modalTimer interface { | |||
| 12 | + | Stop() bool | |||
| 13 | + | } | |||
| 14 | + | ||||
| 11 | // Mode represents the current input mode. | 15 | // Mode represents the current input mode. | ||
| 12 | type Mode int | 16 | type Mode int | ||
| 13 | 17 | ||||
| @@ -22,6 +26,8 @@ const ( | |||||
| 22 | ModeSession | 26 | ModeSession | ||
| 23 | // ModeResize is the modal pane resizing mode. | 27 | // ModeResize is the modal pane resizing mode. | ||
| 24 | ModeResize | 28 | ModeResize | ||
| 29 | + | // ModePage is the modal page scrolling mode. | |||
| 30 | + | ModePage | |||
| 25 | ) | 31 | ) | ||
| 26 | 32 | ||||
| 27 | // String returns a human-readable mode name. | 33 | // String returns a human-readable mode name. | ||
| @@ -37,6 +43,8 @@ func (m Mode) String() string { | |||||
| 37 | return "session" | 43 | return "session" | ||
| 38 | case ModeResize: | 44 | case ModeResize: | ||
| 39 | return "resize" | 45 | return "resize" | ||
| 46 | + | case ModePage: | |||
| 47 | + | return "page" | |||
| 40 | default: | 48 | default: | ||
| 41 | return "unknown" | 49 | return "unknown" | ||
| 42 | } | 50 | } | ||
| @@ -53,6 +61,8 @@ func (m Mode) DisplayName() string { | |||||
| 53 | return "SESSION MODE" | 61 | return "SESSION MODE" | ||
| 54 | case ModeResize: | 62 | case ModeResize: | ||
| 55 | return "RESIZE MODE" | 63 | return "RESIZE MODE" | ||
| 64 | + | case ModePage: | |||
| 65 | + | return "PAGE MODE" | |||
| 56 | default: | 66 | default: | ||
| 57 | return "" | 67 | return "" | ||
| 58 | } | 68 | } | ||
| @@ -62,9 +72,11 @@ func (m Mode) DisplayName() string { | |||||
| 62 | type ModalState struct { | 72 | type ModalState struct { | ||
| 63 | mode Mode | 73 | mode Mode | ||
| 64 | timeout time.Duration | 74 | timeout time.Duration | ||
| 65 | - | timer *time.Timer | 75 | + | timer modalTimer |
| 66 | timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks | 76 | timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks | ||
| 67 | 77 | ||||
| 78 | + | afterFunc func(time.Duration, func()) modalTimer | |||
| 79 | + | ||||
| 68 | // Callback for mode changes (called synchronously under lock). | 80 | // Callback for mode changes (called synchronously under lock). | ||
| 69 | onModeChange func(from, to Mode) | 81 | onModeChange func(from, to Mode) | ||
| 70 | 82 | ||||
| @@ -84,10 +96,16 @@ func NewModalState(ctx context.Context) *ModalState { | |||||
| 84 | log.Debug().Msg("creating modal state") | 96 | log.Debug().Msg("creating modal state") | ||
| 85 | return &ModalState{ | 97 | return &ModalState{ | ||
| 86 | mode: ModeNormal, | 98 | mode: ModeNormal, | ||
| 99 | + | afterFunc: func(d time.Duration, fn func()) modalTimer { return time.AfterFunc(d, fn) }, | |||
| 87 | scheduleOnMainThread: func(fn func()) { fn() }, | 100 | scheduleOnMainThread: func(fn func()) { fn() }, | ||
| 88 | } | 101 | } | ||
| 89 | } | 102 | } | ||
| 90 | 103 | ||||
| 104 | + | // EnterPageMode switches to page scrolling mode with an optional timeout. | |||
| 105 | + | func (m *ModalState) EnterPageMode(ctx context.Context, timeout time.Duration) { | |||
| 106 | + | m.enterMode(ctx, ModePage, timeout) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 91 | // SetMainThreadScheduler sets the function used to dispatch timer callbacks | 109 | // SetMainThreadScheduler sets the function used to dispatch timer callbacks | ||
| 92 | // to the GTK main thread. In the real app this should be a glib.IdleAdd | 110 | // to the GTK main thread. In the real app this should be a glib.IdleAdd | ||
| 93 | // wrapper. In tests, the default (direct execution) is used. | 111 | // wrapper. In tests, the default (direct execution) is used. | ||
| @@ -223,7 +241,7 @@ func (m *ModalState) startTimeoutLocked(ctx context.Context, timeout time.Durati | |||||
| 223 | m.timerGen++ | 241 | m.timerGen++ | ||
| 224 | gen := m.timerGen | 242 | gen := m.timerGen | ||
| 225 | schedule := m.scheduleOnMainThread | 243 | schedule := m.scheduleOnMainThread | ||
| 226 | - | m.timer = time.AfterFunc(timeout, func() { | 244 | + | m.timer = m.afterFunc(timeout, func() { |
| 227 | schedule(func() { | 245 | schedule(func() { | ||
| 228 | // Ignore stale callback: time.Timer.Stop can race with the | 246 | // Ignore stale callback: time.Timer.Stop can race with the | ||
| 229 | // timer firing, so a canceled timer's callback may still run. | 247 | // timer firing, so a canceled timer's callback may still run. | ||
M internal/ui/input/shortcuts.go +62 -1
| @@ -202,6 +202,15 @@ const ( | |||||
| 202 | 202 | ||||
| 203 | // Application | 203 | // Application | ||
| 204 | ActionQuit Action = "quit" | 204 | ActionQuit Action = "quit" | ||
| 205 | + | ||||
| 206 | + | // Page mode actions | |||
| 207 | + | ActionEnterPageMode Action = "enter_page_mode" | |||
| 208 | + | ActionPageScrollLeft Action = "page_scroll_left" | |||
| 209 | + | ActionPageScrollDown Action = "page_scroll_down" | |||
| 210 | + | ActionPageScrollUp Action = "page_scroll_up" | |||
| 211 | + | ActionPageScrollRight Action = "page_scroll_right" | |||
| 212 | + | ActionPageScrollDownFast Action = "page_scroll_down_fast" | |||
| 213 | + | ActionPageScrollUpFast Action = "page_scroll_up_fast" | |||
| 205 | ) | 214 | ) | ||
| 206 | 215 | ||||
| 207 | const floatingProfileActionPrefix = "open_floating_profile:" | 216 | const floatingProfileActionPrefix = "open_floating_profile:" | ||
| @@ -222,6 +231,8 @@ type ShortcutSet struct { | |||||
| 222 | SessionMode ShortcutTable | 231 | SessionMode ShortcutTable | ||
| 223 | // ResizeMode shortcuts are only active in resize mode. | 232 | // ResizeMode shortcuts are only active in resize mode. | ||
| 224 | ResizeMode ShortcutTable | 233 | ResizeMode ShortcutTable | ||
| 234 | + | // PageMode shortcuts are only active in page mode. | |||
| 235 | + | PageMode ShortcutTable | |||
| 225 | } | 236 | } | ||
| 226 | 237 | ||||
| 227 | // NewShortcutSet creates a ShortcutSet from workspace and session configuration. | 238 | // NewShortcutSet creates a ShortcutSet from workspace and session configuration. | ||
| @@ -235,6 +246,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||||
| 235 | PaneMode: make(ShortcutTable), | 246 | PaneMode: make(ShortcutTable), | ||
| 236 | SessionMode: make(ShortcutTable), | 247 | SessionMode: make(ShortcutTable), | ||
| 237 | ResizeMode: make(ShortcutTable), | 248 | ResizeMode: make(ShortcutTable), | ||
| 249 | + | PageMode: make(ShortcutTable), | |||
| 238 | } | 250 | } | ||
| 239 | 251 | ||||
| 240 | set.buildGlobalShortcutsFromParts(ctx, workspace, session) | 252 | set.buildGlobalShortcutsFromParts(ctx, workspace, session) | ||
| @@ -242,6 +254,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||||
| 242 | set.buildTabModeShortcuts(ctx, workspace) | 254 | set.buildTabModeShortcuts(ctx, workspace) | ||
| 243 | set.buildPaneModeShortcuts(ctx, workspace) | 255 | set.buildPaneModeShortcuts(ctx, workspace) | ||
| 244 | set.buildResizeModeShortcuts(ctx, workspace) | 256 | set.buildResizeModeShortcuts(ctx, workspace) | ||
| 257 | + | set.buildPageModeShortcuts(ctx, workspace) | |||
| 245 | } | 258 | } | ||
| 246 | if session != nil { | 259 | if session != nil { | ||
| 247 | set.buildSessionModeShortcuts(ctx, session) | 260 | set.buildSessionModeShortcuts(ctx, session) | ||
| @@ -253,6 +266,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||||
| 253 | Int("pane", len(set.PaneMode)). | 266 | Int("pane", len(set.PaneMode)). | ||
| 254 | Int("resize", len(set.ResizeMode)). | 267 | Int("resize", len(set.ResizeMode)). | ||
| 255 | Int("session", len(set.SessionMode)). | 268 | Int("session", len(set.SessionMode)). | ||
| 269 | + | Int("page", len(set.PageMode)). | |||
| 256 | Msg("shortcuts registered") | 270 | Msg("shortcuts registered") | ||
| 257 | 271 | ||||
| 258 | return set | 272 | return set | ||
| @@ -288,6 +302,22 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity. | |||||
| 288 | s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize") | 302 | s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize") | ||
| 289 | } | 303 | } | ||
| 290 | 304 | ||||
| 305 | + | // buildPageModeShortcuts populates page mode shortcuts from config. | |||
| 306 | + | func (s *ShortcutSet) buildPageModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) { | |||
| 307 | + | log := logging.FromContext(ctx) | |||
| 308 | + | s.buildModeShortcuts(ctx, cfg.PageMode.GetKeyBindings(), s.PageMode, "page") | |||
| 309 | + | if binding, ok := ParseKeyString(cfg.PageMode.ActivationShortcut); ok { | |||
| 310 | + | s.PageMode[binding] = ActionEnterPageMode | |||
| 311 | + | log.Trace(). | |||
| 312 | + | Str("shortcut", cfg.PageMode.ActivationShortcut). | |||
| 313 | + | Uint("keyval", binding.Keyval). | |||
| 314 | + | Uint("mod", uint(binding.Modifiers)). | |||
| 315 | + | Msg("page mode toggle registered in page mode table") | |||
| 316 | + | } else { | |||
| 317 | + | log.Warn().Str("shortcut", cfg.PageMode.ActivationShortcut).Msg("failed to parse page mode activation shortcut for page mode table") | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | ||||
| 291 | func (s *ShortcutSet) registerActivationShortcutsFromParts( | 321 | func (s *ShortcutSet) registerActivationShortcutsFromParts( | ||
| 292 | ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig, | 322 | ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig, | ||
| 293 | ) { | 323 | ) { | ||
| @@ -308,6 +338,16 @@ func (s *ShortcutSet) registerActivationShortcutsFromParts( | |||||
| 308 | } | 338 | } | ||
| 309 | return | 339 | return | ||
| 310 | } | 340 | } | ||
| 341 | + | if binding, ok := ParseKeyString(workspace.PageMode.ActivationShortcut); ok { | |||
| 342 | + | s.Global[binding] = ActionEnterPageMode | |||
| 343 | + | log.Trace(). | |||
| 344 | + | Str("shortcut", workspace.PageMode.ActivationShortcut). | |||
| 345 | + | Uint("keyval", binding.Keyval). | |||
| 346 | + | Uint("mod", uint(binding.Modifiers)). | |||
| 347 | + | Msg("page mode activation registered") | |||
| 348 | + | } else { | |||
| 349 | + | log.Warn().Str("shortcut", workspace.PageMode.ActivationShortcut).Msg("failed to parse page mode activation shortcut") | |||
| 350 | + | } | |||
| 311 | if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok { | 351 | if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok { | ||
| 312 | s.Global[binding] = ActionEnterTabMode | 352 | s.Global[binding] = ActionEnterTabMode | ||
| 313 | log.Trace(). | 353 | log.Trace(). | ||
| @@ -642,6 +682,20 @@ var configActionToAction = map[string]Action{ | |||||
| 642 | 682 | ||||
| 643 | // Session actions | 683 | // Session actions | ||
| 644 | "session-manager": ActionOpenSessionManager, | 684 | "session-manager": ActionOpenSessionManager, | ||
| 685 | + | ||||
| 686 | + | // Page mode scroll actions | |||
| 687 | + | "page_scroll_left": ActionPageScrollLeft, | |||
| 688 | + | "page-scroll-left": ActionPageScrollLeft, | |||
| 689 | + | "page_scroll_down": ActionPageScrollDown, | |||
| 690 | + | "page-scroll-down": ActionPageScrollDown, | |||
| 691 | + | "page_scroll_up": ActionPageScrollUp, | |||
| 692 | + | "page-scroll-up": ActionPageScrollUp, | |||
| 693 | + | "page_scroll_right": ActionPageScrollRight, | |||
| 694 | + | "page-scroll-right": ActionPageScrollRight, | |||
| 695 | + | "page_scroll_down_fast": ActionPageScrollDownFast, | |||
| 696 | + | "page-scroll-down-fast": ActionPageScrollDownFast, | |||
| 697 | + | "page_scroll_up_fast": ActionPageScrollUpFast, | |||
| 698 | + | "page-scroll-up-fast": ActionPageScrollUpFast, | |||
| 645 | } | 699 | } | ||
| 646 | 700 | ||||
| 647 | // FloatingProfileTarget carries the session identity and URL for a floating profile action. | 701 | // FloatingProfileTarget carries the session identity and URL for a floating profile action. | ||
| @@ -791,7 +845,9 @@ func stringToKeyval(s string) (uint, bool) { | |||||
| 791 | } | 845 | } | ||
| 792 | 846 | ||||
| 793 | // Lookup finds an action for the given key binding in the appropriate table. | 847 | // Lookup finds an action for the given key binding in the appropriate table. | ||
| 794 | - | // It first checks the mode-specific table, then falls back to global. | 848 | + | // It first checks the mode-specific table, then falls back to global unless |
| 849 | + | // the current mode is Page Mode. Page Mode intentionally stays self-contained | |||
| 850 | + | // so app-global shortcuts do not interrupt held page scrolling. | |||
| 795 | func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | 851 | func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | ||
| 796 | // Normalize the binding modifiers | 852 | // Normalize the binding modifiers | ||
| 797 | binding.Modifiers &= modifierMask | 853 | binding.Modifiers &= modifierMask | ||
| @@ -807,12 +863,17 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |||||
| 807 | modeTable = s.ResizeMode | 863 | modeTable = s.ResizeMode | ||
| 808 | case ModeSession: | 864 | case ModeSession: | ||
| 809 | modeTable = s.SessionMode | 865 | modeTable = s.SessionMode | ||
| 866 | + | case ModePage: | |||
| 867 | + | modeTable = s.PageMode | |||
| 810 | } | 868 | } | ||
| 811 | 869 | ||||
| 812 | if modeTable != nil { | 870 | if modeTable != nil { | ||
| 813 | if action, ok := modeTable[binding]; ok { | 871 | if action, ok := modeTable[binding]; ok { | ||
| 814 | return action, true | 872 | return action, true | ||
| 815 | } | 873 | } | ||
| 874 | + | if mode == ModePage { | |||
| 875 | + | return "", false | |||
| 876 | + | } | |||
| 816 | } | 877 | } | ||
| 817 | 878 | ||||
| 818 | // Fall back to global | 879 | // Fall back to global | ||
M internal/ui/input/shortcuts_test.go +141 -0
| @@ -1,8 +1,10 @@ | |||||
| 1 | package input | 1 | package input | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 6 | "github.com/bnema/puregotk/v4/gdk" | 8 | "github.com/bnema/puregotk/v4/gdk" | ||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| @@ -462,3 +464,142 @@ func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) { | |||||
| 462 | }) | 464 | }) | ||
| 463 | } | 465 | } | ||
| 464 | } | 466 | } | ||
| 467 | + | ||||
| 468 | + | func TestNewShortcutSet_PageModeActivationShortcut(t *testing.T) { | |||
| 469 | + | workspace := &entity.WorkspaceConfig{ | |||
| 470 | + | TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"}, | |||
| 471 | + | PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"}, | |||
| 472 | + | PageMode: entity.PageModeConfig{ActivationShortcut: "ctrl+y"}, | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | set := NewShortcutSet(context.Background(), workspace, nil) | |||
| 476 | + | ||||
| 477 | + | binding, ok := ParseKeyString("ctrl+y") | |||
| 478 | + | if !ok { | |||
| 479 | + | t.Fatal("failed to parse ctrl+y") | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | action, found := set.Global[binding] | |||
| 483 | + | if !found { | |||
| 484 | + | t.Fatal("page mode activation shortcut not found in Global") | |||
| 485 | + | } | |||
| 486 | + | if action != ActionEnterPageMode { | |||
| 487 | + | t.Fatalf("Global shortcut action = %s, want %s", action, ActionEnterPageMode) | |||
| 488 | + | } | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | func TestShortcutSet_Lookup_PageMode(t *testing.T) { | |||
| 492 | + | set := &ShortcutSet{ | |||
| 493 | + | Global: ShortcutTable{ | |||
| 494 | + | {uint(gdk.KEY_q), ModCtrl}: ActionQuit, | |||
| 495 | + | }, | |||
| 496 | + | PageMode: ShortcutTable{ | |||
| 497 | + | {uint('h'), ModNone}: ActionPageScrollLeft, | |||
| 498 | + | {uint('j'), ModNone}: ActionPageScrollDown, | |||
| 499 | + | {uint('k'), ModNone}: ActionPageScrollUp, | |||
| 500 | + | {uint('l'), ModNone}: ActionPageScrollRight, | |||
| 501 | + | {uint(gdk.KEY_Escape), ModNone}: ActionExitMode, | |||
| 502 | + | {uint(gdk.KEY_Return), ModNone}: ActionExitMode, | |||
| 503 | + | {uint('y'), ModCtrl}: ActionEnterPageMode, | |||
| 504 | + | }, | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | tests := []struct { | |||
| 508 | + | name string | |||
| 509 | + | binding KeyBinding | |||
| 510 | + | want Action | |||
| 511 | + | wantOk bool | |||
| 512 | + | }{ | |||
| 513 | + | { | |||
| 514 | + | name: "scroll left", | |||
| 515 | + | binding: KeyBinding{uint('h'), ModNone}, | |||
| 516 | + | want: ActionPageScrollLeft, | |||
| 517 | + | wantOk: true, | |||
| 518 | + | }, | |||
| 519 | + | { | |||
| 520 | + | name: "scroll down", | |||
| 521 | + | binding: KeyBinding{uint('j'), ModNone}, | |||
| 522 | + | want: ActionPageScrollDown, | |||
| 523 | + | wantOk: true, | |||
| 524 | + | }, | |||
| 525 | + | { | |||
| 526 | + | name: "scroll up", | |||
| 527 | + | binding: KeyBinding{uint('k'), ModNone}, | |||
| 528 | + | want: ActionPageScrollUp, | |||
| 529 | + | wantOk: true, | |||
| 530 | + | }, | |||
| 531 | + | { | |||
| 532 | + | name: "scroll right", | |||
| 533 | + | binding: KeyBinding{uint('l'), ModNone}, | |||
| 534 | + | want: ActionPageScrollRight, | |||
| 535 | + | wantOk: true, | |||
| 536 | + | }, | |||
| 537 | + | { | |||
| 538 | + | name: "escape exits page mode", | |||
| 539 | + | binding: KeyBinding{uint(gdk.KEY_Escape), ModNone}, | |||
| 540 | + | want: ActionExitMode, | |||
| 541 | + | wantOk: true, | |||
| 542 | + | }, | |||
| 543 | + | { | |||
| 544 | + | name: "enter exits page mode", | |||
| 545 | + | binding: KeyBinding{uint(gdk.KEY_Return), ModNone}, | |||
| 546 | + | want: ActionExitMode, | |||
| 547 | + | wantOk: true, | |||
| 548 | + | }, | |||
| 549 | + | { | |||
| 550 | + | name: "activation shortcut still toggles page mode", | |||
| 551 | + | binding: KeyBinding{uint('y'), ModCtrl}, | |||
| 552 | + | want: ActionEnterPageMode, | |||
| 553 | + | wantOk: true, | |||
| 554 | + | }, | |||
| 555 | + | { | |||
| 556 | + | name: "global shortcut does not fire inside page mode", | |||
| 557 | + | binding: KeyBinding{uint(gdk.KEY_q), ModCtrl}, | |||
| 558 | + | want: "", | |||
| 559 | + | wantOk: false, | |||
| 560 | + | }, | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | for _, tt := range tests { | |||
| 564 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 565 | + | got, ok := set.Lookup(tt.binding, ModePage) | |||
| 566 | + | if ok != tt.wantOk { | |||
| 567 | + | t.Errorf("Lookup() ok = %v, want %v", ok, tt.wantOk) | |||
| 568 | + | return | |||
| 569 | + | } | |||
| 570 | + | if got != tt.want { | |||
| 571 | + | t.Errorf("Lookup() = %v, want %v", got, tt.want) | |||
| 572 | + | } | |||
| 573 | + | }) | |||
| 574 | + | } | |||
| 575 | + | } | |||
| 576 | + | ||||
| 577 | + | func TestMapConfigAction_PageScrollActions(t *testing.T) { | |||
| 578 | + | tests := []struct { | |||
| 579 | + | name string | |||
| 580 | + | config string | |||
| 581 | + | expected Action | |||
| 582 | + | }{ | |||
| 583 | + | {name: "page_scroll_left", config: "page_scroll_left", expected: ActionPageScrollLeft}, | |||
| 584 | + | {name: "page-scroll-left", config: "page-scroll-left", expected: ActionPageScrollLeft}, | |||
| 585 | + | {name: "page_scroll_down", config: "page_scroll_down", expected: ActionPageScrollDown}, | |||
| 586 | + | {name: "page-scroll-down", config: "page-scroll-down", expected: ActionPageScrollDown}, | |||
| 587 | + | {name: "page_scroll_up", config: "page_scroll_up", expected: ActionPageScrollUp}, | |||
| 588 | + | {name: "page-scroll-up", config: "page-scroll-up", expected: ActionPageScrollUp}, | |||
| 589 | + | {name: "page_scroll_right", config: "page_scroll_right", expected: ActionPageScrollRight}, | |||
| 590 | + | {name: "page-scroll-right", config: "page-scroll-right", expected: ActionPageScrollRight}, | |||
| 591 | + | {name: "page_scroll_down_fast", config: "page_scroll_down_fast", expected: ActionPageScrollDownFast}, | |||
| 592 | + | {name: "page-scroll-down-fast", config: "page-scroll-down-fast", expected: ActionPageScrollDownFast}, | |||
| 593 | + | {name: "page_scroll_up_fast", config: "page_scroll_up_fast", expected: ActionPageScrollUpFast}, | |||
| 594 | + | {name: "page-scroll-up-fast", config: "page-scroll-up-fast", expected: ActionPageScrollUpFast}, | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | for _, tt := range tests { | |||
| 598 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 599 | + | got := mapConfigAction(tt.config) | |||
| 600 | + | if got != tt.expected { | |||
| 601 | + | t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.config, got, tt.expected) | |||
| 602 | + | } | |||
| 603 | + | }) | |||
| 604 | + | } | |||
| 605 | + | } | |||
M internal/ui/theme/css.go +77 -1
| @@ -51,6 +51,8 @@ window, tooltip, popover { | |||||
| 51 | ` | 51 | ` | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | + | const defaultTransitionDurationMs = 120 | |||
| 55 | + | ||||
| 54 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | 56 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | ||
| 55 | // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings. | 57 | // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings. | ||
| 56 | // Uses default mode colors. | 58 | // Uses default mode colors. | ||
| @@ -58,9 +60,16 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st | |||||
| 58 | return GenerateCSSFull(p, scale, fonts, DefaultModeColors()) | 60 | return GenerateCSSFull(p, scale, fonts, DefaultModeColors()) | ||
| 59 | } | 61 | } | ||
| 60 | 62 | ||||
| 61 | - | // GenerateCSSFull creates GTK4 CSS using all provided configuration. | 63 | + | // GenerateCSSFull creates GTK4 CSS using all provided configuration with the |
| 64 | + | // default transition duration. | |||
| 62 | // Text scaling is handled via GtkSettings; CSS here stays scale-independent. | 65 | // Text scaling is handled via GtkSettings; CSS here stays scale-independent. | ||
| 63 | func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColors) string { | 66 | func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColors) string { | ||
| 67 | + | return GenerateCSSFullWithTiming(p, 1.0, fonts, modeColors, defaultTransitionDurationMs) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // GenerateCSSFullWithTiming creates GTK4 CSS using all provided configuration | |||
| 71 | + | // and an explicit transition duration from workspace styling config. | |||
| 72 | + | func GenerateCSSFullWithTiming(p Palette, _ float64, fonts FontConfig, modeColors ModeColors, transitionDurationMs int) string { | |||
| 64 | defaults := DefaultFontConfig() | 73 | defaults := DefaultFontConfig() | ||
| 65 | fonts = FontConfig{ | 74 | fonts = FontConfig{ | ||
| 66 | SansFont: Coalesce(fonts.SansFont, defaults.SansFont), | 75 | SansFont: Coalesce(fonts.SansFont, defaults.SansFont), | ||
| @@ -102,6 +111,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo | |||||
| 102 | sb.WriteString(generateStackedPaneCSS(p)) | 111 | sb.WriteString(generateStackedPaneCSS(p)) | ||
| 103 | sb.WriteString("\n") | 112 | sb.WriteString("\n") | ||
| 104 | 113 | ||||
| 114 | + | // Page mode styling | |||
| 115 | + | sb.WriteString(generatePageModeCSS(transitionDurationMs)) | |||
| 116 | + | sb.WriteString("\n") | |||
| 117 | + | ||||
| 105 | // Progress bar styling | 118 | // Progress bar styling | ||
| 106 | sb.WriteString(generateProgressBarCSS(p)) | 119 | sb.WriteString(generateProgressBarCSS(p)) | ||
| 107 | sb.WriteString("\n") | 120 | sb.WriteString("\n") | ||
| @@ -572,6 +585,69 @@ entry.find-bar-entry:focus-visible { | |||||
| 572 | 585 | ||||
| 573 | // generatePaneCSS creates pane border styles. | 586 | // generatePaneCSS creates pane border styles. | ||
| 574 | // Uses em units for scalable UI. | 587 | // Uses em units for scalable UI. | ||
| 588 | + | // generatePageModeCSS creates the pane-local Page Mode accent and pulse styling. | |||
| 589 | + | // It reuses --pane-mode-color (from workspace.styling.pane_mode_color). | |||
| 590 | + | func generatePageModeCSS(transitionDurationMs int) string { | |||
| 591 | + | if transitionDurationMs < 0 { | |||
| 592 | + | transitionDurationMs = defaultTransitionDurationMs | |||
| 593 | + | } | |||
| 594 | + | normalPulseMs := transitionDurationMs * 3 | |||
| 595 | + | fastPulseMs := transitionDurationMs * 6 | |||
| 596 | + | return fmt.Sprintf(`/* ===== Page Mode Styling ===== */ | |||
| 597 | + | ||||
| 598 | + | /* Page mode active — subtle local border accent on the pane overlay. | |||
| 599 | + | Uses the existing pane mode color token via CSS variable. | |||
| 600 | + | This is independent of the workspace-level pane-mode border overlay. */ | |||
| 601 | + | .page-mode-active { | |||
| 602 | + | box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); | |||
| 603 | + | border-radius: 0; | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | /* Pane overlay pulse keyframes — start/end at the active accent state so the | |||
| 607 | + | pane keeps its Page mode border while briefly flaring brighter. */ | |||
| 608 | + | @keyframes page-mode-overlay-pulse-anim-a { | |||
| 609 | + | 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); } | |||
| 610 | + | 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--pane-mode-color), 0.78); } | |||
| 611 | + | 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); } | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | @keyframes page-mode-overlay-pulse-anim-b { | |||
| 615 | + | 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); } | |||
| 616 | + | 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--pane-mode-color), 0.78); } | |||
| 617 | + | 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); } | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | @keyframes page-mode-overlay-pulse-fast-anim-a { | |||
| 621 | + | 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); } | |||
| 622 | + | 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--pane-mode-color), 0.95); } | |||
| 623 | + | 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); } | |||
| 624 | + | } | |||
| 625 | + | ||||
| 626 | + | @keyframes page-mode-overlay-pulse-fast-anim-b { | |||
| 627 | + | 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); } | |||
| 628 | + | 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--pane-mode-color), 0.95); } | |||
| 629 | + | 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); } | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | /* Pane overlay scroll pulse — derived from transition_duration. */ | |||
| 633 | + | .page-mode-pulse.page-mode-pulse-cycle-a { | |||
| 634 | + | animation: page-mode-overlay-pulse-anim-a %dms ease-in-out; | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | .page-mode-pulse.page-mode-pulse-cycle-b { | |||
| 638 | + | animation: page-mode-overlay-pulse-anim-b %dms ease-in-out; | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | .page-mode-pulse-fast.page-mode-pulse-cycle-a { | |||
| 642 | + | animation: page-mode-overlay-pulse-fast-anim-a %dms ease-in-out; | |||
| 643 | + | } | |||
| 644 | + | ||||
| 645 | + | .page-mode-pulse-fast.page-mode-pulse-cycle-b { | |||
| 646 | + | animation: page-mode-overlay-pulse-fast-anim-b %dms ease-in-out; | |||
| 647 | + | } | |||
| 648 | + | `, normalPulseMs, normalPulseMs, fastPulseMs, fastPulseMs) | |||
| 649 | + | } | |||
| 650 | + | ||||
| 575 | func generatePaneCSS(p Palette) string { | 651 | func generatePaneCSS(p Palette) string { | ||
| 576 | return `/* ===== Pane Styling ===== */ | 652 | return `/* ===== Pane Styling ===== */ | ||
| 577 | 653 | ||||
M internal/ui/theme/css_test.go +10 -0
| @@ -34,6 +34,16 @@ func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing | |||||
| 34 | } | 34 | } | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | + | func TestGenerateCSSFullWithTiming_PageModePulseUsesConfiguredTransitionDuration(t *testing.T) { | |||
| 38 | + | // Page Mode overlay pulse timings scale from the base transition duration: | |||
| 39 | + | // normal pulse = 3× (150ms -> 450ms), fast pulse = 6× (150ms -> 900ms). | |||
| 40 | + | css := GenerateCSSFullWithTiming(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors(), 150) | |||
| 41 | + | ||||
| 42 | + | assert.Contains(t, css, "animation: page-mode-overlay-pulse-anim-a 450ms ease-in-out;") | |||
| 43 | + | assert.Contains(t, css, "animation: page-mode-overlay-pulse-fast-anim-a 900ms ease-in-out;") | |||
| 44 | + | assert.NotContains(t, css, "page-mode-indicator") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 37 | func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) { | 47 | func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) { | ||
| 38 | css := GenerateCSS(DefaultDarkPalette()) | 48 | css := GenerateCSS(DefaultDarkPalette()) | ||
| 39 | 49 | ||||
M internal/ui/theme/manager.go +25 -11
| @@ -15,15 +15,16 @@ import ( | |||||
| 15 | // Manager handles theme state and CSS application. | 15 | // Manager handles theme state and CSS application. | ||
| 16 | // Theme precedence and fallback are resolved before data reaches this adapter. | 16 | // Theme precedence and fallback are resolved before data reaches this adapter. | ||
| 17 | type Manager struct { | 17 | type Manager struct { | ||
| 18 | - | prefersDark bool // Resolved dark mode preference | 18 | + | prefersDark bool // Resolved dark mode preference |
| 19 | - | lightPalette Palette // Light theme colors | 19 | + | lightPalette Palette // Light theme colors |
| 20 | - | darkPalette Palette // Dark theme colors | 20 | + | darkPalette Palette // Dark theme colors |
| 21 | - | uiScale float64 // UI scaling factor (1.0 = 100%) | 21 | + | uiScale float64 // UI scaling factor (1.0 = 100%) |
| 22 | - | fonts FontConfig | 22 | + | fonts FontConfig |
| 23 | - | gtkFont string | 23 | + | gtkFont string |
| 24 | - | modeColors ModeColors // Modal mode indicator colors | 24 | + | modeColors ModeColors // Modal mode indicator colors |
| 25 | - | cssProvider *gtk.CssProvider | 25 | + | transitionDurationMs int |
| 26 | - | appliedFont string | 26 | + | cssProvider *gtk.CssProvider |
| 27 | + | appliedFont string | |||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | // NewManager creates a new theme manager from an already-resolved theme. | 30 | // NewManager creates a new theme manager from an already-resolved theme. | ||
| @@ -58,6 +59,18 @@ func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) { | |||||
| 58 | } | 59 | } | ||
| 59 | m.gtkFont = m.fonts.GtkFont | 60 | m.gtkFont = m.fonts.GtkFont | ||
| 60 | m.modeColors = ModeColorsFromEntity(resolved.ModeColors) | 61 | m.modeColors = ModeColorsFromEntity(resolved.ModeColors) | ||
| 62 | + | if m.transitionDurationMs <= 0 { | |||
| 63 | + | m.transitionDurationMs = defaultTransitionDurationMs | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // SetTransitionDuration sets the CSS transition duration used for theme-driven | |||
| 68 | + | // animations such as Page mode pulse timing. | |||
| 69 | + | func (m *Manager) SetTransitionDuration(ms int) { | |||
| 70 | + | if ms < 0 { | |||
| 71 | + | ms = defaultTransitionDurationMs | |||
| 72 | + | } | |||
| 73 | + | m.transitionDurationMs = ms | |||
| 61 | } | 74 | } | ||
| 62 | 75 | ||||
| 63 | // PrefersDark returns true if dark mode is active. | 76 | // PrefersDark returns true if dark mode is active. | ||
| @@ -135,9 +148,10 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||||
| 135 | return | 148 | return | ||
| 136 | } | 149 | } | ||
| 137 | 150 | ||||
| 138 | - | // Generate CSS with current palette, UI scale, fonts, and mode colors | 151 | + | // Generate CSS with current palette, UI scale, fonts, mode colors, and the |
| 152 | + | // configured transition duration. | |||
| 139 | palette := m.GetCurrentPalette() | 153 | palette := m.GetCurrentPalette() | ||
| 140 | - | css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors) | 154 | + | css := GenerateCSSFullWithTiming(palette, m.uiScale, m.fonts, m.modeColors, m.transitionDurationMs) |
| 141 | fontName := formatGTKFontName(m.gtkFont, m.uiScale) | 155 | fontName := formatGTKFontName(m.gtkFont, m.uiScale) | ||
| 142 | 156 | ||||
| 143 | settings := gtk.SettingsGetForDisplay(display) | 157 | settings := gtk.SettingsGetForDisplay(display) | ||