detached c649a8d

Merge pull request #364 from bnema/feat/page-mode

feat(page-mode): add keyboard page navigation
commit
c649a8d60a88a87cf74a060094db974b6cdf3f90
author
bnema · · 2026-07-29 07:22 UTC
committer
GitHub · · 2026-07-29 07:22 UTC
merge of
11ab80c + c8b70f3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
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 workflow47 ### Keyboard workflow
48 48
49-- Pane, tab, resize, and session modes49+- Pane, tab, page, resize, and session modes
50 - Vim/Zellij-style navigation patterns50 - 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 notes180 ### 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 Mode453 ### 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 ```toml593 ```toml
541 [workspace.styling]594 [workspace.styling]
542 border_width = 1595 border_width = 1
543 border_color = "@theme_selected_bg_color"596 border_color = "@theme_selected_bg_color"
544 mode_border_width = 4597 mode_border_width = 4
545-pane_mode_color = "#4A90E2" # Blue for pane mode598+pane_mode_color = "#4A90E2" # Blue for pane mode and Page mode local accent
546 tab_mode_color = "#FFA500" # Orange for tab mode599 tab_mode_color = "#FFA500" # Orange for tab mode
547 session_mode_color = "#9B59B6" # Purple for session mode600 session_mode_color = "#9B59B6" # Purple for session mode
548 resize_mode_color = "#00D4AA" # Teal for resize mode601 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 Reference26 ## 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 panes30 3. `Shift+arrows` to focus adjacent panes
29 4. `X` to close current pane31 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 ## Omnibox50 ## 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 _c3113 return _c
3114 }3114 }
3115+
3116+// NewMockPageScrollable creates a new instance of MockPageScrollable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3117+// The first argument is typically a *testing.T value.
3118+func NewMockPageScrollable(t interface {
3119+ mock.TestingT
3120+ Cleanup(func())
3121+}) *MockPageScrollable {
3122+ mock := &MockPageScrollable{}
3123+ mock.Mock.Test(t)
3124+
3125+ t.Cleanup(func() { mock.AssertExpectations(t) })
3126+
3127+ return mock
3128+}
3129+
3130+// MockPageScrollable is an autogenerated mock type for the PageScrollable type
3131+type MockPageScrollable struct {
3132+ mock.Mock
3133+}
3134+
3135+type MockPageScrollable_Expecter struct {
3136+ mock *mock.Mock
3137+}
3138+
3139+func (_m *MockPageScrollable) EXPECT() *MockPageScrollable_Expecter {
3140+ return &MockPageScrollable_Expecter{mock: &_m.Mock}
3141+}
3142+
3143+// ScrollPage provides a mock function for the type MockPageScrollable
3144+func (_mock *MockPageScrollable) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
3145+ ret := _mock.Called(ctx, request)
3146+
3147+ if len(ret) == 0 {
3148+ panic("no return value specified for ScrollPage")
3149+ }
3150+
3151+ var r0 error
3152+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.PageScrollRequest) error); ok {
3153+ r0 = returnFunc(ctx, request)
3154+ } else {
3155+ r0 = ret.Error(0)
3156+ }
3157+ return r0
3158+}
3159+
3160+// MockPageScrollable_ScrollPage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ScrollPage'
3161+type MockPageScrollable_ScrollPage_Call struct {
3162+ *mock.Call
3163+}
3164+
3165+// ScrollPage is a helper method to define mock.On call
3166+// - ctx context.Context
3167+// - request port.PageScrollRequest
3168+func (_e *MockPageScrollable_Expecter) ScrollPage(ctx any, request any) *MockPageScrollable_ScrollPage_Call {
3169+ return &MockPageScrollable_ScrollPage_Call{Call: _e.mock.On("ScrollPage", ctx, request)}
3170+}
3171+
3172+func (_c *MockPageScrollable_ScrollPage_Call) Run(run func(ctx context.Context, request port.PageScrollRequest)) *MockPageScrollable_ScrollPage_Call {
3173+ _c.Call.Run(func(args mock.Arguments) {
3174+ var arg0 context.Context
3175+ if args[0] != nil {
3176+ arg0 = args[0].(context.Context)
3177+ }
3178+ var arg1 port.PageScrollRequest
3179+ if args[1] != nil {
3180+ arg1 = args[1].(port.PageScrollRequest)
3181+ }
3182+ run(
3183+ arg0,
3184+ arg1,
3185+ )
3186+ })
3187+ return _c
3188+}
3189+
3190+func (_c *MockPageScrollable_ScrollPage_Call) Return(err error) *MockPageScrollable_ScrollPage_Call {
3191+ _c.Call.Return(err)
3192+ return _c
3193+}
3194+
3195+func (_c *MockPageScrollable_ScrollPage_Call) RunAndReturn(run func(ctx context.Context, request port.PageScrollRequest) error) *MockPageScrollable_ScrollPage_Call {
3196+ _c.Call.Return(run)
3197+ return _c
3198+}
M internal/application/port/webview.go +47 -0
@@ -142,6 +142,10 @@ type WebViewCallbacks struct {
142 // OnTouchpadNavigationGesture is called while a two-finger touchpad history142 // 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() TextInputTarget395 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 line95 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 app284 return app
285 }285 }
286 286
287 // NewSubprocessApp returns a lightweight raw App implementation for CEF287 // NewSubprocessApp returns a lightweight raw App implementation for CEF
288-// subprocesses re-executed from the main dumber binary. Keep the legacy288+// subprocesses re-executed from the main dumber binary. Keep the legacy V8
289-// renderer bridge disabled, but allow the minimal popup-opener render handler289+// renderer bridge disabled while composing editable-focus reporting with the
290-// needed to install synthetic opener state before page scripts run.290+// popup-opener callbacks needed before page scripts run.
291 func NewSubprocessApp() purecef.App {291 func NewSubprocessApp() purecef.App {
292- return &subprocessApp{rph: newPopupOpenerRenderProcessHandler()}292+ return &subprocessApp{rph: newComposedRenderProcessHandler()}
293 }293 }
294 294
295 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.295 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
M internal/infrastructure/cef/popup_opener_renderer.go +25 -0
@@ -50,6 +50,31 @@ func decodePopupOpenerRenderMetadata(extraInfo purecef.DictionaryValue) (popupOp
50 return metadata, true50 return metadata, true
51 }51 }
52 52
53+// composedRenderProcessHandler keeps the popup-opener handler as the primary
54+// CEF renderer handler and adds only editable-focus reporting from the legacy
55+// renderer bridge. Embedding preserves every popup callback without enabling
56+// the bridge's V8 extension in OnWebKitInitialized.
57+type composedRenderProcessHandler struct {
58+ *popupOpenerRenderProcessHandler
59+ editableFocusHandler *rendererBridgeProcessHandler
60+}
61+
62+func newComposedRenderProcessHandler() *composedRenderProcessHandler {
63+ return &composedRenderProcessHandler{
64+ popupOpenerRenderProcessHandler: newPopupOpenerRenderProcessHandler(),
65+ editableFocusHandler: &rendererBridgeProcessHandler{},
66+ }
67+}
68+
69+func (h *composedRenderProcessHandler) OnFocusedNodeChanged(
70+ browser purecef.Browser,
71+ frame purecef.Frame,
72+ node purecef.Domnode,
73+) {
74+ h.popupOpenerRenderProcessHandler.OnFocusedNodeChanged(browser, frame, node)
75+ h.editableFocusHandler.OnFocusedNodeChanged(browser, frame, node)
76+}
77+
53 type popupOpenerRenderProcessHandler struct {78 type popupOpenerRenderProcessHandler struct {
54 mu sync.RWMutex79 mu sync.RWMutex
55 byBrowserID map[int32]popupOpenerRenderMetadata80 byBrowserID map[int32]popupOpenerRenderMetadata
M internal/infrastructure/cef/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.Second73 cefGTKSyncDispatchTimeout = 2 * time.Second
71 cefGTKSyncDispatchSlowThreshold = 250 * time.Millisecond74 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.NewTask94 cefNewTask = purecef.NewTask
76 cefNewStringVisitor = purecef.NewStringVisitor95 cefNewStringVisitor = purecef.NewStringVisitor
@@ -131,6 +150,8 @@ type WebView struct {
131 adaptiveFrameRatePollID uint150 adaptiveFrameRatePollID uint
132 lastAdaptiveFrameRate int32151 lastAdaptiveFrameRate int32
133 152
153+ pageScrollQueue pageScrollQueue
154+
134 // beginFrameTick drives CEF external BeginFrame requests while the GTK155 // 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.TickCallback157 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 return1223 return
1203 }1224 }
1225+ wv.resetPageScrollQueue()
1204 wv.syntheticPopupMu.Lock()1226 wv.syntheticPopupMu.Lock()
1205 wv.syntheticPopups = nil1227 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.focusedEditable1446 previous := wv.focusedEditable
1425 wv.focusedEditable = editable1447 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.selectionDebounceTimer1452+ timer = wv.selectionDebounceTimer
1429 wv.selectionDebounceTimer = nil1453 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 pc2395 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 new2556 // beginAudioStreamStart invalidates any prior stream before creating a new
2372 // one. A stream created for the returned generation may only be published if2557 // 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.callbacks478 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 := false582 called := false
549 wv := &WebView{closeCallbacks: []func(){func() { called = true }}}583 wv := &WebView{closeCallbacks: []func(){func() { called = true }}}
A internal/infrastructure/cef/webview_scroll_test.go +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 = 300044 defaultTabTimeoutMilliseconds = 3000
45 defaultResizeActivationShortcut = "ctrl+n"45 defaultResizeActivationShortcut = "ctrl+n"
46 defaultResizeTimeoutMilliseconds = 300046 defaultResizeTimeoutMilliseconds = 3000
47+ defaultPageActivationShortcut = "ctrl+y"
48+ defaultPageTimeoutMilliseconds = 0
47 defaultResizeStepPercent = 5.049 defaultResizeStepPercent = 5.0
48 defaultResizeMinPanePercent = 10.050 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.Actions111 cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions
110 cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions112 cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions
113+ cfg.Workspace.PageMode.Actions = defaults.Workspace.PageMode.Actions
111 cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions114 cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions
112 cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts115 cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts
113 cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions116 cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions
@@ -144,6 +147,16 @@ func (g *KeybindingsGateway) buildTabModeGroup(cfg, defaults *Config) port.Keybi
144 }147 }
145 }148 }
146 149
150+// 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.Keys221 existing.Keys = req.Keys
209 cfg.Workspace.TabMode.Actions[req.Action] = existing222 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.Keys231 existing.Keys = req.Keys
@@ -236,6 +254,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string)
236 if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok {254 if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok {
237 return binding.Keys255 return binding.Keys
238 }256 }
257+ case "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.Keys263 return binding.Keys
@@ -269,6 +291,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction
269 addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)291 addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)
270 addBindings("pane", cfg.Workspace.PaneMode.Actions)292 addBindings("pane", cfg.Workspace.PaneMode.Actions)
271 addBindings("tab", cfg.Workspace.TabMode.Actions)293 addBindings("tab", cfg.Workspace.TabMode.Actions)
294+ addBindings("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.ResizeModeConfig25 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.SessionModeConfig31 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 mode498 // 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 []string206+ 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 []string229 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] = action252+ seenKeys[key] = action
266 }253 }
267 }254 }
268 return validationErrors255 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 JS182 // 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 string184 var accentDetectionScript string
185+var accentDetectionScriptKeys string
185 186
186 func buildExplicitCopyScript() string {187 func buildExplicitCopyScript() string {
187 return explicitCopyScript188 return explicitCopyScript
188 }189 }
189 190
190-func init() {191+func buildAccentDetectionScript(token string) string {
191- // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"192+ return fmt.Sprintf(`(function() {
192- keys := make([]string, 0, len(entity.AccentMap))
193- for k := range entity.AccentMap {
194- keys = append(keys, fmt.Sprintf("'%c'", k))
195- }
196- sort.Strings(keys)
197-
198- accentDetectionScript = fmt.Sprintf(`(function() {
199 'use strict';193 'use strict';
200 const accentKeys = new Set([%s]);194 const accentKeys = new Set([%s]);
195+ const editableFocusToken = %q;
201 let pressedKey = null;196 let pressedKey = null;
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 pages501 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 return193 return
217 }194 }
218 195
@@ -221,6 +198,9 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
221 log.Warn().Msg("rejecting script message from unknown sender")198 log.Warn().Msg("rejecting script message from unknown sender")
222 return199 return
223 }200 }
201+ if r.handleAllowlistedBridgeMessage(senderWV, msg) {
202+ return
203+ }
224 if !isTrustedBridgeWebView(senderWV) {204 if !isTrustedBridgeWebView(senderWV) {
225 log.Warn().205 log.Warn().
226 Str("type", msg.Type).206 Str("type", msg.Type).
@@ -279,6 +259,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) > 0248 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 != nil259 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 fullscreen187 OnEnterFullscreen func() bool // Return true to prevent fullscreen
164 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen188 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
165 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops189 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
190+ OnEditableFocusChanged func(editable bool) // Called when page editable focus changes
166 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)191 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
167 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)192 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
168 browsingContextDecision dto.HostDecision193 browsingContextDecision dto.HostDecision
@@ -179,8 +204,9 @@ type WebView struct {
179 logger zerolog.Logger204 logger zerolog.Logger
180 mu sync.RWMutex205 mu sync.RWMutex
181 206
182- frontendAttached atomic.Bool207+ frontendAttached atomic.Bool
183- navigationActive atomic.Bool208+ navigationActive atomic.Bool
209+ editableFocusBridgeToken string
184 210
185 // asyncCallbacks keeps references to async JS callbacks to prevent GC211 // asyncCallbacks keeps references to async JS callbacks to prevent GC
186 asyncCallbacks []any212 asyncCallbacks []any
@@ -346,11 +372,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
346 }372 }
347 373
348 wv := &WebView{374 wv := &WebView{
349- inner: inner,375+ inner: inner,
350- ucm: inner.GetUserContentManager(),376+ ucm: inner.GetUserContentManager(),
351- logger: log.With().Str("component", "webview").Logger(),377+ logger: log.With().Str("component", "webview").Logger(),
352- signalIDs: make([]uintptr, 0, 4),378+ signalIDs: make([]uintptr, 0, 4),
353- runJSErrorStats: make(map[string]runJSErrorStat),379+ runJSErrorStats: make(map[string]runJSErrorStat),
380+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
354 }381 }
355 382
356 // Register in global registry383 // Register in global registry
@@ -396,12 +423,13 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti
396 Msg("related webview created, checking pointers")423 Msg("related webview created, checking pointers")
397 424
398 wv := &WebView{425 wv := &WebView{
399- inner: inner,426+ inner: inner,
400- isRelated: true, // Shares web process with parent - must not terminate process on destroy427+ isRelated: true, // Shares web process with parent - must not terminate process on destroy
401- ucm: inner.GetUserContentManager(),428+ ucm: inner.GetUserContentManager(),
402- logger: log.With().Str("component", "webview-popup").Logger(),429+ logger: log.With().Str("component", "webview-popup").Logger(),
403- signalIDs: make([]uintptr, 0, 6),430+ signalIDs: make([]uintptr, 0, 6),
404- runJSErrorStats: make(map[string]runJSErrorStat),431+ runJSErrorStats: make(map[string]runJSErrorStat),
432+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
405 }433 }
406 434
407 wv.id = globalRegistry.register(wv)435 wv.id = globalRegistry.register(wv)
@@ -448,37 +476,49 @@ func (wv *WebView) connectLoadChangedSignal() {
448 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {476 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
449 uri := inner.GetUri()477 uri := inner.GetUri()
450 title := inner.GetTitle()478 title := inner.GetTitle()
451-479+ progress := inner.GetEstimatedLoadProgress()
452- wv.mu.Lock()480+ wv.handleLoadChanged(LoadEvent(event), uri, title, progress)
453- wv.uri = uri
454- wv.title = title
455- // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
456- // which fires for both traditional navigation and SPA history.pushState()
457- wv.progress = inner.GetEstimatedLoadProgress()
458-
459- switch event {
460- case webkit.LoadStartedValue:
461- wv.navigationActive.Store(true)
462- wv.isLoading = true
463- wv.logger.Debug().Str("uri", uri).Msg("load started")
464- case webkit.LoadRedirectedValue:
465- wv.logger.Debug().Str("uri", uri).Msg("load redirected")
466- case webkit.LoadCommittedValue:
467- wv.logger.Debug().Str("uri", uri).Msg("load committed")
468- case webkit.LoadFinishedValue:
469- wv.isLoading = false
470- wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
471- }
472- wv.mu.Unlock()
473-
474- if wv.OnLoadChanged != nil {
475- wv.OnLoadChanged(LoadEvent(event))
476- }
477 }481 }
478 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)482 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
479 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))483 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
480 }484 }
481 485
486+// handleLoadChanged updates load state then notifies outside wv.mu so callbacks
487+// may re-enter URI()/State() without deadlocking.
488+func (wv *WebView) handleLoadChanged(event LoadEvent, uri, title string, progress float64) {
489+ wv.mu.Lock()
490+ wv.uri = uri
491+ wv.title = title
492+ // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
493+ // which fires for both traditional navigation and SPA history.pushState()
494+ wv.progress = progress
495+
496+ notifyEditableBlur := false
497+ switch event {
498+ case LoadStarted:
499+ wv.navigationActive.Store(true)
500+ wv.isLoading = true
501+ wv.logger.Debug().Str("uri", uri).Msg("load started")
502+ notifyEditableBlur = true
503+ case LoadRedirected:
504+ wv.logger.Debug().Str("uri", uri).Msg("load redirected")
505+ case LoadCommitted:
506+ wv.logger.Debug().Str("uri", uri).Msg("load committed")
507+ case LoadFinished:
508+ wv.isLoading = false
509+ wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
510+ }
511+ wv.mu.Unlock()
512+
513+ if notifyEditableBlur {
514+ wv.dispatchEditableFocusChanged(false)
515+ }
516+
517+ if wv.OnLoadChanged != nil {
518+ wv.OnLoadChanged(event)
519+ }
520+}
521+
482 func (wv *WebView) connectLoadFailedSignal() {522 func (wv *WebView) connectLoadFailedSignal() {
483 loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool {523 loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool {
484 wv.logger.Warn().524 wv.logger.Warn().
@@ -1762,6 +1802,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1762 wv.OnEnterFullscreen = nil1802 wv.OnEnterFullscreen = nil
1763 wv.OnLeaveFullscreen = nil1803 wv.OnLeaveFullscreen = nil
1764 wv.OnAudioStateChanged = nil1804 wv.OnAudioStateChanged = nil
1805+ wv.OnEditableFocusChanged = nil
1765 return1806 return
1766 }1807 }
1767 1808
@@ -1807,6 +1848,16 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1807 wv.OnEnterFullscreen = callbacks.OnEnterFullscreen1848 wv.OnEnterFullscreen = callbacks.OnEnterFullscreen
1808 wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen1849 wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen
1809 wv.OnAudioStateChanged = callbacks.OnAudioStateChanged1850 wv.OnAudioStateChanged = callbacks.OnAudioStateChanged
1851+ wv.OnEditableFocusChanged = callbacks.OnEditableFocusChanged
1852+}
1853+
1854+func (wv *WebView) dispatchEditableFocusChanged(editable bool) {
1855+ if wv == nil || wv.destroyed.Load() {
1856+ return
1857+ }
1858+ if wv.OnEditableFocusChanged != nil {
1859+ wv.OnEditableFocusChanged(editable)
1860+ }
1810 }1861 }
1811 1862
1812 // ShowDevTools opens the WebKit inspector/developer tools.1863 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1999,6 +2050,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
1999 wv.OnEnterFullscreen = nil2050 wv.OnEnterFullscreen = nil
2000 wv.OnLeaveFullscreen = nil2051 wv.OnLeaveFullscreen = nil
2001 wv.OnAudioStateChanged = nil2052 wv.OnAudioStateChanged = nil
2053+ wv.OnEditableFocusChanged = nil
2002 wv.OnLinkHover = nil2054 wv.OnLinkHover = nil
2003 wv.OnWebProcessTerminated = nil2055 wv.OnWebProcessTerminated = nil
2004 wv.OnPermissionRequest = nil2056 wv.OnPermissionRequest = nil
@@ -2047,6 +2099,7 @@ func (wv *WebView) ResetForPoolReuse() {
2047 return2099 return
2048 }2100 }
2049 wv.generation.Add(1)2101 wv.generation.Add(1)
2102+ wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken())
2050 2103
2051 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.2104 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.
2052 // They will be reconnected when the WebView is re-acquired from the pool.2105 // They will be reconnected when the WebView is re-acquired from the pool.
@@ -2064,6 +2117,7 @@ func (wv *WebView) ResetForPoolReuse() {
2064 wv.OnEnterFullscreen = nil2117 wv.OnEnterFullscreen = nil
2065 wv.OnLeaveFullscreen = nil2118 wv.OnLeaveFullscreen = nil
2066 wv.OnAudioStateChanged = nil2119 wv.OnAudioStateChanged = nil
2120+ wv.OnEditableFocusChanged = nil
2067 wv.OnLinkHover = nil2121 wv.OnLinkHover = nil
2068 wv.OnWebProcessTerminated = nil2122 wv.OnWebProcessTerminated = nil
2069 wv.OnPermissionRequest = nil2123 wv.OnPermissionRequest = nil
@@ -2376,3 +2430,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 nil2431 return nil
2378 }2432 }
2433+
2434+// ScrollPage scrolls from the viewport center, handing exhausted nested
2435+// scrollers off to their ancestors and then the document. CEF uses the same
2436+// shared directional DOM-target resolution once its browser is ready.
2437+// Implements port.PageScrollable.
2438+func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
2439+ if 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 webkit1 package webkit
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
6+ "fmt"
5 "testing"7 "testing"
6 "time"8 "time"
7 9
10+ "github.com/bnema/dumber/internal/application/port"
8 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
9 )13 )
10 14
11 func TestClassifyRunJSEvaluateError(t *testing.T) {15 func TestClassifyRunJSEvaluateError(t *testing.T) {
@@ -87,3 +91,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.Manager124 focusMgr *focus.Manager
119 125
126+ pageModePolicyUC *usecase.PageModePolicyUseCase
127+ pageEditableFocusByPane map[entity.PaneID]bool
128+
120 resizeModeBorderTarget layout.Widget129 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 support159 // Accent picker for dead keys support
151 accentFocusProvider port.FocusedInputProvider160 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 navigation168 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
154 deferredInitOnce sync.Once169 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.AutoCopyConfig233 var autoCopyConfig port.AutoCopyConfig
214 var clipboardOrchestrator port.ClipboardTextOrchestrator234 var clipboardOrchestrator port.ClipboardTextOrchestrator
215 if deps.Clipboard != nil {235 if deps.Clipboard != nil {
@@ -814,6 +834,9 @@ func (a *App) wireSessionManagerShortcut() {
814 return nil834 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 nil840 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.RouteHandleShortcuts1127 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 input1375 // 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 input1414 // When find bar loses focus, set WebView as the focused input
@@ -1400,6 +1432,9 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
1400 return1432 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 nil1537 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 persistence3258 // 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 URI3278 // 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 paints3285 // 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 nil3401 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 return3998 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 return4015 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 return4045 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 historySidebarReloader52 historySidebarReloader historySidebarReloader
53 sidebarVisible bool53 sidebarVisible bool
54 activeSidebarKind nativeSidebarKind54 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 = nil111 bw.favoritesSidebar = nil
109 bw.historySidebarReloader = nil112 bw.historySidebarReloader = nil
110 bw.activeSidebarKind = nativeSidebarNone113 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 overlay15 // 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.PaneID52 paneID entity.PaneID
32 isActive bool53 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.RWMutex73 mu sync.RWMutex
74 }74 }
75 75
76+// SetPaneViewsForTest injects pane views into the workspace view for testing.
77+// This is a narrow testability seam; production code populates pane views via
78+// SetWorkspace and the tree renderer. The workspace field must be set
79+// separately on the entity if the tests exercise code paths that read it.
80+func (wv *WorkspaceView) SetPaneViewsForTest(paneViews map[entity.PaneID]*PaneView) {
81+ wv.mu.Lock()
82+ defer wv.mu.Unlock()
83+ wv.paneViews = paneViews
84+}
85+
76 // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface86 // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
77 // so TreeRenderer can create PaneViews.87 // so TreeRenderer can create PaneViews.
78 type paneViewFactoryAdapter struct {88 type paneViewFactoryAdapter struct {
M internal/ui/coordinator/content/callbacks.go +6 -0
@@ -179,6 +179,12 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
179 }179 }
180 }180 }
181 181
182+ callbacks.OnEditableFocusChanged = func(editable bool) {
183+ if c.onEditableFocusChanged != nil {
184+ c.onEditableFocusChanged(paneID, editable)
185+ }
186+ }
187+
182 // Add popup create handler if popup handling is configured188 // Add popup create handler if popup handling is configured
183 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)189 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
184 190
M internal/ui/coordinator/content/coordinator.go +8 -0
@@ -109,6 +109,9 @@ type Coordinator struct {
109 // Callback when WebView gains focus (for accent picker text input targeting)109 // Callback when WebView gains focus (for accent picker text input targeting)
110 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)110 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
111 111
112+ // Callback when a page editable target gains or loses focus.
113+ onEditableFocusChanged func(paneID entity.PaneID, editable bool)
114+
112 // Callback for first load_started event (triggers deferred initialization)115 // Callback for first load_started event (triggers deferred initialization)
113 onFirstLoadStarted func()116 onFirstLoadStarted func()
114 loadStartedOnce sync.Once117 loadStartedOnce sync.Once
@@ -243,6 +246,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.
243 c.onWebViewFocused = fn246 c.onWebViewFocused = fn
244 }247 }
245 248
249+// SetOnEditableFocusChanged sets the callback for page editable focus changes.
250+func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
251+ c.onEditableFocusChanged = fn
252+}
253+
246 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.254 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.
247 // This is used to trigger deferred initialization after the initial load_uri()255 // This is used to trigger deferred initialization after the initial load_uri()
248 // has been processed by the GTK main loop.256 // has been processed by the GTK main loop.
A internal/ui/coordinator/content/editable_focus_test.go +84 -0
@@ -0,0 +1,84 @@
1+package content
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/application/port/mocks"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+func TestEditableFocusCallback_ForwardsPaneID(t *testing.T) {
17+ c := newMinimalCoordinator()
18+ wv := mocks.NewMockWebView(t)
19+
20+ var callbacks *port.WebViewCallbacks
21+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
22+ wv.EXPECT().Generation().Return(uint64(0)).Twice()
23+ wv.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) {
24+ callbacks = cb
25+ }).Once()
26+
27+ var gotPaneID entity.PaneID
28+ var gotEditable bool
29+ c.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
30+ gotPaneID = paneID
31+ gotEditable = editable
32+ })
33+
34+ c.setupWebViewCallbacks(context.Background(), entity.PaneID("pane-1"), wv)
35+ require.NotNil(t, callbacks)
36+ require.NotNil(t, callbacks.OnEditableFocusChanged)
37+
38+ callbacks.OnEditableFocusChanged(true)
39+
40+ assert.Equal(t, entity.PaneID("pane-1"), gotPaneID)
41+ assert.True(t, gotEditable)
42+}
43+
44+func TestEditableFocusCallback_BackgroundPaneDoesNotConfusePaneOwnership(t *testing.T) {
45+ c := newMinimalCoordinator()
46+ c.SetActivePaneOverride(entity.PaneID("pane-a"))
47+
48+ wvA := mocks.NewMockWebView(t)
49+ wvB := mocks.NewMockWebView(t)
50+
51+ var callbacksA *port.WebViewCallbacks
52+ var callbacksB *port.WebViewCallbacks
53+ wvA.EXPECT().ID().Return(port.WebViewID(1)).Once()
54+ wvA.EXPECT().Generation().Return(uint64(0)).Twice()
55+ wvA.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) {
56+ callbacksA = cb
57+ }).Once()
58+ wvB.EXPECT().ID().Return(port.WebViewID(2)).Once()
59+ wvB.EXPECT().Generation().Return(uint64(0)).Twice()
60+ wvB.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) {
61+ callbacksB = cb
62+ }).Once()
63+
64+ type event struct {
65+ paneID entity.PaneID
66+ editable bool
67+ }
68+ var events []event
69+ c.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
70+ events = append(events, event{paneID: paneID, editable: editable})
71+ })
72+
73+ c.setupWebViewCallbacks(context.Background(), entity.PaneID("pane-a"), wvA)
74+ c.setupWebViewCallbacks(context.Background(), entity.PaneID("pane-b"), wvB)
75+ require.NotNil(t, callbacksA)
76+ require.NotNil(t, callbacksB)
77+
78+ callbacksB.OnEditableFocusChanged(true)
79+ callbacksA.OnEditableFocusChanged(false)
80+
81+ require.Len(t, events, 2)
82+ assert.Equal(t, event{paneID: entity.PaneID("pane-b"), editable: true}, events[0])
83+ assert.Equal(t, event{paneID: entity.PaneID("pane-a"), editable: false}, events[1])
84+}
M internal/ui/coordinator/navigation.go +51 -0
@@ -23,6 +23,7 @@ type NavigationCoordinator struct {
23 contextProvider func() context.Context23 contextProvider func() context.Context
24 navigateUC *usecase.NavigateUseCase24 navigateUC *usecase.NavigateUseCase
25 historyRecorder *usecase.HistoryRecorderUseCase25 historyRecorder *usecase.HistoryRecorderUseCase
26+ pageScrollUC *usecase.PageScrollUseCase
26 contentCoord *content.Coordinator27 contentCoord *content.Coordinator
27 omniboxProvider OmniboxProvider28 omniboxProvider OmniboxProvider
28 }29 }
@@ -275,6 +276,56 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
275 c.historyRecorder.ClearPaneHistory(string(paneID))276 c.historyRecorder.ClearPaneHistory(string(paneID))
276 }277 }
277 278
279+// SetPageScrollUseCase sets the PageScrollUseCase for page scrolling operations.
280+// This is an optional dependency: ScrollWebView will return an error if not set.
281+func (c *NavigationCoordinator) SetPageScrollUseCase(uc *usecase.PageScrollUseCase) {
282+ c.pageScrollUC = uc
283+}
284+
285+// ScrollWebView applies a semantic page scroll command to the provided WebView.
286+// It delegates to PageScrollUseCase for delta computation and Scrollable dispatch.
287+// The PageScrollUseCase must be set via SetPageScrollUseCase before calling this.
288+func (c *NavigationCoordinator) ScrollWebView(ctx context.Context, wv port.WebView, cmd usecase.PageScrollCommand) error {
289+ log := logging.FromContext(ctx)
290+
291+ if err := requireWebView(wv); err != nil {
292+ log.Warn().Str("command", cmd.String()).Msg("ScrollWebView called with nil webview")
293+ return err
294+ }
295+
296+ log.Debug().Str("command", cmd.String()).Uint64("webview_id", uint64(wv.ID())).Msg("scrolling webview")
297+
298+ if c.pageScrollUC == nil {
299+ err := fmt.Errorf("page scroll usecase not set")
300+ log.Error().Msg(err.Error())
301+ return err
302+ }
303+
304+ return c.pageScrollUC.Scroll(ctx, wv, cmd)
305+}
306+
307+// ScrollWebViewContinuous applies one autonomous held-key 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.MockWebView19 *mocks.MockWebView
14 *mocks.MockDevToolsOpener20 *mocks.MockDevToolsOpener
@@ -76,6 +82,15 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi
76 t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {82 t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {
77 testPrintWebViewTargetsProvidedWebView(t, ctx)83 testPrintWebViewTargetsProvidedWebView(t, ctx)
78 })84 })
85+ t.Run("ScrollWebView with nil webview returns error", func(t *testing.T) {
86+ testScrollWebViewNilWebView(t, ctx)
87+ })
88+ t.Run("ScrollWebView delegates each scroll command through usecase", func(t *testing.T) {
89+ testScrollWebViewDelegatesEachCommand(t, ctx)
90+ })
91+ t.Run("ScrollWebView usecase not set returns error", func(t *testing.T) {
92+ testScrollWebViewUsecaseNotSet(t, ctx)
93+ })
79 }94 }
80 95
81 func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {96 func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {
@@ -268,3 +283,62 @@ func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
268 t.Fatalf("unexpected error: %v", err)283 t.Fatalf("unexpected error: %v", err)
269 }284 }
270 }285 }
286+
287+func testScrollWebViewNilWebView(t *testing.T, ctx context.Context) {
288+ t.Helper()
289+ c := &NavigationCoordinator{}
290+ c.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
291+ err := c.ScrollWebView(ctx, nil, usecase.PageScrollDown)
292+ if err == nil {
293+ t.Fatal("expected error for nil webview, got nil")
294+ }
295+}
296+
297+func testScrollWebViewDelegatesEachCommand(t *testing.T, ctx context.Context) {
298+ t.Helper()
299+ commands := []struct {
300+ cmd usecase.PageScrollCommand
301+ dx int
302+ dy int
303+ }{
304+ {usecase.PageScrollLeft, -80, 0},
305+ {usecase.PageScrollRight, 80, 0},
306+ {usecase.PageScrollUp, 0, -80},
307+ {usecase.PageScrollDown, 0, 80},
308+ {usecase.PageScrollUpFast, 0, -320},
309+ {usecase.PageScrollDownFast, 0, 320},
310+ }
311+
312+ for _, tc := range commands {
313+ t.Run(tc.cmd.String(), func(t *testing.T) {
314+ base := mocks.NewMockWebView(t)
315+ scroller := mocks.NewMockPageScrollable(t)
316+ wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}
317+ base.EXPECT().ID().Return(port.WebViewID(42)).Once()
318+ req := port.PageScrollRequest{Command: port.PageScrollCommand(tc.cmd), FallbackDX: tc.dx, FallbackDY: tc.dy}
319+ scroller.EXPECT().ScrollPage(ctx, req).Return(nil).Once()
320+
321+ c := &NavigationCoordinator{}
322+ c.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
323+ err := c.ScrollWebView(ctx, wv, tc.cmd)
324+ if err != nil {
325+ t.Fatalf("unexpected error for %s: %v", tc.cmd, err)
326+ }
327+ })
328+ }
329+}
330+
331+func testScrollWebViewUsecaseNotSet(t *testing.T, ctx context.Context) {
332+ t.Helper()
333+ base := mocks.NewMockWebView(t)
334+ wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: mocks.NewMockPageScrollable(t)}
335+ base.EXPECT().ID().Return(port.WebViewID(1)).Once()
336+ c := &NavigationCoordinator{}
337+ err := c.ScrollWebView(ctx, wv, usecase.PageScrollDown)
338+ if err == nil {
339+ t.Fatal("expected error for missing usecase, got nil")
340+ }
341+ if got, want := err.Error(), "page scroll usecase not set"; got != want {
342+ t.Fatalf("error = %q, want %q", got, want)
343+ }
344+}
M internal/ui/dispatcher/keyboard.go +91 -0
@@ -52,6 +52,7 @@ type KeyboardDispatcher struct {
52 onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error52 onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
53 onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error53 onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
54 onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error54 onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55+ onPageModePulse func(ctx context.Context, fast bool)
55 onToggleHistorySidebar func(ctx context.Context) error56 onToggleHistorySidebar func(ctx context.Context) error
56 onToggleFavoritesSidebar func(ctx context.Context) error57 onToggleFavoritesSidebar func(ctx context.Context) error
57 onToggleCurrentFavorite func(ctx context.Context) error58 onToggleCurrentFavorite func(ctx context.Context) error
@@ -156,6 +157,22 @@ func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context
156 d.onOpenFloating = fn157 d.onOpenFloating = fn
157 }158 }
158 159
160+// 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 = 0178 firstTabIndex = 0
@@ -249,6 +266,13 @@ func (d *KeyboardDispatcher) initActionHandlers() {
249 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },266 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
250 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },267 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
251 input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },268 input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
269+ // 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 // UI276 // 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 visibility69 // Add new mode class and set visibility
@@ -74,6 +77,11 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
74 case input.ModeSession:77 case input.ModeSession:
75 bm.borderOverlay.AddCssClass(sessionModeClass)78 bm.borderOverlay.AddCssClass(sessionModeClass)
76 bm.borderOverlay.SetVisible(true)79 bm.borderOverlay.SetVisible(true)
80+ case input.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 continue127 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 false299 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 evt494 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 continue591 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 true844 return true
821 default:845 default:
822 return false846 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) error37 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.WorkspaceConfig76 workspace *entity.WorkspaceConfig
59 session *entity.SessionConfig77 session *entity.SessionConfig
60 78
61- // Action handler callback79+ // Action handler callbacks.
62- onAction ActionHandler80+ 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 focused83 // 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 AccentHandler87 accentHandler AccentHandler
69 // Optional escape hook for app-level overlays88 // Optional escape hook for app-level overlays
70 onEscape func(ctx context.Context) bool89 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.EventControllerKey95 controller *gtk.EventControllerKey
@@ -82,6 +104,16 @@ type KeyboardHandler struct {
82 activePressedActions map[Action]uint104 activePressedActions map[Action]uint
83 ctx context.Context105 ctx context.Context
84 mu sync.RWMutex106 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 h140 return h
103 }141 }
@@ -109,9 +147,26 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
109 h.onAction = fn147 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 callback195 // 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 = 0333 h.keyPressedHandlerID = 0
276 h.keyReleasedHandlerID = 0334 h.keyReleasedHandlerID = 0
277 h.activePressedActions = nil335 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) & modifierMask355 modifiers := Modifier(state) & modifierMask
356+ mode := h.modal.Mode()
297 357
298 // Escape in normal mode: check app-level escape hook first358 // 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 true361 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 event378 // Determine routing for this key event
306 route := RouteHandleShortcuts // default: process through shortcut system379 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 mode431 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 true443 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 as551 // 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 keyval575 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.workspace740 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 true776 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 true794 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 global868 // DispatchAction processes an action externally triggered (e.g., by a global
608 // shortcut). Mode-enter actions update modal state; other actions are forwarded869 // 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.accentHandler884 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 return899 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 bool38 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 int16 type Mode int
13 17
@@ -22,6 +26,8 @@ const (
22 ModeSession26 ModeSession
23 // ModeResize is the modal pane resizing mode.27 // ModeResize is the modal pane resizing mode.
24 ModeResize28 ModeResize
29+ // 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 Mode73 mode Mode
64 timeout time.Duration74 timeout time.Duration
65- timer *time.Timer75+ timer modalTimer
66 timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks76 timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks
67 77
78+ afterFunc func(time.Duration, func()) modalTimer
79+
68 // Callback for mode changes (called synchronously under lock).80 // Callback for mode changes (called synchronously under lock).
69 onModeChange func(from, to Mode)81 onModeChange func(from, to Mode)
70 82
@@ -84,10 +96,16 @@ func NewModalState(ctx context.Context) *ModalState {
84 log.Debug().Msg("creating modal state")96 log.Debug().Msg("creating modal state")
85 return &ModalState{97 return &ModalState{
86 mode: ModeNormal,98 mode: ModeNormal,
99+ afterFunc: func(d time.Duration, fn func()) modalTimer { return time.AfterFunc(d, fn) },
87 scheduleOnMainThread: func(fn func()) { fn() },100 scheduleOnMainThread: func(fn func()) { fn() },
88 }101 }
89 }102 }
90 103
104+// 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 callbacks109 // SetMainThreadScheduler sets the function used to dispatch timer callbacks
92 // to the GTK main thread. In the real app this should be a glib.IdleAdd110 // to the GTK main thread. In the real app this should be a glib.IdleAdd
93 // wrapper. In tests, the default (direct execution) is used.111 // wrapper. In tests, the default (direct execution) is used.
@@ -223,7 +241,7 @@ func (m *ModalState) startTimeoutLocked(ctx context.Context, timeout time.Durati
223 m.timerGen++241 m.timerGen++
224 gen := m.timerGen242 gen := m.timerGen
225 schedule := m.scheduleOnMainThread243 schedule := m.scheduleOnMainThread
226- m.timer = time.AfterFunc(timeout, func() {244+ m.timer = m.afterFunc(timeout, func() {
227 schedule(func() {245 schedule(func() {
228 // Ignore stale callback: time.Timer.Stop can race with the246 // Ignore stale callback: time.Timer.Stop can race with the
229 // timer firing, so a canceled timer's callback may still run.247 // timer firing, so a canceled timer's callback may still run.
M internal/ui/input/shortcuts.go +62 -1
@@ -202,6 +202,15 @@ const (
202 202
203 // Application203 // 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 ShortcutTable231 SessionMode ShortcutTable
223 // ResizeMode shortcuts are only active in resize mode.232 // ResizeMode shortcuts are only active in resize mode.
224 ResizeMode ShortcutTable233 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 set272 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 return339 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] = ActionEnterTabMode352 s.Global[binding] = ActionEnterTabMode
313 log.Trace().353 log.Trace().
@@ -642,6 +682,20 @@ var configActionToAction = map[string]Action{
642 682
643 // Session actions683 // 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 modifiers852 // Normalize the binding modifiers
797 binding.Modifiers &= modifierMask853 binding.Modifiers &= modifierMask
@@ -807,12 +863,17 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
807 modeTable = s.ResizeMode863 modeTable = s.ResizeMode
808 case ModeSession:864 case ModeSession:
809 modeTable = s.SessionMode865 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, true872 return action, true
815 }873 }
874+ if mode == ModePage {
875+ return "", false
876+ }
816 }877 }
817 878
818 // Fall back to global879 // Fall back to global
M internal/ui/input/shortcuts_test.go +141 -0
@@ -1,8 +1,10 @@
1 package input1 package input
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
7+ "github.com/bnema/dumber/internal/domain/entity"
6 "github.com/bnema/puregotk/v4/gdk"8 "github.com/bnema/puregotk/v4/gdk"
7 )9 )
8 10
@@ -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 styling118 // 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 preference18+ prefersDark bool // Resolved dark mode preference
19- lightPalette Palette // Light theme colors19+ lightPalette Palette // Light theme colors
20- darkPalette Palette // Dark theme colors20+ darkPalette Palette // Dark theme colors
21- uiScale float64 // UI scaling factor (1.0 = 100%)21+ uiScale float64 // UI scaling factor (1.0 = 100%)
22- fonts FontConfig22+ fonts FontConfig
23- gtkFont string23+ gtkFont string
24- modeColors ModeColors // Modal mode indicator colors24+ modeColors ModeColors // Modal mode indicator colors
25- cssProvider *gtk.CssProvider25+ transitionDurationMs int
26- appliedFont string26+ cssProvider *gtk.CssProvider
27+ appliedFont string
27 }28 }
28 29
29 // NewManager creates a new theme manager from an already-resolved theme.30 // NewManager creates a new theme manager from an already-resolved theme.
@@ -58,6 +59,18 @@ func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) {
58 }59 }
59 m.gtkFont = m.fonts.GtkFont60 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 return148 return
136 }149 }
137 150
138- // Generate CSS with current palette, UI scale, fonts, and mode colors151+ // 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)

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close