detached 0c4b1f1

feat(page-mode): add keyboard page navigation

commit
0c4b1f1f814f4bfe1ab25aa6fd6d07f574aad56c
author
brice · · 2026-07-28 07:50 UTC
parent
11ab80c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
60 files · +5531 -126
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`, and each
173+engine owns its execution strategy:
174+- **CEF** sends synthetic native key events (arrow keys, Page Up/Down) through
175+the Chromium browser host for the best compatibility with SPA scroll containers.
176+- **WebKit** falls back to JavaScript scroll delta injection using the shared
177+`BuildScrollByJS` helper, which coalesces rapid held-key repeats via
178+`requestAnimationFrame` and resolves the nearest scrollable ancestor.
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 +56 -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 depends on the engine: CEF uses native Chromium key events (arrow keys, Page Up/Down) for all six scroll commands; WebKit falls back to JavaScript scroll delta injection.
452+
401 ### Resize Mode453 ### Resize Mode
402 454
403 | Key | Type | Default | Description |455 | Key | Type | Default | Description |
@@ -529,12 +581,12 @@ 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 toaster notification when modal modes are active; Page mode uses it only as brief secondary feedback |
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:**
540 ```toml592 ```toml
@@ -542,7 +594,7 @@ desc = "Open floating pane on GitHub"
542 border_width = 1594 border_width = 1
543 border_color = "@theme_selected_bg_color"595 border_color = "@theme_selected_bg_color"
544 mode_border_width = 4596 mode_border_width = 4
545-pane_mode_color = "#4A90E2" # Blue for pane mode597+pane_mode_color = "#4A90E2" # Blue for pane mode and Page mode local accent
546 tab_mode_color = "#FFA500" # Orange for tab mode598 tab_mode_color = "#FFA500" # Orange for tab mode
547 session_mode_color = "#9B59B6" # Purple for session mode599 session_mode_color = "#9B59B6" # Purple for session mode
548 resize_mode_color = "#00D4AA" # Teal for resize mode600 resize_mode_color = "#00D4AA" # Teal for resize mode
M docs/getting-started.md +16 -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,20 @@ 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 sends native arrow key and Page Up/Down events for
44+> scroll execution, matching Chromium's built-in scroll behaviour. WebKit uses
45+> JavaScript scroll delta injection as a fallback.
46+
31 ## Omnibox47 ## Omnibox
32 48
33 Press `Ctrl+L` to open the omnibox for:49 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 +43 -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,25 @@ 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 shows a local `PAGE` indicator on the owning pane, uses `workspace.styling.pane_mode_color` for the local accent, and 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+Scroll execution depends on the active browser engine:
61+- **CEF**: Page Mode scroll commands (`h/j/k/l`, `Shift+J/K`) are translated to native Chromium key events (arrow keys, Page Up/Down) for reliable behaviour with SPA scroll containers.
62+- **WebKit**: Falls back to JavaScript-driven scroll delta injection handled by `BuildScrollByJS`, which resolves the nearest scrollable ancestor and coalesces held-key repeats via `requestAnimationFrame`.
63+
64+| Action | Keys |
65+|--------|------|
66+| Scroll left | `H` |
67+| Scroll down | `J` |
68+| Scroll up | `K` |
69+| Scroll right | `L` |
70+| Scroll down fast | `Shift+J` |
71+| Scroll up fast | `Shift+K` |
72+| Confirm | `Enter` |
73+| Cancel | `Escape` |
74+
54 ## Resize Mode (`Ctrl+N`)75 ## Resize Mode (`Ctrl+N`)
55 76
56 | Action | Keys |77 | Action | Keys |
@@ -118,6 +139,28 @@ All keybindings can be customized in `~/.config/dumber/config.toml`:
118 split-right = ["arrowright", "r"]139 split-right = ["arrowright", "r"]
119 close-pane = ["x", "q"]140 close-pane = ["x", "q"]
120 141
142+[workspace.page_mode]
143+activation_shortcut = "ctrl+y"
144+timeout_ms = 0
145+
146+[workspace.page_mode.actions.page-scroll-left]
147+keys = ["h"]
148+
149+[workspace.page_mode.actions.page-scroll-down]
150+keys = ["j"]
151+
152+[workspace.page_mode.actions.page-scroll-up]
153+keys = ["k"]
154+
155+[workspace.page_mode.actions.page-scroll-right]
156+keys = ["l"]
157+
158+[workspace.page_mode.actions.page-scroll-down-fast]
159+keys = ["shift+j"]
160+
161+[workspace.page_mode.actions.page-scroll-up-fast]
162+keys = ["shift+k"]
163+
121 [workspace.shortcuts.actions.close-pane]164 [workspace.shortcuts.actions.close-pane]
122 keys = ["ctrl+w"]165 keys = ["ctrl+w"]
123 166
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 +37 -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,36 @@ 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+}
419+
420+// PageScrollable is an optional capability for WebViews that support semantic
421+// page scrolling. Page scrolling is used by keyboard-driven navigation modes
422+// such as Page mode.
423+//
424+// Engines own the execution strategy. When a native mechanism exists for the
425+// requested command, the engine may ignore the fallback delta. Otherwise it
426+// should use the fallback delta as a JavaScript-driven scroll amount.
427+type PageScrollable interface {
428+ ScrollPage(ctx context.Context, request PageScrollRequest) error
429+}
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 +126 -0
@@ -0,0 +1,126 @@
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 (*PageScrollUseCase) Scroll(ctx context.Context, wv port.WebView, cmd PageScrollCommand) error {
48+ if wv == nil {
49+ return errors.New("page scroll: nil webview")
50+ }
51+
52+ scroller, ok := wv.(port.PageScrollable)
53+ if !ok {
54+ return errors.New("page scroll: webview does not support page scrolling")
55+ }
56+
57+ dx, dy := scrollDelta(cmd)
58+ req := port.PageScrollRequest{
59+ Command: toPortPageScrollCommand(cmd),
60+ FallbackDX: dx,
61+ FallbackDY: dy,
62+ }
63+ if err := scroller.ScrollPage(ctx, req); err != nil {
64+ return fmt.Errorf("page scroll: %w", err)
65+ }
66+ return nil
67+}
68+
69+func toPortPageScrollCommand(cmd PageScrollCommand) port.PageScrollCommand {
70+ switch cmd {
71+ case PageScrollLeft:
72+ return port.PageScrollCommandLeft
73+ case PageScrollRight:
74+ return port.PageScrollCommandRight
75+ case PageScrollUp:
76+ return port.PageScrollCommandUp
77+ case PageScrollDown:
78+ return port.PageScrollCommandDown
79+ case PageScrollUpFast:
80+ return port.PageScrollCommandUpFast
81+ case PageScrollDownFast:
82+ return port.PageScrollCommandDownFast
83+ default:
84+ return port.PageScrollCommand(-1)
85+ }
86+}
87+
88+// scrollDelta returns the pixel delta for a PageScrollCommand.
89+func scrollDelta(cmd PageScrollCommand) (dx, dy int) {
90+ switch cmd {
91+ case PageScrollLeft:
92+ return -scrollSmallHorizontal, 0
93+ case PageScrollRight:
94+ return scrollSmallHorizontal, 0
95+ case PageScrollUp:
96+ return 0, -scrollSmallVertical
97+ case PageScrollDown:
98+ return 0, scrollSmallVertical
99+ case PageScrollUpFast:
100+ return 0, -scrollFastVertical
101+ case PageScrollDownFast:
102+ return 0, scrollFastVertical
103+ default:
104+ return 0, 0
105+ }
106+}
107+
108+// String returns a human-readable name for the scroll command.
109+func (cmd PageScrollCommand) String() string {
110+ switch cmd {
111+ case PageScrollLeft:
112+ return "left"
113+ case PageScrollRight:
114+ return "right"
115+ case PageScrollUp:
116+ return "up"
117+ case PageScrollDown:
118+ return "down"
119+ case PageScrollUpFast:
120+ return "up-fast"
121+ case PageScrollDownFast:
122+ return "down-fast"
123+ default:
124+ return "unknown"
125+ }
126+}
A internal/application/usecase/page_scroll_test.go +258 -0
@@ -0,0 +1,258 @@
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_DeltaMethod(t *testing.T) {
142+ tests := []struct {
143+ cmd PageScrollCommand
144+ wantDx int
145+ wantDy int
146+ wantName string
147+ }{
148+ {PageScrollLeft, -80, 0, "left"},
149+ {PageScrollRight, 80, 0, "right"},
150+ {PageScrollUp, 0, -80, "up"},
151+ {PageScrollDown, 0, 80, "down"},
152+ {PageScrollUpFast, 0, -320, "up-fast"},
153+ {PageScrollDownFast, 0, 320, "down-fast"},
154+ }
155+
156+ for _, tt := range tests {
157+ t.Run(tt.wantName, func(t *testing.T) {
158+ dx, dy := scrollDelta(tt.cmd)
159+ if dx != tt.wantDx || dy != tt.wantDy {
160+ t.Errorf("scrollDelta(%v) = (%d, %d), want (%d, %d)", tt.cmd, dx, dy, tt.wantDx, tt.wantDy)
161+ }
162+ if got := tt.cmd.String(); got != tt.wantName {
163+ t.Errorf("PageScrollCommand.String() = %q, want %q", got, tt.wantName)
164+ }
165+ })
166+ }
167+}
168+
169+func TestPageScroll_CommandIdentityPropagation(t *testing.T) {
170+ // Prove that PageScrollDownFast forwards command identity separately
171+ // from fallback delta (dy=320).
172+ wv := newScrollableStub(nil)
173+ uc := NewPageScrollUseCase()
174+
175+ err := uc.Scroll(context.Background(), wv, PageScrollDownFast)
176+ if err != nil {
177+ t.Fatalf("Scroll() unexpected error: %v", err)
178+ }
179+
180+ if wv.lastRequest.Command != port.PageScrollCommandDownFast {
181+ t.Fatalf("ScrollPage request.Command = %d, want %d (PageScrollDownFast)",
182+ wv.lastRequest.Command, port.PageScrollCommandDownFast)
183+ }
184+ if int(wv.lastRequest.Command) == wv.lastRequest.FallbackDY {
185+ t.Fatal("command identity must NOT be confused with fallback delta: Command and FallbackDY should differ")
186+ }
187+ if wv.lastRequest.FallbackDX != 0 || wv.lastRequest.FallbackDY != 320 {
188+ t.Fatalf("ScrollPage request fallback delta = (%d, %d), want (0, 320)",
189+ wv.lastRequest.FallbackDX, wv.lastRequest.FallbackDY)
190+ }
191+}
192+
193+func TestPageScroll_UnknownCommand_ReturnsNoOp(t *testing.T) {
194+ dx, dy := scrollDelta(PageScrollCommand(999))
195+ if dx != 0 || dy != 0 {
196+ t.Errorf("scrollDelta(unknown) = (%d, %d), want (0, 0)", dx, dy)
197+ }
198+
199+ got := PageScrollCommand(999).String()
200+ if got != "unknown" {
201+ t.Errorf("PageScrollCommand(999).String() = %q, want %q", got, "unknown")
202+ }
203+}
204+
205+// --- test helpers ---
206+
207+// scrollableStub satisfies both port.WebView (via embedded nonScrollableStub)
208+// and port.PageScrollable (via ScrollPage).
209+type scrollableStub struct {
210+ *nonScrollableStub
211+ lastRequest port.PageScrollRequest
212+ scrollErr error
213+}
214+
215+func newScrollableStub(scrollErr error) *scrollableStub {
216+ return &scrollableStub{
217+ nonScrollableStub: &nonScrollableStub{},
218+ scrollErr: scrollErr,
219+ }
220+}
221+
222+// ScrollPage implements port.PageScrollable.
223+func (s *scrollableStub) ScrollPage(_ context.Context, req port.PageScrollRequest) error {
224+ s.lastRequest = req
225+ return s.scrollErr
226+}
227+
228+// nonScrollableStub satisfies port.WebView but NOT port.PageScrollable.
229+type nonScrollableStub struct{}
230+
231+func (*nonScrollableStub) ID() port.WebViewID { return 0 }
232+func (*nonScrollableStub) LoadURI(_ context.Context, _ string) error { return nil }
233+func (*nonScrollableStub) LoadHTML(_ context.Context, _, _ string) error { return nil }
234+func (*nonScrollableStub) Reload(_ context.Context) error { return nil }
235+func (*nonScrollableStub) ReloadBypassCache(_ context.Context) error { return nil }
236+func (*nonScrollableStub) Stop(_ context.Context) error { return nil }
237+func (*nonScrollableStub) GoBack(_ context.Context) error { return nil }
238+func (*nonScrollableStub) GoForward(_ context.Context) error { return nil }
239+func (*nonScrollableStub) State() port.WebViewState { return port.WebViewState{} }
240+func (*nonScrollableStub) URI() string { return "" }
241+func (*nonScrollableStub) Title() string { return "" }
242+func (*nonScrollableStub) IsLoading() bool { return false }
243+func (*nonScrollableStub) EstimatedProgress() float64 { return 0 }
244+func (*nonScrollableStub) CanGoBack() bool { return false }
245+func (*nonScrollableStub) CanGoForward() bool { return false }
246+func (*nonScrollableStub) SetZoomLevel(_ context.Context, _ float64) error { return nil }
247+func (*nonScrollableStub) GetZoomLevel() float64 { return 1.0 }
248+func (*nonScrollableStub) GetFindController() port.FindController { return nil }
249+func (*nonScrollableStub) SetCallbacks(_ *port.WebViewCallbacks) {}
250+func (*nonScrollableStub) RunJavaScript(_ context.Context, _ string) {}
251+func (*nonScrollableStub) SetBackgroundColor(_, _, _, _ float64) {}
252+func (*nonScrollableStub) ResetBackgroundToDefault() {}
253+func (*nonScrollableStub) Favicon() port.Texture { return nil }
254+func (*nonScrollableStub) Generation() uint64 { return 0 }
255+func (*nonScrollableStub) IsFullscreen() bool { return false }
256+func (*nonScrollableStub) IsPlayingAudio() bool { return false }
257+func (*nonScrollableStub) IsDestroyed() bool { return false }
258+func (*nonScrollableStub) Destroy() {}
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/webview.go +67 -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,7 @@ 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)
37 )39 )
38 40
39 // errDestroyed is returned when an operation is attempted on a destroyed WebView.41 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -1423,23 +1425,28 @@ func (wv *WebView) setEditableFocus(editable bool) {
1423 wv.mu.Lock()1425 wv.mu.Lock()
1424 previous := wv.focusedEditable1426 previous := wv.focusedEditable
1425 wv.focusedEditable = editable1427 wv.focusedEditable = editable
1428+ cb := wv.callbacks
1429+ var timer stoppableTimer
1426 if editable {1430 if editable {
1427 wv.selectionDebounceSeq++1431 wv.selectionDebounceSeq++
1428- timer := wv.selectionDebounceTimer1432+ timer = wv.selectionDebounceTimer
1429 wv.selectionDebounceTimer = nil1433 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 }1434 }
1439 wv.mu.Unlock()1435 wv.mu.Unlock()
1440- if previous != editable && wv.ctx != nil {1436+ if timer != nil {
1437+ timer.Stop()
1438+ }
1439+ if previous == editable {
1440+ return
1441+ }
1442+ if wv.ctx != nil {
1441 logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")1443 logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
1442 }1444 }
1445+ if cb != nil && cb.OnEditableFocusChanged != nil {
1446+ wv.runOnGTK(func() {
1447+ cb.OnEditableFocusChanged(editable)
1448+ })
1449+ }
1443 }1450 }
1444 1451
1445 func (wv *WebView) cancelSelectionDebounce() {1452 func (wv *WebView) cancelSelectionDebounce() {
@@ -2368,6 +2375,56 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
2368 return pc2375 return pc
2369 }2376 }
2370 2377
2378+// pageScrollWheelEvent builds the CEF mouse wheel event used for keyboard-driven
2379+// Page Mode scrolling. It targets the center of the current OSR view so the
2380+// scroll goes through Chromium's compositor/input pipeline instead of a series
2381+// of synthetic key taps.
2382+func (wv *WebView) pageScrollWheelEvent() purecef.MouseEvent {
2383+ width, height := int32(1), int32(1)
2384+ if wv != nil && wv.viewBridge != nil {
2385+ width, height = wv.viewBridge.Size()
2386+ }
2387+ scale := normalizeScale(wv.osrBackingScaleFactor())
2388+ return purecef.MouseEvent{
2389+ X: int32(math.Floor(float64(width) * scale / 2)),
2390+ Y: int32(math.Floor(float64(height) * scale / 2)),
2391+ Modifiers: uint32(purecef.EventFlagsEventflagPrecisionScrollingDelta),
2392+ }
2393+}
2394+
2395+func pageScrollWheelDeltas(request port.PageScrollRequest) (deltaX, deltaY int32) {
2396+ return int32(-request.FallbackDX), int32(-request.FallbackDY)
2397+}
2398+
2399+// ScrollPage resolves a DOM scroll target when the browser is ready so an
2400+// exhausted nested scroller can hand off automatically to its ancestor or the
2401+// document. Native precision-wheel input remains the pre-frame fallback.
2402+func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
2403+ if wv.destroyed.Load() {
2404+ return errDestroyed
2405+ }
2406+
2407+ wv.mu.RLock()
2408+ host := wv.host
2409+ browser := wv.browser
2410+ wv.mu.RUnlock()
2411+ if browser != nil {
2412+ wv.RunJavaScript(ctx, webutil.BuildScrollAtViewportCenterByJS(request.FallbackDX, request.FallbackDY))
2413+ return nil
2414+ }
2415+ if host != nil {
2416+ deltaX, deltaY := pageScrollWheelDeltas(request)
2417+ if deltaX != 0 || deltaY != 0 {
2418+ event := wv.pageScrollWheelEvent()
2419+ host.SendMouseWheelEvent(&event, deltaX, deltaY)
2420+ }
2421+ return nil
2422+ }
2423+
2424+ wv.RunJavaScript(ctx, webutil.BuildScrollByJS(request.FallbackDX, request.FallbackDY))
2425+ return nil
2426+}
2427+
2371 // beginAudioStreamStart invalidates any prior stream before creating a new2428 // beginAudioStreamStart invalidates any prior stream before creating a new
2372 // one. A stream created for the returned generation may only be published if2429 // one. A stream created for the returned generation may only be published if
2373 // that generation is still current.2430 // 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 +259 -0
@@ -0,0 +1,259 @@
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_Destroyed_ReturnsError(t *testing.T) {
252+ wv := &WebView{}
253+ wv.destroyed.Store(true)
254+
255+ err := wv.ScrollPage(context.Background(), port.PageScrollRequest{})
256+ if !errors.Is(err, errDestroyed) {
257+ t.Fatalf("ScrollPage() on destroyed webview: got %v, want %v", err, errDestroyed)
258+ }
259+}
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 +153 -0
@@ -0,0 +1,153 @@
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 TestSchemaProvider_PageModeKeys(t *testing.T) {
126+ provider := &SchemaProvider{}
127+ schema := provider.GetSchema()
128+
129+ var foundActivation, foundTimeout, foundActions bool
130+ for _, key := range schema {
131+ switch key.Key {
132+ case "workspace.page_mode.activation_shortcut":
133+ foundActivation = true
134+ assert.Equal(t, "string", key.Type)
135+ assert.Equal(t, "ctrl+y", key.Default)
136+ assert.Equal(t, SectionWorkspace, key.Section)
137+ case "workspace.page_mode.timeout_ms":
138+ foundTimeout = true
139+ assert.Equal(t, "int", key.Type)
140+ assert.Equal(t, "0", key.Default)
141+ assert.Equal(t, ">=0", key.Range)
142+ assert.Equal(t, SectionWorkspace, key.Section)
143+ case "workspace.page_mode.actions.<action>":
144+ foundActions = true
145+ assert.Equal(t, "[]string", key.Type)
146+ assert.Equal(t, SectionWorkspace, key.Section)
147+ }
148+ }
149+
150+ assert.True(t, foundActivation, "missing workspace.page_mode.activation_shortcut schema key")
151+ assert.True(t, foundTimeout, "missing workspace.page_mode.timeout_ms schema key")
152+ assert.True(t, foundActions, "missing workspace.page_mode.actions.<action> schema key")
153+}
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 +30 -0
@@ -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)...)
@@ -268,6 +269,35 @@ func validateTabMode(config *Config) []string {
268 return validationErrors269 return validationErrors
269 }270 }
270 271
272+func validatePageMode(config *Config) []string {
273+ var validationErrors []string
274+ if config.Workspace.PageMode.TimeoutMilliseconds < 0 {
275+ validationErrors = append(validationErrors, "workspace.page_mode.timeout_ms must be non-negative")
276+ }
277+ if len(config.Workspace.PageMode.Actions) == 0 {
278+ validationErrors = append(validationErrors, "workspace.page_mode.actions cannot be empty")
279+ }
280+
281+ seenKeys := make(map[string]string)
282+ for action, binding := range config.Workspace.PageMode.Actions {
283+ if len(binding.Keys) == 0 {
284+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.page_mode.actions.%s must have at least one key binding", action))
285+ }
286+ for _, key := range binding.Keys {
287+ if existingAction, exists := seenKeys[key]; exists {
288+ validationErrors = append(validationErrors, fmt.Sprintf(
289+ "duplicate key binding '%s' found in page_mode actions '%s' and '%s'",
290+ key,
291+ existingAction,
292+ action,
293+ ))
294+ }
295+ seenKeys[key] = action
296+ }
297+ }
298+ return validationErrors
299+}
300+
271 func validateFloatingPane(config *Config) []string {301 func validateFloatingPane(config *Config) []string {
272 var validationErrors []string302 var validationErrors []string
273 seenProfileKeys := make(map[string]string)303 seenProfileKeys := make(map[string]string)
M internal/infrastructure/webkit/injector.go +58 -17
@@ -182,28 +182,33 @@ 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 (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return;
200+ window.webkit.messageHandlers.dumber.postMessage({
201+ type: 'editable_focus_changed',
202+ payload: { editable: editable, token: editableFocusToken }
203+ });
204+ }
205+
203 function isEditableTarget(target) {206 function isEditableTarget(target) {
204 const el = target instanceof Element ? target : null;207 const el = target instanceof Element ? target : null;
205 if (!el) return false;208 if (!el) return false;
206- if (el.closest('[contenteditable=""], [contenteditable="true"]')) return true;209+ if (el.isContentEditable) return true;
210+ const editableAncestor = el.closest('[contenteditable]');
211+ if (editableAncestor && editableAncestor.isContentEditable) return true;
207 if (el.closest('textarea')) return true;212 if (el.closest('textarea')) return true;
208 const input = el.closest('input');213 const input = el.closest('input');
209 if (!input) return false;214 if (!input) return false;
@@ -234,12 +239,44 @@ func init() {
234 }239 }
235 }, true);240 }, true);
236 241
237- document.addEventListener('focusin', function(e) {242+ document.addEventListener('focusin', function(e) {
238- if (isEditableTarget(e.target)) {243+ if (e && e.isTrusted === false) return;
239- window.__dumber_lastEditableEl = e.target;244+ if (isEditableTarget(e.target)) {
240- }245+ window.__dumber_lastEditableEl = e.target;
241- }, true);246+ postEditableFocus(true);
242-})();`, strings.Join(keys, ","))247+ return;
248+ }
249+ postEditableFocus(false);
250+ }, true);
251+
252+ document.addEventListener('focusout', function(e) {
253+ if (e && e.isTrusted === false) return;
254+ if (!isEditableTarget(e.target)) return;
255+ setTimeout(function() {
256+ if (!isEditableTarget(document.activeElement)) {
257+ postEditableFocus(false);
258+ }
259+ }, 0);
260+ }, true);
261+
262+ if (isEditableTarget(document.activeElement)) {
263+ window.__dumber_lastEditableEl = document.activeElement;
264+ postEditableFocus(true);
265+ } else {
266+ postEditableFocus(false);
267+ }
268+})();`, accentDetectionScriptKeys, token)
269+}
270+
271+func init() {
272+ // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"
273+ keys := make([]string, 0, len(entity.AccentMap))
274+ for k := range entity.AccentMap {
275+ keys = append(keys, fmt.Sprintf("'%c'", k))
276+ }
277+ sort.Strings(keys)
278+ accentDetectionScriptKeys = strings.Join(keys, ",")
279+ accentDetectionScript = buildAccentDetectionScript("")
243 }280 }
244 281
245 // accentDetectionInjectionMode controls which frames receive the accent detection script.282 // accentDetectionInjectionMode controls which frames receive the accent detection script.
@@ -449,9 +486,13 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
449 486
450 // 8. Inject accent key detection for all pages and all frames (unconditional).487 // 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.488 // JS only reports keydown/keyup events; Go handles timing and picker display.
489+ accentScript := accentDetectionScript
490+ if wv := LookupWebView(webviewID); wv != nil && wv.editableFocusBridgeToken != "" {
491+ accentScript = buildAccentDetectionScript(wv.editableFocusBridgeToken)
492+ }
452 addScript(493 addScript(
453 webkit.NewUserScript(494 webkit.NewUserScript(
454- accentDetectionScript,495+ accentScript,
455 accentDetectionInjectionMode,496 accentDetectionInjectionMode,
456 webkit.UserScriptInjectAtDocumentEndValue,497 webkit.UserScriptInjectAtDocumentEndValue,
457 nil, // all pages498 nil, // all pages
M internal/infrastructure/webkit/injector_accent_test.go +14 -0
@@ -29,6 +29,20 @@ 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`)
32 }46 }
33 47
34 func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {48 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 == "" || payload.Token != senderWV.editableFocusBridgeToken {
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 +51 -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,55 @@ 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+ called := false
64+ wv.SetCallbacks(&port.WebViewCallbacks{
65+ OnEditableFocusChanged: func(bool) {
66+ called = true
67+ },
68+ })
69+ wv.ResetForPoolReuse()
70+
71+ handled := router.handleAllowlistedBridgeMessage(wv, Message{
72+ Type: "editable_focus_changed",
73+ Payload: json.RawMessage(`{"editable":true,"token":"secret-token"}`),
74+ })
75+
76+ require.True(t, handled)
77+ require.False(t, called)
78+}
79+
80+func TestResetForPoolReuse_RotatesEditableFocusBridgeToken(t *testing.T) {
81+ wv := &WebView{editableFocusBridgeToken: "secret-token"}
82+
83+ wv.ResetForPoolReuse()
84+
85+ require.NotEmpty(t, wv.editableFocusBridgeToken)
86+ require.NotEqual(t, "secret-token", wv.editableFocusBridgeToken)
87+}
88+
38 func TestIsTrustedBridgeURI(t *testing.T) {89 func TestIsTrustedBridgeURI(t *testing.T) {
39 t.Parallel()90 t.Parallel()
40 91
M internal/infrastructure/webkit/pool.go +5 -0
@@ -179,6 +179,11 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
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 _ = wv.AttachFrontend(ctx, p.injector, p.router)181 _ = wv.AttachFrontend(ctx, p.injector, p.router)
182+ if p.injector != nil && wv.ucm != nil {
183+ wv.ucm.RemoveAllScripts()
184+ wv.ucm.RemoveAllStyleSheets()
185+ p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
186+ }
182 // Apply filters if available (may not have been applied during prewarm)187 // Apply filters if available (may not have been applied during prewarm)
183 if p.filterApplier != nil {188 if p.filterApplier != nil {
184 p.filterApplier.ApplyTo(ctx, wv.ucm)189 p.filterApplier.ApplyTo(ctx, wv.ucm)
M internal/infrastructure/webkit/webview.go +63 -13
@@ -2,6 +2,8 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/rand"
6+ "encoding/hex"
5 "fmt"7 "fmt"
6 "net/url"8 "net/url"
7 "os"9 "os"
@@ -16,6 +18,7 @@ import (
16 "github.com/bnema/dumber/internal/domain/entity"18 "github.com/bnema/dumber/internal/domain/entity"
17 urlutil "github.com/bnema/dumber/internal/domain/url"19 urlutil "github.com/bnema/dumber/internal/domain/url"
18 "github.com/bnema/dumber/internal/infrastructure/desktop"20 "github.com/bnema/dumber/internal/infrastructure/desktop"
21+ "github.com/bnema/dumber/internal/infrastructure/webutil"
19 "github.com/bnema/dumber/internal/logging"22 "github.com/bnema/dumber/internal/logging"
20 "github.com/bnema/puregotk/v4/gdk"23 "github.com/bnema/puregotk/v4/gdk"
21 "github.com/bnema/puregotk/v4/gio"24 "github.com/bnema/puregotk/v4/gio"
@@ -34,6 +37,12 @@ var _ port.Printer = (*WebView)(nil)
34 var _ port.PopupLifecycleCapable = (*WebView)(nil)37 var _ port.PopupLifecycleCapable = (*WebView)(nil)
35 var _ port.PopupFeatureResolver = (*WebView)(nil)38 var _ port.PopupFeatureResolver = (*WebView)(nil)
36 var _ port.OAuthCallbackCapable = (*WebView)(nil)39 var _ port.OAuthCallbackCapable = (*WebView)(nil)
40+var _ port.PageScrollable = (*WebView)(nil)
41+
42+var buildPageScrollFallbackJS = webutil.BuildScrollByJS
43+var runPageScrollFallbackJS = func(wv *WebView, ctx context.Context, script string) {
44+ wv.RunJavaScript(ctx, script)
45+}
37 46
38 // WebViewID is an alias to port.WebViewID for clean architecture compliance.47 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
39 // Infrastructure layer uses the type defined in the application port.48 // Infrastructure layer uses the type defined in the application port.
@@ -163,6 +172,7 @@ type WebView struct {
163 OnEnterFullscreen func() bool // Return true to prevent fullscreen172 OnEnterFullscreen func() bool // Return true to prevent fullscreen
164 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen173 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
165 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops174 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
175+ 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)176 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)177 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
168 browsingContextDecision dto.HostDecision178 browsingContextDecision dto.HostDecision
@@ -179,8 +189,9 @@ type WebView struct {
179 logger zerolog.Logger189 logger zerolog.Logger
180 mu sync.RWMutex190 mu sync.RWMutex
181 191
182- frontendAttached atomic.Bool192+ frontendAttached atomic.Bool
183- navigationActive atomic.Bool193+ navigationActive atomic.Bool
194+ editableFocusBridgeToken string
184 195
185 // asyncCallbacks keeps references to async JS callbacks to prevent GC196 // asyncCallbacks keeps references to async JS callbacks to prevent GC
186 asyncCallbacks []any197 asyncCallbacks []any
@@ -212,8 +223,18 @@ const (
212 runJSNonFatalLogInterval = 30 * time.Second223 runJSNonFatalLogInterval = 30 * time.Second
213 runJSAggregateLogEvery = 20224 runJSAggregateLogEvery = 20
214 runJSUnknown = "unknown"225 runJSUnknown = "unknown"
226+
227+ editableFocusBridgeTokenBytes = 16
215 )228 )
216 229
230+func newEditableFocusBridgeToken() string {
231+ buf := make([]byte, editableFocusBridgeTokenBytes)
232+ if _, err := rand.Read(buf); err != nil {
233+ return fmt.Sprintf("editable-focus-%d", time.Now().UnixNano())
234+ }
235+ return hex.EncodeToString(buf)
236+}
237+
217 type findControllerAdapter struct {238 type findControllerAdapter struct {
218 fc *webkit.FindController239 fc *webkit.FindController
219 }240 }
@@ -346,11 +367,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
346 }367 }
347 368
348 wv := &WebView{369 wv := &WebView{
349- inner: inner,370+ inner: inner,
350- ucm: inner.GetUserContentManager(),371+ ucm: inner.GetUserContentManager(),
351- logger: log.With().Str("component", "webview").Logger(),372+ logger: log.With().Str("component", "webview").Logger(),
352- signalIDs: make([]uintptr, 0, 4),373+ signalIDs: make([]uintptr, 0, 4),
353- runJSErrorStats: make(map[string]runJSErrorStat),374+ runJSErrorStats: make(map[string]runJSErrorStat),
375+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
354 }376 }
355 377
356 // Register in global registry378 // Register in global registry
@@ -396,12 +418,13 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti
396 Msg("related webview created, checking pointers")418 Msg("related webview created, checking pointers")
397 419
398 wv := &WebView{420 wv := &WebView{
399- inner: inner,421+ inner: inner,
400- isRelated: true, // Shares web process with parent - must not terminate process on destroy422+ isRelated: true, // Shares web process with parent - must not terminate process on destroy
401- ucm: inner.GetUserContentManager(),423+ ucm: inner.GetUserContentManager(),
402- logger: log.With().Str("component", "webview-popup").Logger(),424+ logger: log.With().Str("component", "webview-popup").Logger(),
403- signalIDs: make([]uintptr, 0, 6),425+ signalIDs: make([]uintptr, 0, 6),
404- runJSErrorStats: make(map[string]runJSErrorStat),426+ runJSErrorStats: make(map[string]runJSErrorStat),
427+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
405 }428 }
406 429
407 wv.id = globalRegistry.register(wv)430 wv.id = globalRegistry.register(wv)
@@ -461,6 +484,7 @@ func (wv *WebView) connectLoadChangedSignal() {
461 wv.navigationActive.Store(true)484 wv.navigationActive.Store(true)
462 wv.isLoading = true485 wv.isLoading = true
463 wv.logger.Debug().Str("uri", uri).Msg("load started")486 wv.logger.Debug().Str("uri", uri).Msg("load started")
487+ wv.dispatchEditableFocusChanged(false)
464 case webkit.LoadRedirectedValue:488 case webkit.LoadRedirectedValue:
465 wv.logger.Debug().Str("uri", uri).Msg("load redirected")489 wv.logger.Debug().Str("uri", uri).Msg("load redirected")
466 case webkit.LoadCommittedValue:490 case webkit.LoadCommittedValue:
@@ -1762,6 +1786,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1762 wv.OnEnterFullscreen = nil1786 wv.OnEnterFullscreen = nil
1763 wv.OnLeaveFullscreen = nil1787 wv.OnLeaveFullscreen = nil
1764 wv.OnAudioStateChanged = nil1788 wv.OnAudioStateChanged = nil
1789+ wv.OnEditableFocusChanged = nil
1765 return1790 return
1766 }1791 }
1767 1792
@@ -1807,6 +1832,16 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1807 wv.OnEnterFullscreen = callbacks.OnEnterFullscreen1832 wv.OnEnterFullscreen = callbacks.OnEnterFullscreen
1808 wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen1833 wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen
1809 wv.OnAudioStateChanged = callbacks.OnAudioStateChanged1834 wv.OnAudioStateChanged = callbacks.OnAudioStateChanged
1835+ wv.OnEditableFocusChanged = callbacks.OnEditableFocusChanged
1836+}
1837+
1838+func (wv *WebView) dispatchEditableFocusChanged(editable bool) {
1839+ if wv == nil || wv.destroyed.Load() {
1840+ return
1841+ }
1842+ if wv.OnEditableFocusChanged != nil {
1843+ wv.OnEditableFocusChanged(editable)
1844+ }
1810 }1845 }
1811 1846
1812 // ShowDevTools opens the WebKit inspector/developer tools.1847 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1999,6 +2034,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
1999 wv.OnEnterFullscreen = nil2034 wv.OnEnterFullscreen = nil
2000 wv.OnLeaveFullscreen = nil2035 wv.OnLeaveFullscreen = nil
2001 wv.OnAudioStateChanged = nil2036 wv.OnAudioStateChanged = nil
2037+ wv.OnEditableFocusChanged = nil
2002 wv.OnLinkHover = nil2038 wv.OnLinkHover = nil
2003 wv.OnWebProcessTerminated = nil2039 wv.OnWebProcessTerminated = nil
2004 wv.OnPermissionRequest = nil2040 wv.OnPermissionRequest = nil
@@ -2047,6 +2083,7 @@ func (wv *WebView) ResetForPoolReuse() {
2047 return2083 return
2048 }2084 }
2049 wv.generation.Add(1)2085 wv.generation.Add(1)
2086+ wv.editableFocusBridgeToken = newEditableFocusBridgeToken()
2050 2087
2051 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.2088 // 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.2089 // They will be reconnected when the WebView is re-acquired from the pool.
@@ -2064,6 +2101,7 @@ func (wv *WebView) ResetForPoolReuse() {
2064 wv.OnEnterFullscreen = nil2101 wv.OnEnterFullscreen = nil
2065 wv.OnLeaveFullscreen = nil2102 wv.OnLeaveFullscreen = nil
2066 wv.OnAudioStateChanged = nil2103 wv.OnAudioStateChanged = nil
2104+ wv.OnEditableFocusChanged = nil
2067 wv.OnLinkHover = nil2105 wv.OnLinkHover = nil
2068 wv.OnWebProcessTerminated = nil2106 wv.OnWebProcessTerminated = nil
2069 wv.OnPermissionRequest = nil2107 wv.OnPermissionRequest = nil
@@ -2376,3 +2414,15 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
2376 log.Debug().Msg("frontend assets attached to webview")2414 log.Debug().Msg("frontend assets attached to webview")
2377 return nil2415 return nil
2378 }2416 }
2417+
2418+// ScrollPage scrolls the page using a semantic page-scroll request.
2419+// Currently uses the JS fallback with request fallback deltas.
2420+// Implements port.PageScrollable.
2421+func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
2422+ if wv.destroyed.Load() {
2423+ return fmt.Errorf("webview %d is destroyed", wv.id)
2424+ }
2425+ js := buildPageScrollFallbackJS(request.FallbackDX, request.FallbackDY)
2426+ runPageScrollFallbackJS(wv, ctx, js)
2427+ return nil
2428+}
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(_ *WebView, _ context.Context, 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(_ *WebView, _ context.Context, 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 +90 -0
@@ -0,0 +1,90 @@
1+package webutil
2+
3+import "fmt"
4+
5+// BuildScrollByJS returns a JavaScript string that scrolls a web page by the
6+// given CSS-pixel delta.
7+//
8+// THIS IS A FALLBACK IMPLEMENTATION. The primary page-scroll abstraction is
9+// port.PageScrollable.ScrollPage. Engines should attempt native scrolling
10+// first and call this function only when no native mechanism exists for the
11+// requested command.
12+//
13+// Semantics (frontend scroll-target resolution):
14+// 1. Start from document.activeElement.
15+// 2. Walk up the DOM tree to find the nearest scrollable ancestor that can
16+// still scroll in the requested direction.
17+// 3. Fall back to document.scrollingElement (or documentElement).
18+// 4. Fall back to window scrolling.
19+//
20+// The app-level Page Mode repeater now owns held-key cadence. This helper
21+// performs one immediate fallback scroll step for each semantic Page Mode tick
22+// instead of maintaining its own RAF-based repeat loop.
23+func BuildScrollByJS(dx, dy int) string {
24+ return buildScrollByJS(dx, dy, "doc.activeElement")
25+}
26+
27+// BuildScrollAtViewportCenterByJS returns one immediate scroll step whose DOM
28+// target starts under the viewport center. It is used by keyboard-driven CEF
29+// scrolling to preserve the native wheel path's hit-test location while
30+// allowing exhausted nested scrollers to hand off to the document.
31+func BuildScrollAtViewportCenterByJS(dx, dy int) string {
32+ startNode := "(typeof doc.elementFromPoint==='function'?" +
33+ "doc.elementFromPoint(window.innerWidth/2,window.innerHeight/2):null)||doc.activeElement"
34+ return buildScrollByJS(dx, dy, startNode)
35+}
36+
37+func buildScrollByJS(dx, dy int, startNode string) string {
38+ return fmt.Sprintf(`(function(){
39+var dx=%d,dy=%d,doc=document;
40+function hasScrollableOverflow(value){
41+ return value==='auto'||value==='scroll'||value==='overlay';
42+}
43+function canScroll(el){
44+ if(!el)return false;
45+ var style=window.getComputedStyle(el);
46+ if(dy!==0){
47+ var overflowY=style.overflowY||style.overflow;
48+ var maxTop=el.scrollHeight-el.clientHeight;
49+ if(hasScrollableOverflow(overflowY)){
50+ if((dy<0&&el.scrollTop>0)||(dy>0&&el.scrollTop<maxTop))return true;
51+ }
52+ }
53+ if(dx!==0){
54+ var overflowX=style.overflowX||style.overflow;
55+ var maxLeft=el.scrollWidth-el.clientWidth;
56+ if(hasScrollableOverflow(overflowX)){
57+ if((dx<0&&el.scrollLeft>0)||(dx>0&&el.scrollLeft<maxLeft))return true;
58+ }
59+ }
60+ return false;
61+}
62+function scrollElement(el){
63+ if(!el)return false;
64+ var beforeLeft=el.scrollLeft,beforeTop=el.scrollTop;
65+ try{
66+ if(dx!==0)el.scrollLeft=beforeLeft+dx;
67+ if(dy!==0)el.scrollTop=beforeTop+dy;
68+ }catch(_){
69+ return false;
70+ }
71+ return el.scrollLeft!==beforeLeft||el.scrollTop!==beforeTop;
72+}
73+try{
74+ var node=%s;
75+ while(node&&node!==doc.body&&node!==doc.documentElement){
76+ if(canScroll(node)&&scrollElement(node))return;
77+ node=node.parentElement;
78+ }
79+ var scroller=doc.scrollingElement||doc.documentElement;
80+ if(scroller&&canScroll(scroller)&&scrollElement(scroller))return;
81+ if(typeof window.scrollBy==='function'){
82+ window.scrollBy(dx,dy);
83+ return;
84+ }
85+ if(typeof window.scrollTo==='function'){
86+ window.scrollTo(window.scrollX+dx,window.scrollY+dy);
87+ }
88+}catch(e){}
89+})()`, dx, dy, startNode)
90+}
A internal/infrastructure/webutil/pagescroll_test.go +136 -0
@@ -0,0 +1,136 @@
1+package webutil
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestBuildScrollByJS_ContainsRequiredOperations(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ dx int
12+ dy int
13+ }{
14+ {"down", 0, 80},
15+ {"up", 0, -80},
16+ {"left", -80, 0},
17+ {"right", 80, 0},
18+ {"upFast", 0, -320},
19+ {"downFast", 0, 320},
20+ }
21+ required := []struct {
22+ message string
23+ snippets []string
24+ }{
25+ {"JS must reference document.activeElement", []string{"activeElement"}},
26+ {"JS must walk parentElement in a loop", []string{"parentElement"}},
27+ {"JS must reference document.scrollingElement", []string{"scrollingElement"}},
28+ {"JS must fall back to window.scrollBy", []string{"window.scrollBy"}},
29+ {"JS must use getComputedStyle", []string{"getComputedStyle"}},
30+ {"JS must check overflowY", []string{"overflowY"}},
31+ {"JS must check overflowX", []string{"overflowX"}},
32+ {"JS must use direct element scroll offsets", []string{"scrollLeft=beforeLeft+dx", "scrollTop=beforeTop+dy"}},
33+ {"JS must check vertical direction-specific remaining scroll", []string{"dy<0&&el.scrollTop>0", "dy>0&&el.scrollTop<maxTop"}},
34+ {"JS must check horizontal direction-specific remaining scroll", []string{"dx<0&&el.scrollLeft>0", "dx>0&&el.scrollLeft<maxLeft"}},
35+ {"JS must check scrollable overflow values", []string{"'scroll'", "'auto'", "'overlay'"}},
36+ {"JS must wrap in try/catch", []string{"try{", "catch(e)"}},
37+ {"JS must support immediate window scrolling", []string{"window.scrollBy(dx,dy)"}},
38+ }
39+
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ js := BuildScrollByJS(tt.dx, tt.dy)
43+ for _, check := range required {
44+ for _, snippet := range check.snippets {
45+ if !strings.Contains(js, snippet) {
46+ t.Error(check.message)
47+ break
48+ }
49+ }
50+ }
51+ if !strings.HasPrefix(js, "(function(){") || !strings.HasSuffix(js, "})()") {
52+ t.Error("JS must be wrapped in an IIFE")
53+ }
54+ })
55+ }
56+}
57+
58+func TestBuildScrollByJS_SpecificDelta(t *testing.T) {
59+ js := BuildScrollByJS(0, 80)
60+ if !strings.Contains(js, "var dx=0,dy=80") {
61+ t.Errorf("expected dy=80 in JS variables, got: %s", js)
62+ }
63+ if strings.Contains(js, "requestAnimationFrame") {
64+ t.Errorf("expected immediate fallback scroll step without requestAnimationFrame, got: %s", js)
65+ }
66+
67+ js = BuildScrollByJS(-80, 0)
68+ if !strings.Contains(js, "var dx=-80,dy=0") {
69+ t.Errorf("expected dx=-80 in JS variables, got: %s", js)
70+ }
71+}
72+
73+func TestBuildScrollByJS_HorizontalAxisCheck(t *testing.T) {
74+ js := BuildScrollByJS(-80, 0)
75+ if !strings.Contains(js, "clientWidth") || !strings.Contains(js, "scrollWidth") {
76+ t.Error("horizontal scroll must check scrollWidth/clientWidth")
77+ }
78+}
79+
80+func TestBuildScrollByJS_VerticalAxisCheck(t *testing.T) {
81+ js := BuildScrollByJS(0, 80)
82+ if !strings.Contains(js, "clientHeight") || !strings.Contains(js, "scrollHeight") {
83+ t.Error("vertical scroll must check scrollHeight/clientHeight")
84+ }
85+}
86+
87+func TestBuildScrollByJS_NegativeDeltaMagnitude(t *testing.T) {
88+ js := BuildScrollByJS(0, -320)
89+ if !strings.Contains(js, "var dx=0,dy=-320") {
90+ t.Errorf("expected dy=-320 in JS variables, got: %s", js)
91+ }
92+}
93+
94+func TestBuildScrollByJS_SyntacticallyValid(t *testing.T) {
95+ tests := []struct{ dx, dy int }{{0, 80}, {0, -320}, {-80, 0}, {80, 0}, {0, 0}}
96+
97+ for _, tt := range tests {
98+ js := BuildScrollByJS(tt.dx, tt.dy)
99+
100+ depth := 0
101+ for _, ch := range js {
102+ switch ch {
103+ case '(':
104+ depth++
105+ case ')':
106+ depth--
107+ }
108+ }
109+ if depth != 0 {
110+ t.Errorf("dx=%d,dy=%d: unbalanced parentheses (depth=%d)", tt.dx, tt.dy, depth)
111+ }
112+
113+ depth = 0
114+ for _, ch := range js {
115+ switch ch {
116+ case '{':
117+ depth++
118+ case '}':
119+ depth--
120+ }
121+ }
122+ if depth != 0 {
123+ t.Errorf("dx=%d,dy=%d: unbalanced braces (depth=%d)", tt.dx, tt.dy, depth)
124+ }
125+ }
126+}
127+
128+func TestBuildScrollByJS_ZeroDeltaSafe(t *testing.T) {
129+ js := BuildScrollByJS(0, 0)
130+ if !strings.Contains(js, "var dx=0,dy=0") {
131+ t.Errorf("expected zero deltas in JS variables, got: %s", js)
132+ }
133+ if !strings.Contains(js, "clientHeight") || !strings.Contains(js, "scrollHeight") {
134+ t.Error("zero-delta script must still include vertical scroll checks")
135+ }
136+}
M internal/ui/app.go +358 -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 }
@@ -1071,7 +1094,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1071 })1094 })
1072 bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {1095 bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
1073 a.activateBrowserWindow(bw)1096 a.activateBrowserWindow(bw)
1074- a.handleModeChange(ctx, from, to)1097+ a.handleModeChange(ctx, bw, from, to)
1075 })1098 })
1076 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {1099 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
1077 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {1100 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
@@ -1099,6 +1122,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1099 1122
1100 return input.RouteHandleShortcuts1123 return input.RouteHandleShortcuts
1101 })1124 })
1125+ bw.keyboardHandler.SetPageModeActivationPassthrough(func() bool {
1126+ return a.shouldBypassPageModeActivation(bw)
1127+ })
1102 bw.keyboardHandler.SetAccentHandler(a)1128 bw.keyboardHandler.SetAccentHandler(a)
1103 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())1129 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())
1104 1130
@@ -1339,6 +1365,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
1339 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))1365 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
1340 }1366 }
1341 }1367 }
1368+ a.handlePageModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.PageModePolicyTriggerOmniboxFocus)
1342 },1369 },
1343 OnFocusOut: func() {1370 OnFocusOut: func() {
1344 // When omnibox loses focus, set WebView as the focused input1371 // When omnibox loses focus, set WebView as the focused input
@@ -1377,6 +1404,7 @@ func (a *App) initFindBarConfig(ctx context.Context) {
1377 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))1404 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
1378 }1405 }
1379 }1406 }
1407+ a.handlePageModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.PageModePolicyTriggerFindBarFocus)
1380 },1408 },
1381 OnFocusOut: func() {1409 OnFocusOut: func() {
1382 // When find bar loses focus, set WebView as the focused input1410 // When find bar loses focus, set WebView as the focused input
@@ -1400,6 +1428,9 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
1400 return1428 return
1401 }1429 }
1402 bw.sessionManager.Toggle(ctx)1430 bw.sessionManager.Toggle(ctx)
1431+ if bw.sessionManager.IsVisible() {
1432+ a.handlePageModeFocusTrigger(ctx, bw, usecase.PageModePolicyTriggerOverlayFocus)
1433+ }
1403 }1434 }
1404 1435
1405 func (a *App) attachTabPickerToActivePane() {1436 func (a *App) attachTabPickerToActivePane() {
@@ -1498,6 +1529,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
1498 1529
1499 a.attachTabPickerToActivePane()1530 a.attachTabPickerToActivePane()
1500 bw.tabPicker.Show(ctx, items)1531 bw.tabPicker.Show(ctx, items)
1532+ a.handlePageModeFocusTrigger(ctx, bw, usecase.PageModePolicyTriggerOverlayFocus)
1501 return nil1533 return nil
1502 }1534 }
1503 1535
@@ -3072,6 +3104,7 @@ func (a *App) initTabCoordinator(ctx context.Context) {
3072 // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.3104 // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.
3073 if bw := a.browserWindowForTabTarget(target); bw != nil {3105 if bw := a.browserWindowForTabTarget(target); bw != nil {
3074 a.activateBrowserWindow(bw)3106 a.activateBrowserWindow(bw)
3107+ a.handlePageModeTabSwitch(ctx, bw)
3075 }3108 }
3076 a.switchWorkspaceView(ctx, tab.ID)3109 a.switchWorkspaceView(ctx, tab.ID)
3077 })3110 })
@@ -3209,8 +3242,13 @@ func (a *App) initCoordinators(ctx context.Context) {
3209 a.deps.HistoryRecorderUC,3242 a.deps.HistoryRecorderUC,
3210 a.contentCoord,3243 a.contentCoord,
3211 )3244 )
3245+ // Wire the page scroll usecase so semantic scroll commands (left, right,
3246+ // up, down, fast up, fast down) can be dispatched through the
3247+ // NavigationCoordinator to the active WebView.
3248+ a.navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
3212 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {3249 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
3213 a.navCoord.ClearPaneHistory(paneID)3250 a.navCoord.ClearPaneHistory(paneID)
3251+ a.clearPageEditableFocusState(paneID)
3214 })3252 })
3215 3253
3216 // Wire title updates to history persistence3254 // Wire title updates to history persistence
@@ -3220,6 +3258,7 @@ func (a *App) initCoordinators(ctx context.Context) {
3220 3258
3221 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)3259 // 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) {3260 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
3261+ a.clearPageEditableFocusState(paneID)
3223 a.navCoord.RecordHistory(ctx, paneID, url)3262 a.navCoord.RecordHistory(ctx, paneID, url)
3224 })3263 })
3225 3264
@@ -3235,6 +3274,9 @@ func (a *App) initCoordinators(ctx context.Context) {
3235 // Mark dirty so snapshot captures the new URI3274 // Mark dirty so snapshot captures the new URI
3236 a.MarkDirty()3275 a.MarkDirty()
3237 })3276 })
3277+ a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
3278+ a.handlePageEditableFocusChanged(ctx, paneID, editable)
3279+ })
3238 3280
3239 // Hide loading skeleton once the WebView paints3281 // Hide loading skeleton once the WebView paints
3240 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {3282 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
@@ -3347,6 +3389,9 @@ func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bo
3347 3389
3348 func (a *App) wireKeyboardActions() {3390 func (a *App) wireKeyboardActions() {
3349 a.kbDispatcher.SetOnQuit(a.Quit)3391 a.kbDispatcher.SetOnQuit(a.Quit)
3392+ a.kbDispatcher.SetOnPageModePulse(func(ctx context.Context, fast bool) {
3393+ a.triggerPageModePulse(ctx, fast)
3394+ })
3350 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {3395 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
3351 a.ToggleFindBar(ctx)3396 a.ToggleFindBar(ctx)
3352 return nil3397 return nil
@@ -3678,8 +3723,128 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
3678 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))3723 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
3679 }3724 }
3680 3725
3681-// handleModeChange is called when the input mode changes.3726+func (a *App) pageModePolicy() *usecase.PageModePolicyUseCase {
3682-func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {3727+ if a == nil {
3728+ return usecase.NewPageModePolicyUseCase()
3729+ }
3730+ if a.pageModePolicyUC == nil {
3731+ a.pageModePolicyUC = usecase.NewPageModePolicyUseCase()
3732+ }
3733+ return a.pageModePolicyUC
3734+}
3735+
3736+func (a *App) ensurePageEditableFocusMap() {
3737+ if a != nil && a.pageEditableFocusByPane == nil {
3738+ a.pageEditableFocusByPane = make(map[entity.PaneID]bool)
3739+ }
3740+}
3741+
3742+func (a *App) pageEditableFocused(paneID entity.PaneID) bool {
3743+ if a == nil || paneID == "" || a.pageEditableFocusByPane == nil {
3744+ return false
3745+ }
3746+ return a.pageEditableFocusByPane[paneID]
3747+}
3748+
3749+func (a *App) setPageEditableFocused(paneID entity.PaneID, editable bool) {
3750+ if a == nil || paneID == "" {
3751+ return
3752+ }
3753+ a.ensurePageEditableFocusMap()
3754+ if editable {
3755+ a.pageEditableFocusByPane[paneID] = true
3756+ return
3757+ }
3758+ delete(a.pageEditableFocusByPane, paneID)
3759+}
3760+
3761+func (a *App) clearPageEditableFocusState(paneID entity.PaneID) {
3762+ a.setPageEditableFocused(paneID, false)
3763+}
3764+
3765+func (a *App) pageModeActiveForBrowserWindow(bw *browserWindow) bool {
3766+ return bw != nil && bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModePage
3767+}
3768+
3769+func (a *App) applyPageModePolicyTransition(_ context.Context, bw *browserWindow, transition usecase.PageModePolicyTransition) {
3770+ if transition != usecase.PageModePolicyTransitionExit || bw == nil || bw.keyboardHandler == nil {
3771+ return
3772+ }
3773+ if bw.keyboardHandler.Mode() != input.ModePage {
3774+ return
3775+ }
3776+ bw.keyboardHandler.ExitMode()
3777+}
3778+
3779+func (a *App) handlePageModeFocusTrigger(ctx context.Context, bw *browserWindow, trigger usecase.PageModePolicyTrigger) {
3780+ if bw == nil {
3781+ return
3782+ }
3783+ transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{
3784+ Trigger: trigger,
3785+ PageModeActive: a.pageModeActiveForBrowserWindow(bw),
3786+ })
3787+ a.applyPageModePolicyTransition(ctx, bw, transition)
3788+}
3789+
3790+func (a *App) handlePageModeTabSwitch(_ context.Context, bw *browserWindow) {
3791+ if bw == nil || bw.tabs == nil || !a.pageModeActiveForBrowserWindow(bw) {
3792+ return
3793+ }
3794+ transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{
3795+ Trigger: usecase.PageModePolicyTriggerContextChanged,
3796+ PageModeActive: true,
3797+ PreserveOnContextChange: false,
3798+ })
3799+ if transition != usecase.PageModePolicyTransitionExit {
3800+ return
3801+ }
3802+ if prevTabID := bw.tabs.PreviousActiveTabID; prevTabID != "" {
3803+ if prevView := a.workspaceViews[prevTabID]; prevView != nil {
3804+ if pv := prevView.GetPaneView(bw.pageModePaneID); pv != nil {
3805+ pv.SetPageMode(false)
3806+ }
3807+ }
3808+ }
3809+ bw.pageModePaneID = ""
3810+ bw.keyboardHandler.ExitMode()
3811+}
3812+
3813+func (a *App) handlePageEditableFocusChanged(ctx context.Context, paneID entity.PaneID, editable bool) {
3814+ if paneID == "" {
3815+ return
3816+ }
3817+ a.setPageEditableFocused(paneID, editable)
3818+
3819+ bw := a.browserWindowForAnyPane(paneID)
3820+ if bw == nil {
3821+ return
3822+ }
3823+ ws := a.activeWorkspaceForBrowserWindow(bw)
3824+ activeContext := ws != nil && ws.ActivePaneID == paneID && a.lastFocusedBrowserWindow() == bw
3825+ transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{
3826+ Trigger: usecase.PageModePolicyTriggerPageEditableFocusChanged,
3827+ PageModeActive: a.pageModeActiveForBrowserWindow(bw),
3828+ PageEditableFocused: editable,
3829+ EventInActiveContext: activeContext,
3830+ })
3831+ a.applyPageModePolicyTransition(ctx, bw, transition)
3832+}
3833+
3834+func (a *App) shouldBypassPageModeActivation(bw *browserWindow) bool {
3835+ ws := a.activeWorkspaceForBrowserWindow(bw)
3836+ if ws == nil {
3837+ return false
3838+ }
3839+ transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{
3840+ Trigger: usecase.PageModePolicyTriggerActivationAttempt,
3841+ PageEditableFocused: a.pageEditableFocused(ws.ActivePaneID),
3842+ })
3843+ return transition == usecase.PageModePolicyTransitionBlockActivation
3844+}
3845+
3846+// handleModeChange is called when the input mode changes for a specific browser window.
3847+func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to input.Mode) {
3683 log := logging.FromContext(ctx)3848 log := logging.FromContext(ctx)
3684 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")3849 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
3685 3850
@@ -3691,48 +3856,204 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
3691 a.applyResizeModeBorder(ctx, a.activeWorkspace())3856 a.applyResizeModeBorder(ctx, a.activeWorkspace())
3692 }3857 }
3693 3858
3859+ // Handle pane-local Page Mode visual ownership.
3860+ // Entering Page Mode accents the active pane; leaving removes the accent.
3861+ a.handlePageModeOwnership(ctx, bw, to, from)
3862+
3694 // Update global border overlay visibility based on mode.3863 // Update global border overlay visibility based on mode.
3695 // Note: resize mode border is handled per-pane (stack container), not via global overlay.3864 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
3696- if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil {3865+ // Page mode explicitly skips the global border overlay.
3866+ if bw != nil && bw.borderMgr != nil {
3697 bw.borderMgr.OnModeChange(ctx, from, to)3867 bw.borderMgr.OnModeChange(ctx, from, to)
3698 }3868 }
3699 3869
3700- // Show/hide mode indicator toaster based on config.3870+ // Show/hide this window's mode indicator toaster based on mode and config.
3701- a.updateModeIndicatorToaster(ctx, to)3871+ a.updateModeIndicatorToaster(ctx, bw, to)
3872+}
3873+
3874+// transferPageModeOwnershipToPane transfers the pane-local Page Mode accent
3875+// from the current owning pane to another pane in the same browser window.
3876+// The transfer only happens while that window is in Page Mode.
3877+func (a *App) transferPageModeOwnershipToPane(ctx context.Context, bw *browserWindow, newPaneID entity.PaneID) {
3878+ if bw == nil {
3879+ return
3880+ }
3881+
3882+ // Only transfer if page mode is currently active and the owner is changing.
3883+ if bw.pageModePaneID == "" || bw.pageModePaneID == newPaneID {
3884+ return
3885+ }
3886+
3887+ // Check if this window is actually in page mode.
3888+ if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModePage {
3889+ // Page mode not active on this window; just clear stale ownership.
3890+ bw.pageModePaneID = ""
3891+ return
3892+ }
3893+
3894+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
3895+ if wsView == nil {
3896+ return
3897+ }
3898+
3899+ transition := a.pageModePolicy().Evaluate(usecase.PageModePolicyInput{
3900+ Trigger: usecase.PageModePolicyTriggerContextChanged,
3901+ PageModeActive: true,
3902+ PreserveOnContextChange: !a.pageEditableFocused(newPaneID),
3903+ })
3904+ if transition == usecase.PageModePolicyTransitionExit {
3905+ a.applyPageModePolicyTransition(ctx, bw, transition)
3906+ return
3907+ }
3908+
3909+ oldPaneID := bw.pageModePaneID
3910+
3911+ // Deactivate the old owning pane.
3912+ if oldPV := wsView.GetPaneView(oldPaneID); oldPV != nil {
3913+ oldPV.SetPageMode(false)
3914+ }
3915+
3916+ // Activate the new pane.
3917+ if newPV := wsView.GetPaneView(newPaneID); newPV != nil {
3918+ newPV.SetPageMode(true)
3919+ }
3920+ bw.pageModePaneID = newPaneID
3921+
3922+ logging.FromContext(ctx).Debug().
3923+ Str("window_id", bw.id).
3924+ Str("old_pane_id", string(oldPaneID)).
3925+ Str("new_pane_id", string(newPaneID)).
3926+ Msg("page mode ownership transferred")
3927+}
3928+
3929+// handlePageModeOwnership manages the pane-local Page Mode accent and pulse
3930+// owner for a specific browser window when input modes change.
3931+func (a *App) handlePageModeOwnership(ctx context.Context, bw *browserWindow, to, from input.Mode) {
3932+ if bw == nil {
3933+ return
3934+ }
3935+
3936+ ws := a.activeWorkspaceForBrowserWindow(bw)
3937+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
3938+ if ws == nil || wsView == nil {
3939+ // If there's no workspace yet (startup), just track the intent.
3940+ if to != input.ModePage && from == input.ModePage {
3941+ bw.pageModePaneID = ""
3942+ }
3943+ return
3944+ }
3945+
3946+ if to == input.ModePage && from != input.ModePage {
3947+ // Entering page mode: activate on the current active pane of this window.
3948+ paneID := ws.ActivePaneID
3949+ if pv := wsView.GetPaneView(paneID); pv != nil {
3950+ pv.SetPageMode(true)
3951+ bw.pageModePaneID = paneID
3952+ logging.FromContext(ctx).Debug().
3953+ Str("window_id", bw.id).
3954+ Str("pane_id", string(paneID)).
3955+ Msg("page mode activated on pane")
3956+ }
3957+ } else if from == input.ModePage && to != input.ModePage {
3958+ // Leaving Page Mode: deactivate the pane that owns the visual accent.
3959+ a.clearPageModeOwnership(ctx, bw)
3960+ }
3702 }3961 }
3703 3962
3704-// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.3963+// triggerPageModePulse triggers a pane-local page mode pulse on the
3705-func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {3964+// last-focused browser window's owning pane. This is called by the
3965+// keyboard dispatcher after a page scroll action. The bw used is the
3966+// window that was active when the action was dispatched (via
3967+// dispatchBrowserWindowAction which calls activateBrowserWindow first).
3968+// If no pane is currently in page mode, the pulse is a no-op.
3969+//
3970+// Pulses are debounced at pageModePulseInterval to prevent excessive GTK CSS
3971+// class churn during held-key repeats. Smooth scroll cadence is owned by the
3972+// Page Mode repeater and backend scroll path; pulse feedback is deliberately
3973+// slower so indicator/overlay animation work does not become part of the
3974+// scrolling critical path.
3975+func (a *App) triggerPageModePulse(_ context.Context, fast bool) {
3976+ // Debounce: skip if called again too soon during a continuous hold.
3977+ // We still serialize the timestamp update so repeated dispatcher calls do
3978+ // not restart CSS animations at scroll cadence.
3979+ a.pageModePulseMu.Lock()
3980+ since := time.Since(a.pageModePulseLastTime)
3981+ if since < pageModePulseInterval {
3982+ a.pageModePulseMu.Unlock()
3983+ return
3984+ }
3985+ a.pageModePulseLastTime = time.Now()
3986+ a.pageModePulseMu.Unlock()
3987+
3706 bw := a.lastFocusedBrowserWindow()3988 bw := a.lastFocusedBrowserWindow()
3707- if bw == nil || bw.modeToaster == nil {3989+ if bw == nil || bw.pageModePaneID == "" {
3990+ return
3991+ }
3992+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
3993+ if wsView == nil {
3708 return3994 return
3709 }3995 }
3996+ if pv := wsView.GetPaneView(bw.pageModePaneID); pv != nil {
3997+ if fast {
3998+ pv.TriggerPageModePulseFast()
3999+ } else {
4000+ pv.TriggerPageModePulse()
4001+ }
4002+ }
4003+}
3710 4004
3711- // Check if mode indicator toaster is enabled in config.4005+// clearPageModeOwnership deactivates page mode on the owning pane for a
3712- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {4006+// specific browser window and resets its tracked pane ID.
3713- bw.modeToaster.Hide()4007+// Also resets the pulse debounce timer so the first pulse on re-entry
4008+// is never skipped.
4009+func (a *App) clearPageModeOwnership(ctx context.Context, bw *browserWindow) {
4010+ if bw == nil || bw.pageModePaneID == "" {
3714 return4011 return
3715 }4012 }
3716 4013
3717- if mode == input.ModeNormal {4014+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
4015+ if wsView != nil {
4016+ if pv := wsView.GetPaneView(bw.pageModePaneID); pv != nil {
4017+ pv.SetPageMode(false)
4018+ logging.FromContext(ctx).Debug().
4019+ Str("window_id", bw.id).
4020+ Str("pane_id", string(bw.pageModePaneID)).
4021+ Msg("page mode deactivated on pane")
4022+ }
4023+ }
4024+ bw.pageModePaneID = ""
4025+
4026+ // Reset debounce timer so re-entry pulses are never mis-skipped.
4027+ a.pageModePulseMu.Lock()
4028+ a.pageModePulseLastTime = time.Time{}
4029+ a.pageModePulseMu.Unlock()
4030+}
4031+
4032+// updateModeIndicatorToaster reconciles one window's mode toaster with its
4033+// current mode and the live mode-indicator preference.
4034+func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, mode input.Mode) {
4035+ if bw == nil || bw.modeToaster == nil {
4036+ return
4037+ }
4038+
4039+ if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal {
3718 bw.modeToaster.Hide()4040 bw.modeToaster.Hide()
3719 return4041 return
3720 }4042 }
3721 4043
3722- // Show persistent toaster at bottom-left with mode display name.4044+ // Modal mode toasts remain visible until that same window exits its mode.
3723- // Mode class is applied atomically with Show() to avoid visual flicker.4045+ // 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,4046 bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
3726- component.WithDuration(0), // Persistent until mode exits.4047+ component.WithDuration(0),
3727 component.WithPosition(component.ToastPositionBottomLeft),4048 component.WithPosition(component.ToastPositionBottomLeft),
3728- component.WithModeClass(modeClass),4049+ component.WithModeClass(getModeToastClass(mode)),
3729 )4050 )
3730 }4051 }
3731 4052
3732 // getModeToastClass returns the CSS class for the given mode's toast styling.4053 // getModeToastClass returns the CSS class for the given mode's toast styling.
3733 func getModeToastClass(mode input.Mode) string {4054 func getModeToastClass(mode input.Mode) string {
3734 switch mode {4055 switch mode {
3735- case input.ModePane:4056+ case input.ModePane, input.ModePage:
3736 return "toast-pane-mode"4057 return "toast-pane-mode"
3737 case input.ModeTab:4058 case input.ModeTab:
3738 return "toast-tab-mode"4059 return "toast-tab-mode"
@@ -3843,6 +4164,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
3843 })4164 })
3844 wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {4165 wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {
3845 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")4166 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
4167+ if bw := a.browserWindowForTab(tab.ID); bw != nil {
4168+ a.transferPageModeOwnershipToPane(ctx, bw, paneID)
4169+ }
3846 })4170 })
3847 }4171 }
3848 4172
@@ -4965,6 +5289,11 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
4965 if bw.globalShortcutHandler != nil {5289 if bw.globalShortcutHandler != nil {
4966 bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)5290 bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)
4967 }5291 }
5292+ mode := input.ModeNormal
5293+ if bw.keyboardHandler != nil {
5294+ mode = bw.keyboardHandler.Mode()
5295+ }
5296+ a.updateModeIndicatorToaster(ctx, bw, mode)
4968 }5297 }
4969 }5298 }
4970 5299
@@ -5033,6 +5362,7 @@ func (a *App) applyThemeAppearance(ctx context.Context) {
5033 if resolveThemeUC == nil {5362 if resolveThemeUC == nil {
5034 resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource)5363 resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource)
5035 }5364 }
5365+ a.deps.Theme.SetTransitionDuration(workspaceStyling.TransitionDuration)
5036 resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig(5366 resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig(
5037 &appearanceCfg,5367 &appearanceCfg,
5038 runtimeCfg.DefaultUIScale,5368 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 +29 -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,34 @@ 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+
278 // NotifyZoomChanged updates the omnibox zoom indicator.307 // NotifyZoomChanged updates the omnibox zoom indicator.
279 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {308 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {
280 if c.omniboxProvider != nil {309 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 +74 -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,63 @@ 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
270+ // Each maps a keyboard action to its semantic PageScrollCommand
271+ // and triggers a pane-local visual pulse via the registered callback.
272+ input.ActionPageScrollLeft: func(ctx context.Context) error {
273+ return d.withActiveWebView(ctx, "page scroll left", func(wv port.WebView) error {
274+ if err := d.navCoord.ScrollWebView(ctx, wv, usecase.PageScrollLeft); err != nil {
275+ return err
276+ }
277+ d.triggerPulse(ctx, false)
278+ return nil
279+ })
280+ },
281+ input.ActionPageScrollRight: func(ctx context.Context) error {
282+ return d.withActiveWebView(ctx, "page scroll right", func(wv port.WebView) error {
283+ if err := d.navCoord.ScrollWebView(ctx, wv, usecase.PageScrollRight); err != nil {
284+ return err
285+ }
286+ d.triggerPulse(ctx, false)
287+ return nil
288+ })
289+ },
290+ input.ActionPageScrollUp: func(ctx context.Context) error {
291+ return d.withActiveWebView(ctx, "page scroll up", func(wv port.WebView) error {
292+ if err := d.navCoord.ScrollWebView(ctx, wv, usecase.PageScrollUp); err != nil {
293+ return err
294+ }
295+ d.triggerPulse(ctx, false)
296+ return nil
297+ })
298+ },
299+ input.ActionPageScrollDown: func(ctx context.Context) error {
300+ return d.withActiveWebView(ctx, "page scroll down", func(wv port.WebView) error {
301+ if err := d.navCoord.ScrollWebView(ctx, wv, usecase.PageScrollDown); err != nil {
302+ return err
303+ }
304+ d.triggerPulse(ctx, false)
305+ return nil
306+ })
307+ },
308+ input.ActionPageScrollUpFast: func(ctx context.Context) error {
309+ return d.withActiveWebView(ctx, "page scroll up fast", func(wv port.WebView) error {
310+ if err := d.navCoord.ScrollWebView(ctx, wv, usecase.PageScrollUpFast); err != nil {
311+ return err
312+ }
313+ d.triggerPulse(ctx, true)
314+ return nil
315+ })
316+ },
317+ input.ActionPageScrollDownFast: func(ctx context.Context) error {
318+ return d.withActiveWebView(ctx, "page scroll down fast", func(wv port.WebView) error {
319+ if err := d.navCoord.ScrollWebView(ctx, wv, usecase.PageScrollDownFast); err != nil {
320+ return err
321+ }
322+ d.triggerPulse(ctx, true)
323+ return nil
324+ })
325+ },
252 // UI326 // UI
253 input.ActionOpenOmnibox: d.navCoord.OpenOmnibox,327 input.ActionOpenOmnibox: d.navCoord.OpenOmnibox,
254 input.ActionOpenFind: d.handleFindOpen,328 input.ActionOpenFind: d.handleFindOpen,
M internal/ui/dispatcher/keyboard_test.go +99 -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,99 @@ 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+func TestKeyboardDispatcher_PageModeActionsRouteToCorrectScrollCommand(t *testing.T) {
215+ ctx := context.Background()
216+
217+ tests := []struct {
218+ action input.Action
219+ cmd usecase.PageScrollCommand
220+ expectedDx int
221+ expectedDy int
222+ }{
223+ {input.ActionPageScrollLeft, usecase.PageScrollLeft, -80, 0},
224+ {input.ActionPageScrollRight, usecase.PageScrollRight, 80, 0},
225+ {input.ActionPageScrollUp, usecase.PageScrollUp, 0, -80},
226+ {input.ActionPageScrollDown, usecase.PageScrollDown, 0, 80},
227+ {input.ActionPageScrollUpFast, usecase.PageScrollUpFast, 0, -320},
228+ {input.ActionPageScrollDownFast, usecase.PageScrollDownFast, 0, 320},
229+ }
230+
231+ for _, tc := range tests {
232+ t.Run(string(tc.action), func(t *testing.T) {
233+ base := mocks.NewMockWebView(t)
234+ scroller := mocks.NewMockPageScrollable(t)
235+ wv := &mockScrollableWebView{MockWebView: base, MockPageScrollable: scroller}
236+
237+ navCoord := &coordinator.NavigationCoordinator{}
238+ navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
239+
240+ d := NewKeyboardDispatcher(
241+ ctx,
242+ &coordinator.WorkspaceCoordinator{},
243+ navCoord,
244+ nil,
245+ nil,
246+ KeyboardActions{
247+ ActiveWebView: func(context.Context) port.WebView { return wv },
248+ },
249+ func(context.Context) entity.PaneID { return "" },
250+ )
251+
252+ base.EXPECT().ID().Return(port.WebViewID(42)).Once()
253+ req := port.PageScrollRequest{Command: port.PageScrollCommand(tc.cmd), FallbackDX: tc.expectedDx, FallbackDY: tc.expectedDy}
254+ scroller.EXPECT().ScrollPage(ctx, req).Return(nil).Once()
255+
256+ err := d.Dispatch(ctx, tc.action)
257+ require.NoError(t, err)
258+ })
259+ }
260+}
261+
262+func TestKeyboardDispatcher_PageModeNoopWhenNoActiveWebView(t *testing.T) {
263+ ctx := context.Background()
264+
265+ navCoord := &coordinator.NavigationCoordinator{}
266+ navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
267+
268+ d := NewKeyboardDispatcher(
269+ ctx,
270+ &coordinator.WorkspaceCoordinator{},
271+ navCoord,
272+ nil,
273+ nil,
274+ KeyboardActions{},
275+ func(context.Context) entity.PaneID { return "" },
276+ )
277+
278+ // No ActiveWebView set — dispatcher should no-op cleanly
279+ err := d.Dispatch(ctx, input.ActionPageScrollDown)
280+ require.NoError(t, err)
281+}
282+
283+func TestKeyboardDispatcher_PageModeNoopWhenActiveWebViewReturnsNil(t *testing.T) {
284+ ctx := context.Background()
285+
286+ navCoord := &coordinator.NavigationCoordinator{}
287+ navCoord.SetPageScrollUseCase(usecase.NewPageScrollUseCase())
288+
289+ d := NewKeyboardDispatcher(
290+ ctx,
291+ &coordinator.WorkspaceCoordinator{},
292+ navCoord,
293+ nil,
294+ nil,
295+ KeyboardActions{
296+ ActiveWebView: func(context.Context) port.WebView { return nil },
297+ },
298+ func(context.Context) entity.PaneID { return "" },
299+ )
300+
301+ err := d.Dispatch(ctx, input.ActionPageScrollDown)
302+ require.NoError(t, err)
303+}
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 +257 -7
@@ -36,6 +36,13 @@ 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+const (
40+ // pageScrollRepeatInterval is the fallback cadence when the handler is not
41+ // attached to a GTK widget. Attached handlers use GTK tick callbacks instead,
42+ // which run at the output device's frame rate.
43+ pageScrollRepeatInterval = time.Second / 60
44+)
45+
39 // AccentHandler handles long-press accent detection.46 // AccentHandler handles long-press accent detection.
40 // Called for character keys that may have accent variants.47 // Called for character keys that may have accent variants.
41 type AccentHandler interface {48 type AccentHandler interface {
@@ -68,6 +75,9 @@ type KeyboardHandler struct {
68 accentHandler AccentHandler75 accentHandler AccentHandler
69 // Optional escape hook for app-level overlays76 // Optional escape hook for app-level overlays
70 onEscape func(ctx context.Context) bool77 onEscape func(ctx context.Context) bool
78+ // Optional hook that blocks Page mode activation and lets the original key
79+ // event pass through to the focused widget/page instead.
80+ pageModeActivationPassthrough func() bool
71 81
72 // GTK controller (nil until attached)82 // GTK controller (nil until attached)
73 controller *gtk.EventControllerKey83 controller *gtk.EventControllerKey
@@ -82,6 +92,15 @@ type KeyboardHandler struct {
82 activePressedActions map[Action]uint92 activePressedActions map[Action]uint
83 ctx context.Context93 ctx context.Context
84 mu sync.RWMutex94 mu sync.RWMutex
95+
96+ pageScrollRepeatAction Action
97+ pageScrollRepeatKeyval uint
98+ pageScrollRepeatTimer uint
99+ pageScrollRepeatCb glib.SourceFunc
100+ pageScrollRepeatTickCb gtk.TickCallback
101+ pageScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint
102+ pageScrollRepeatRemove func(id uint) bool
103+ pageScrollRepeatActiveRemove func(id uint) bool
85 }104 }
86 105
87 // NewKeyboardHandler creates a new keyboard handler.106 // NewKeyboardHandler creates a new keyboard handler.
@@ -97,7 +116,12 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
97 session: session,116 session: session,
98 activePressedActions: make(map[Action]uint),117 activePressedActions: make(map[Action]uint),
99 ctx: ctx,118 ctx: ctx,
119+ pageScrollRepeatAdd: func(intervalMS uint, cb *glib.SourceFunc) uint {
120+ return glib.TimeoutAdd(intervalMS, cb, 0)
121+ },
122+ pageScrollRepeatRemove: glib.SourceRemove,
100 }123 }
124+ h.SetOnModeChange(nil)
101 125
102 return h126 return h
103 }127 }
@@ -109,9 +133,18 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
109 h.onAction = fn133 h.onAction = fn
110 }134 }
111 135
136+// SetPageModeActivationPassthrough sets the callback that can block Page mode
137+// activation and let the original key event propagate instead.
138+func (h *KeyboardHandler) SetPageModeActivationPassthrough(fn func() bool) {
139+ h.mu.Lock()
140+ defer h.mu.Unlock()
141+ h.pageModeActivationPassthrough = fn
142+}
143+
112 // ReloadShortcuts rebuilds the shortcut set from new config values.144 // ReloadShortcuts rebuilds the shortcut set from new config values.
113 // This enables hot-reloading of keybindings without restarting.145 // This enables hot-reloading of keybindings without restarting.
114 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {146 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
147+ h.stopPageScrollRepeat()
115 h.mu.Lock()148 h.mu.Lock()
116 defer h.mu.Unlock()149 defer h.mu.Unlock()
117 150
@@ -134,6 +167,9 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
134 } else if from == ModeNormal {167 } else if from == ModeNormal {
135 h.setControllerPhase(gtk.PhaseCaptureValue)168 h.setControllerPhase(gtk.PhaseCaptureValue)
136 }169 }
170+ if to != ModePage {
171+ h.stopPageScrollRepeat()
172+ }
137 // Forward to app-level callback173 // Forward to app-level callback
138 if fn != nil {174 if fn != nil {
139 fn(from, to)175 fn(from, to)
@@ -258,7 +294,6 @@ func (h *KeyboardHandler) DetachForDestroy() {
258 294
259 func (h *KeyboardHandler) detach(removeController bool) {295 func (h *KeyboardHandler) detach(removeController bool) {
260 h.mu.Lock()296 h.mu.Lock()
261- defer h.mu.Unlock()
262 if h.controller != nil && h.keyPressedHandlerID != 0 {297 if h.controller != nil && h.keyPressedHandlerID != 0 {
263 h.controller.DisconnectSignal(h.keyPressedHandlerID)298 h.controller.DisconnectSignal(h.keyPressedHandlerID)
264 }299 }
@@ -275,6 +310,21 @@ func (h *KeyboardHandler) detach(removeController bool) {
275 h.keyPressedHandlerID = 0310 h.keyPressedHandlerID = 0
276 h.keyReleasedHandlerID = 0311 h.keyReleasedHandlerID = 0
277 h.activePressedActions = nil312 h.activePressedActions = nil
313+ timerID := h.pageScrollRepeatTimer
314+ remove := h.pageScrollRepeatRemove
315+ h.pageScrollRepeatAction = ""
316+ h.pageScrollRepeatKeyval = 0
317+ h.pageScrollRepeatTimer = 0
318+ h.pageScrollRepeatCb = nil
319+ h.pageScrollRepeatTickCb = nil
320+ if h.pageScrollRepeatActiveRemove != nil {
321+ remove = h.pageScrollRepeatActiveRemove
322+ }
323+ h.pageScrollRepeatActiveRemove = nil
324+ h.mu.Unlock()
325+ if timerID != 0 && remove != nil {
326+ remove(timerID)
327+ }
278 }328 }
279 329
280 // handleKeyPress processes a key press event.330 // handleKeyPress processes a key press event.
@@ -294,17 +344,31 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
294 }344 }
295 345
296 modifiers := Modifier(state) & modifierMask346 modifiers := Modifier(state) & modifierMask
347+ mode := h.modal.Mode()
297 348
298 // Escape in normal mode: check app-level escape hook first349 // Escape in normal mode: check app-level escape hook first
299- if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {350+ if mode == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
300 if onEscape != nil && onEscape(h.ctx) {351 if onEscape != nil && onEscape(h.ctx) {
301 return true352 return true
302 }353 }
303 }354 }
304 355
356+ // Escape / Enter should always leave modal modes even if the current config
357+ // omits explicit confirm/cancel bindings.
358+ if mode != ModeNormal && modifiers == 0 {
359+ switch keyval {
360+ case uint(gdk.KEY_Escape), uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
361+ if mode == ModePage {
362+ h.stopPageScrollRepeat()
363+ }
364+ h.modal.ExitMode(h.ctx)
365+ return true
366+ }
367+ }
368+
305 // Determine routing for this key event369 // Determine routing for this key event
306 route := RouteHandleShortcuts // default: process through shortcut system370 route := RouteHandleShortcuts // default: process through shortcut system
307- if routeKey != nil && h.modal.Mode() == ModeNormal {371+ if routeKey != nil && mode == ModeNormal {
308 route = routeKey(KeyContext{372 route = routeKey(KeyContext{
309 Keyval: keyval,373 Keyval: keyval,
310 Keycode: keycode,374 Keycode: keycode,
@@ -338,22 +402,132 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
338 keyval = normalizeKeyval(keyval)402 keyval = normalizeKeyval(keyval)
339 403
340 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}404 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
341- mode := h.modal.Mode()
342 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)405 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
406+ return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers)
407+}
343 408
409+func (h *KeyboardHandler) handleShortcutLookupResult(
410+ log *zerolog.Logger,
411+ action Action,
412+ found bool,
413+ mode Mode,
414+ keyval uint,
415+ modifiers Modifier,
416+) bool {
344 if !found {417 if !found {
418+ if shouldPassthroughNativePageModeNavigation(mode, keyval, modifiers) {
419+ log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in page mode")
420+ return false
421+ }
345 return mode != ModeNormal // Consume unrecognized keys in modal mode422 return mode != ModeNormal // Consume unrecognized keys in modal mode
346 }423 }
424+ if h.shouldPassthroughPageModeActivation(action, mode) {
425+ return false
426+ }
427+ if mode == ModePage && isPageScrollAction(action) {
428+ return h.handlePageScrollAction(log, action, mode, keyval)
429+ }
347 if h.suppressHeldAction(action, keyval) {430 if h.suppressHeldAction(action, keyval) {
348 log.Trace().431 log.Trace().
349 Str("action", string(action)).432 Str("action", string(action)).
350 Msg("held keyboard action repeat suppressed")433 Msg("held keyboard action repeat suppressed")
351 return true434 return true
352 }435 }
436+ return h.dispatchAction(action, mode)
437+}
438+
439+func (h *KeyboardHandler) handlePageScrollAction(log *zerolog.Logger, action Action, mode Mode, keyval uint) bool {
440+ h.mu.Lock()
441+ repeatAction := h.pageScrollRepeatAction
442+ repeatKeyval := h.pageScrollRepeatKeyval
443+ repeatTimer := h.pageScrollRepeatTimer
444+ h.mu.Unlock()
445+
446+ if repeatAction == action && repeatKeyval == keyval {
447+ if repeatTimer != 0 {
448+ log.Trace().Str("action", string(action)).Msg("page scroll key repeat handled by smooth repeater")
449+ return true
450+ }
451+ if h.dispatchAction(action, mode) {
452+ h.startPageScrollRepeat(action, keyval)
453+ return true
454+ }
455+ return false
456+ }
353 457
458+ h.stopPageScrollRepeat()
459+ h.mu.Lock()
460+ h.pageScrollRepeatAction = action
461+ h.pageScrollRepeatKeyval = keyval
462+ h.mu.Unlock()
354 return h.dispatchAction(action, mode)463 return h.dispatchAction(action, mode)
355 }464 }
356 465
466+func (h *KeyboardHandler) startPageScrollRepeat(action Action, keyval uint) {
467+ h.mu.Lock()
468+ if h.pageScrollRepeatAction != action || h.pageScrollRepeatKeyval != keyval || h.pageScrollRepeatTimer != 0 {
469+ h.mu.Unlock()
470+ return
471+ }
472+ if h.window != nil {
473+ window := h.window
474+ cb := gtk.TickCallback(func(_, _, _ uintptr) bool {
475+ return h.pageScrollRepeatTick(action, keyval)
476+ })
477+ h.pageScrollRepeatTickCb = cb
478+ h.pageScrollRepeatActiveRemove = func(id uint) bool {
479+ window.RemoveTickCallback(id)
480+ return true
481+ }
482+ h.pageScrollRepeatTimer = window.AddTickCallback(&h.pageScrollRepeatTickCb, 0, nil)
483+ h.mu.Unlock()
484+ return
485+ }
486+
487+ add := h.pageScrollRepeatAdd
488+ if add == nil {
489+ h.mu.Unlock()
490+ return
491+ }
492+ cb := glib.SourceFunc(func(_ uintptr) bool {
493+ return h.pageScrollRepeatTick(action, keyval)
494+ })
495+ h.pageScrollRepeatCb = cb
496+ h.pageScrollRepeatTimer = add(uint(pageScrollRepeatInterval/time.Millisecond), &h.pageScrollRepeatCb)
497+ h.mu.Unlock()
498+}
499+
500+func (h *KeyboardHandler) pageScrollRepeatTick(action Action, keyval uint) bool {
501+ h.mu.RLock()
502+ active := h.pageScrollRepeatAction == action && h.pageScrollRepeatKeyval == keyval && h.pageScrollRepeatTimer != 0
503+ h.mu.RUnlock()
504+ if !active || h.modal.Mode() != ModePage {
505+ h.stopPageScrollRepeat()
506+ return false
507+ }
508+ h.dispatchAction(action, ModePage)
509+ return true
510+}
511+
512+func (h *KeyboardHandler) stopPageScrollRepeat() {
513+ h.mu.Lock()
514+ timerID := h.pageScrollRepeatTimer
515+ remove := h.pageScrollRepeatRemove
516+ h.pageScrollRepeatAction = ""
517+ h.pageScrollRepeatKeyval = 0
518+ h.pageScrollRepeatTimer = 0
519+ h.pageScrollRepeatCb = nil
520+ h.pageScrollRepeatTickCb = nil
521+ if h.pageScrollRepeatActiveRemove != nil {
522+ remove = h.pageScrollRepeatActiveRemove
523+ }
524+ h.pageScrollRepeatActiveRemove = nil
525+ h.mu.Unlock()
526+ if timerID != 0 && remove != nil {
527+ remove(timerID)
528+ }
529+}
530+
357 // tryAccentDetection starts long-press detection for accent-eligible keys.531 // tryAccentDetection starts long-press detection for accent-eligible keys.
358 // It is used only for the RouteAccentDetection route (GTK Entry widgets such as532 // It is used only for the RouteAccentDetection route (GTK Entry widgets such as
359 // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;533 // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;
@@ -382,6 +556,18 @@ func normalizeKeyval(keyval uint) uint {
382 return keyval556 return keyval
383 }557 }
384 558
559+func shouldPassthroughNativePageModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool {
560+ if mode != ModePage || modifiers != ModNone {
561+ return false
562+ }
563+ switch keyval {
564+ case uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down):
565+ return true
566+ default:
567+ return false
568+ }
569+}
570+
385 // dispatchAction dispatches the action and handles mode-related logic.571 // dispatchAction dispatches the action and handles mode-related logic.
386 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {572 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
387 if h.handleModeAction(action) {573 if h.handleModeAction(action) {
@@ -402,6 +588,9 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
402 if mode == ModeResize && isResizeAction(action) {588 if mode == ModeResize && isResizeAction(action) {
403 h.modal.ResetTimeout(h.ctx)589 h.modal.ResetTimeout(h.ctx)
404 }590 }
591+ if mode == ModePage && isPageScrollAction(action) {
592+ h.modal.ResetTimeout(h.ctx)
593+ }
405 594
406 if mode != ModeNormal && ShouldAutoExitMode(action) {595 if mode != ModeNormal && ShouldAutoExitMode(action) {
407 h.modal.ExitMode(h.ctx)596 h.modal.ExitMode(h.ctx)
@@ -436,6 +625,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool {
436 ActionEnterPaneMode,625 ActionEnterPaneMode,
437 ActionEnterSessionMode,626 ActionEnterSessionMode,
438 ActionEnterResizeMode,627 ActionEnterResizeMode,
628+ ActionEnterPageMode,
439 ActionNewTab,629 ActionNewTab,
440 ActionRenameTab,630 ActionRenameTab,
441 ActionSplitRight,631 ActionSplitRight,
@@ -512,6 +702,20 @@ func isResizeAction(action Action) bool {
512 }702 }
513 }703 }
514 704
705+func isPageScrollAction(action Action) bool {
706+ switch action {
707+ case ActionPageScrollLeft,
708+ ActionPageScrollDown,
709+ ActionPageScrollUp,
710+ ActionPageScrollRight,
711+ ActionPageScrollDownFast,
712+ ActionPageScrollUpFast:
713+ return true
714+ default:
715+ return false
716+ }
717+}
718+
515 func (h *KeyboardHandler) handleModeAction(action Action) bool {719 func (h *KeyboardHandler) handleModeAction(action Action) bool {
516 h.mu.RLock()720 h.mu.RLock()
517 workspace := h.workspace721 workspace := h.workspace
@@ -551,7 +755,22 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
551 }755 }
552 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)756 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
553 return true757 return true
758+ case ActionEnterPageMode:
759+ if h.modal.Mode() == ModePage {
760+ h.stopPageScrollRepeat()
761+ h.modal.ExitMode(h.ctx)
762+ return true
763+ }
764+ var pgms int
765+ if workspace != nil {
766+ pgms = workspace.PageMode.TimeoutMilliseconds
767+ }
768+ h.modal.EnterPageMode(h.ctx, time.Duration(pgms)*time.Millisecond)
769+ return true
554 case ActionExitMode:770 case ActionExitMode:
771+ if h.modal.Mode() == ModePage {
772+ h.stopPageScrollRepeat()
773+ }
555 h.modal.ExitMode(h.ctx)774 h.modal.ExitMode(h.ctx)
556 return true775 return true
557 default:776 default:
@@ -598,33 +817,64 @@ func (h *KeyboardHandler) EnterSessionMode() {
598 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)817 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
599 }818 }
600 819
820+// EnterPageMode programmatically enters page scrolling mode.
821+// Useful for testing or programmatic mode changes.
822+func (h *KeyboardHandler) EnterPageMode() {
823+ h.mu.RLock()
824+ workspace := h.workspace
825+ h.mu.RUnlock()
826+ var ms int
827+ if workspace != nil {
828+ ms = workspace.PageMode.TimeoutMilliseconds
829+ }
830+ h.modal.EnterPageMode(h.ctx, time.Duration(ms)*time.Millisecond)
831+}
832+
601 // ExitMode programmatically exits modal mode.833 // ExitMode programmatically exits modal mode.
602 // Useful for testing or programmatic mode changes.834 // Useful for testing or programmatic mode changes.
603 func (h *KeyboardHandler) ExitMode() {835 func (h *KeyboardHandler) ExitMode() {
604 h.modal.ExitMode(h.ctx)836 h.modal.ExitMode(h.ctx)
605 }837 }
606 838
839+func (h *KeyboardHandler) shouldPassthroughPageModeActivation(action Action, mode Mode) bool {
840+ if action != ActionEnterPageMode || mode != ModeNormal {
841+ return false
842+ }
843+ h.mu.RLock()
844+ passthrough := h.pageModeActivationPassthrough
845+ h.mu.RUnlock()
846+ return passthrough != nil && passthrough()
847+}
848+
607 // DispatchAction processes an action externally triggered (e.g., by a global849 // DispatchAction processes an action externally triggered (e.g., by a global
608 // shortcut). Mode-enter actions update modal state; other actions are forwarded850 // shortcut). Mode-enter actions update modal state; other actions are forwarded
609-// to the registered action handler.851+// to the registered action handler. It returns whether the action was consumed.
610-func (h *KeyboardHandler) DispatchAction(action Action) {852+func (h *KeyboardHandler) DispatchAction(action Action) bool {
611 mode := h.modal.Mode()853 mode := h.modal.Mode()
854+ if h.shouldPassthroughPageModeActivation(action, mode) {
855+ return false
856+ }
612 h.dispatchAction(action, mode)857 h.dispatchAction(action, mode)
858+ return true
613 }859 }
614 860
615 // handleKeyRelease processes a key release event for accent detection.861 // handleKeyRelease processes a key release event for accent detection.
616 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {862 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
863+ keyval = normalizeKeyval(keyval)
617 h.mu.Lock()864 h.mu.Lock()
618 accentHandler := h.accentHandler865 accentHandler := h.accentHandler
619 if len(h.activePressedActions) > 0 {866 if len(h.activePressedActions) > 0 {
620- keyval = normalizeKeyval(keyval)
621 for action, pressedKeyval := range h.activePressedActions {867 for action, pressedKeyval := range h.activePressedActions {
622 if pressedKeyval == keyval {868 if pressedKeyval == keyval {
623 delete(h.activePressedActions, action)869 delete(h.activePressedActions, action)
624 }870 }
625 }871 }
626 }872 }
873+ stopPageScroll := h.pageScrollRepeatKeyval == keyval
627 h.mu.Unlock()874 h.mu.Unlock()
875+ if stopPageScroll {
876+ h.stopPageScrollRepeat()
877+ }
628 878
629 if accentHandler == nil {879 if accentHandler == nil {
630 return880 return
M internal/ui/input/keyboard_test.go +581 -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,578 @@ 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+ h.pageScrollRepeatAdd = func(_ uint, cb *glib.SourceFunc) uint {
422+ repeatTick = *cb
423+ return 1
424+ }
425+ h.pageScrollRepeatRemove = func(uint) bool { return true }
426+
427+ // Repeated scroll actions should stay in page mode and transition onto the
428+ // smooth repeater instead of depending on every physical key repeat.
429+ for i := 0; i < 2; i++ {
430+ scrolled := h.handleKeyPress(uint('j'), 0, 0)
431+ if !scrolled {
432+ t.Fatalf("scroll down iteration %d: key not consumed", i)
433+ }
434+ if h.Mode() != ModePage {
435+ t.Fatalf("scroll down iteration %d: mode = %v, want ModePage", i, h.Mode())
436+ }
437+ }
438+ if repeatTick == nil {
439+ t.Fatal("expected held page scroll to register smooth repeater")
440+ }
441+ for i := 0; i < 3; i++ {
442+ if !repeatTick(0) {
443+ t.Fatalf("repeat tick %d stopped unexpectedly", i)
444+ }
445+ if h.Mode() != ModePage {
446+ t.Fatalf("repeat tick %d: mode = %v, want ModePage", i, h.Mode())
447+ }
448+ }
449+ h.handleKeyRelease(uint('j'))
450+
451+ // Scroll up also stays in page mode
452+ result = h.handleKeyPress(uint('k'), 0, 0)
453+ if !result || h.Mode() != ModePage {
454+ t.Fatal("scroll up should stay in page mode")
455+ }
456+
457+ // Ensure scroll actions were dispatched
458+ if actionCalls != 6 {
459+ t.Fatalf("action calls = %d, want 6 (5 down + 1 up)", actionCalls)
460+ }
461+}
462+
463+func TestHandleKeyPress_PageModeTimeoutReset(t *testing.T) {
464+ ctx := context.Background()
465+ workspace := newTestWorkspace()
466+ workspace.PageMode = entity.PageModeConfig{
467+ ActivationShortcut: "ctrl+y",
468+ TimeoutMilliseconds: 100,
469+ Actions: map[string]entity.ActionBinding{
470+ "page-scroll-down": {Keys: []string{"j"}},
471+ "cancel": {Keys: []string{"escape"}},
472+ },
473+ }
474+
475+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
476+ timerStarts := 0
477+ h.modal.afterFunc = func(d time.Duration, fn func()) modalTimer {
478+ timerStarts++
479+ return &fakeModalTimer{}
480+ }
481+ h.SetOnAction(func(ctx context.Context, action Action) error {
482+ return nil
483+ })
484+
485+ // Enter page mode with timeout.
486+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
487+ if h.Mode() != ModePage {
488+ t.Fatal("failed to enter page mode")
489+ }
490+ if timerStarts != 1 {
491+ t.Fatalf("timer starts after entering page mode = %d, want 1", timerStarts)
492+ }
493+
494+ // Scroll action should reset the timeout by starting a replacement timer.
495+ h.handleKeyPress(uint('j'), 0, 0)
496+
497+ if h.Mode() != ModePage {
498+ t.Fatalf("mode after scroll = %v, want ModePage", h.Mode())
499+ }
500+ if timerStarts != 2 {
501+ t.Fatalf("timer starts after scroll reset = %d, want 2", timerStarts)
502+ }
503+}
504+
505+func TestHandleKeyPress_PageModeEscapeExits(t *testing.T) {
506+ ctx := context.Background()
507+ workspace := newTestWorkspace()
508+ workspace.PageMode = entity.PageModeConfig{
509+ ActivationShortcut: "ctrl+y",
510+ Actions: map[string]entity.ActionBinding{
511+ "page-scroll-down": {Keys: []string{"j"}},
512+ "cancel": {Keys: []string{"escape"}},
513+ },
514+ }
515+
516+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
517+ h.SetOnAction(func(ctx context.Context, action Action) error {
518+ return nil
519+ })
520+
521+ // Enter page mode
522+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
523+ if h.Mode() != ModePage {
524+ t.Fatal("failed to enter page mode")
525+ }
526+
527+ // Escape should exit page mode
528+ result := h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0)
529+ if !result {
530+ t.Fatal("Escape should be consumed")
531+ }
532+ if h.Mode() != ModeNormal {
533+ t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode())
534+ }
535+}
536+
537+func TestHandleKeyPress_PageModeEnterExits(t *testing.T) {
538+ ctx := context.Background()
539+ workspace := newTestWorkspace()
540+ workspace.PageMode = entity.PageModeConfig{
541+ ActivationShortcut: "ctrl+y",
542+ Actions: map[string]entity.ActionBinding{
543+ "confirm": {Keys: []string{"enter"}},
544+ },
545+ }
546+
547+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
548+ h.SetOnAction(func(ctx context.Context, action Action) error {
549+ return nil
550+ })
551+
552+ // Enter page mode
553+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
554+ if h.Mode() != ModePage {
555+ t.Fatal("failed to enter page mode")
556+ }
557+
558+ // Enter should exit page mode
559+ result := h.handleKeyPress(uint(gdk.KEY_Return), 0, 0)
560+ if !result {
561+ t.Fatal("Enter should be consumed")
562+ }
563+ if h.Mode() != ModeNormal {
564+ t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode())
565+ }
566+}
567+
568+func TestHandleKeyPress_PageModeActivationTogglesOff(t *testing.T) {
569+ ctx := context.Background()
570+ workspace := newTestWorkspace()
571+ workspace.PageMode = entity.PageModeConfig{
572+ ActivationShortcut: "ctrl+y",
573+ }
574+
575+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
576+
577+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
578+ t.Fatal("first Ctrl+Y should be consumed")
579+ }
580+ if h.Mode() != ModePage {
581+ t.Fatalf("mode after first Ctrl+Y = %v, want ModePage", h.Mode())
582+ }
583+
584+ h.handleKeyRelease(uint('y'))
585+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
586+ t.Fatal("second Ctrl+Y should be consumed")
587+ }
588+ if h.Mode() != ModeNormal {
589+ t.Fatalf("mode after second Ctrl+Y = %v, want ModeNormal", h.Mode())
590+ }
591+}
592+
593+func TestHandleKeyPress_PageModeEscapeExitsWithoutCancelBinding(t *testing.T) {
594+ ctx := context.Background()
595+ workspace := newTestWorkspace()
596+ workspace.PageMode = entity.PageModeConfig{
597+ ActivationShortcut: "ctrl+y",
598+ Actions: map[string]entity.ActionBinding{
599+ "page-scroll-down": {Keys: []string{"j"}},
600+ },
601+ }
602+
603+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
604+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
605+ t.Fatal("Ctrl+Y should be consumed")
606+ }
607+ if h.Mode() != ModePage {
608+ t.Fatal("failed to enter page mode")
609+ }
610+
611+ if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
612+ t.Fatal("Escape should still be consumed without explicit cancel binding")
613+ }
614+ if h.Mode() != ModeNormal {
615+ t.Fatalf("mode after Escape fallback = %v, want ModeNormal", h.Mode())
616+ }
617+}
618+
619+func TestHandleKeyPress_PageModeActivationNotInPassThrough(t *testing.T) {
620+ ctx := context.Background()
621+ workspace := newTestWorkspace()
622+ workspace.PageMode = entity.PageModeConfig{
623+ ActivationShortcut: "ctrl+y",
624+ }
625+
626+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
627+
628+ // Route ALL keys to widget (editable pass-through context)
629+ h.SetRouteKey(func(kc KeyContext) KeyRoute {
630+ return RoutePassToWidget
631+ })
632+
633+ // Ctrl+Y should NOT activate page mode because route says pass to widget
634+ result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
635+ if result {
636+ t.Fatal("Ctrl+Y should NOT be consumed when routed to widget")
637+ }
638+ if h.Mode() != ModeNormal {
639+ t.Fatalf("mode = %v, want ModeNormal (should not enter page mode)", h.Mode())
640+ }
641+}
642+
643+func TestHandleKeyPress_PageModeActivationPassesThroughWhenBlocked(t *testing.T) {
644+ ctx := context.Background()
645+ workspace := newTestWorkspace()
646+ workspace.PageMode = entity.PageModeConfig{
647+ ActivationShortcut: "ctrl+y",
648+ }
649+
650+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
651+ h.SetPageModeActivationPassthrough(func() bool { return true })
652+
653+ result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
654+ if result {
655+ t.Fatal("Ctrl+Y should pass through when page mode activation is blocked")
656+ }
657+ if h.Mode() != ModeNormal {
658+ t.Fatalf("mode = %v, want ModeNormal", h.Mode())
659+ }
660+}
661+
662+func TestDispatchAction_PageModeActivationPassesThroughWhenBlocked(t *testing.T) {
663+ ctx := context.Background()
664+ workspace := newTestWorkspace()
665+ workspace.PageMode = entity.PageModeConfig{
666+ ActivationShortcut: "ctrl+y",
667+ }
668+
669+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
670+ h.SetPageModeActivationPassthrough(func() bool { return true })
671+
672+ consumed := h.DispatchAction(ActionEnterPageMode)
673+ if consumed {
674+ t.Fatal("DispatchAction should not consume blocked page mode activation")
675+ }
676+ if h.Mode() != ModeNormal {
677+ t.Fatalf("mode = %v, want ModeNormal", h.Mode())
678+ }
679+}
680+
681+func TestHandleKeyPress_PageModeFastScrollLookup(t *testing.T) {
682+ tests := []struct {
683+ name string
684+ configAction string
685+ key uint
686+ expected Action
687+ }{
688+ {
689+ name: "scroll down fast",
690+ configAction: "page-scroll-down-fast",
691+ key: uint('d'),
692+ expected: ActionPageScrollDownFast,
693+ },
694+ {
695+ name: "scroll up fast",
696+ configAction: "page-scroll-up-fast",
697+ key: uint('u'),
698+ expected: ActionPageScrollUpFast,
699+ },
700+ }
701+
702+ for _, tt := range tests {
703+ t.Run(tt.name, func(t *testing.T) {
704+ ctx := context.Background()
705+ workspace := newTestWorkspace()
706+ workspace.PageMode = entity.PageModeConfig{
707+ ActivationShortcut: "ctrl+y",
708+ Actions: map[string]entity.ActionBinding{
709+ tt.configAction: {Keys: []string{"ctrl+" + string(rune(tt.key))}},
710+ },
711+ }
712+
713+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
714+ actionCalls := 0
715+ var lastAction Action
716+ h.SetOnAction(func(ctx context.Context, action Action) error {
717+ actionCalls++
718+ lastAction = action
719+ return nil
720+ })
721+
722+ // Enter page mode.
723+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
724+ if h.Mode() != ModePage {
725+ t.Fatal("failed to enter page mode")
726+ }
727+
728+ // Fast-scroll shortcut in page mode should dispatch the expected action.
729+ result := h.handleKeyPress(tt.key, 0, gdk.ControlMaskValue)
730+ if !result {
731+ t.Fatal("fast scroll shortcut in page mode should be consumed")
732+ }
733+ if actionCalls != 1 || lastAction != tt.expected {
734+ t.Fatalf("expected %s, got action=%s calls=%d", tt.expected, lastAction, actionCalls)
735+ }
736+ if h.Mode() != ModePage {
737+ t.Fatal("should stay in page mode after scroll action")
738+ }
739+ })
740+ }
741+}
742+
743+func TestHandleKeyPress_PageModeNoAutoExitForScrollActions(t *testing.T) {
744+ ctx := context.Background()
745+ workspace := newTestWorkspace()
746+ workspace.PageMode = entity.PageModeConfig{
747+ ActivationShortcut: "ctrl+y",
748+ Actions: map[string]entity.ActionBinding{
749+ "page-scroll-left": {Keys: []string{"h"}},
750+ "page-scroll-right": {Keys: []string{"l"}},
751+ },
752+ }
753+
754+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
755+ actionCalls := 0
756+ h.SetOnAction(func(ctx context.Context, action Action) error {
757+ actionCalls++
758+ return nil
759+ })
760+
761+ // Enter page mode
762+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
763+ if h.Mode() != ModePage {
764+ t.Fatal("failed to enter page mode")
765+ }
766+
767+ // Scroll left should not auto-exit
768+ h.handleKeyPress(uint('h'), 0, 0)
769+ if h.Mode() != ModePage {
770+ t.Fatal("scroll left should not exit page mode")
771+ }
772+
773+ // Scroll right should not auto-exit
774+ h.handleKeyPress(uint('l'), 0, 0)
775+ if h.Mode() != ModePage {
776+ t.Fatal("scroll right should not exit page mode")
777+ }
778+
779+ if actionCalls != 2 {
780+ t.Fatalf("action calls = %d, want 2", actionCalls)
781+ }
782+}
783+
784+func TestHandleKeyPress_PageModeArrowKeysPassThroughNatively(t *testing.T) {
785+ ctx := context.Background()
786+ workspace := newTestWorkspace()
787+ workspace.PageMode = entity.PageModeConfig{
788+ ActivationShortcut: "ctrl+y",
789+ Actions: map[string]entity.ActionBinding{
790+ "page-scroll-down": {Keys: []string{"j"}},
791+ },
792+ }
793+
794+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
795+ actionCalls := 0
796+ h.SetOnAction(func(ctx context.Context, action Action) error {
797+ actionCalls++
798+ return nil
799+ })
800+
801+ h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
802+ if h.Mode() != ModePage {
803+ t.Fatal("failed to enter page mode")
804+ }
805+
806+ for _, keyval := range []uint{uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down)} {
807+ consumed := h.handleKeyPress(keyval, 0, 0)
808+ if consumed {
809+ t.Fatalf("arrow key %d should pass through to native page handling in page mode", keyval)
810+ }
811+ if h.Mode() != ModePage {
812+ t.Fatalf("arrow key %d should keep page mode active", keyval)
813+ }
814+ }
815+
816+ if actionCalls != 0 {
817+ t.Fatalf("arrow key passthrough should not dispatch page-mode action, got %d calls", actionCalls)
818+ }
819+}
820+
821+func TestHandleKeyPress_PageModeBlocksGlobalShortcutFallback(t *testing.T) {
822+ ctx := context.Background()
823+ workspace := newTestWorkspace()
824+ workspace.PageMode = entity.PageModeConfig{
825+ ActivationShortcut: "ctrl+y",
826+ Actions: map[string]entity.ActionBinding{
827+ "page-scroll-down": {Keys: []string{"j"}},
828+ },
829+ }
830+
831+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
832+ actionCalls := 0
833+ var lastAction Action
834+ h.SetOnAction(func(ctx context.Context, action Action) error {
835+ actionCalls++
836+ lastAction = action
837+ return nil
838+ })
839+
840+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
841+ t.Fatal("Ctrl+Y should be consumed")
842+ }
843+ if h.Mode() != ModePage {
844+ t.Fatal("failed to enter page mode")
845+ }
846+
847+ if !h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue) {
848+ t.Fatal("Ctrl+L should be consumed inside page mode")
849+ }
850+ if actionCalls != 0 {
851+ t.Fatalf("Ctrl+L should not dispatch a global action in page mode, got %d calls (%s)", actionCalls, lastAction)
852+ }
853+ if h.Mode() != ModePage {
854+ t.Fatalf("mode after blocked Ctrl+L = %v, want ModePage", h.Mode())
855+ }
856+}
857+
858+func TestHandleKeyPress_PageModeSmoothRepeaterStartsAfterHeldRepeat(t *testing.T) {
859+ ctx := context.Background()
860+ workspace := newTestWorkspace()
861+ workspace.PageMode = entity.PageModeConfig{
862+ ActivationShortcut: "ctrl+y",
863+ Actions: map[string]entity.ActionBinding{
864+ "page-scroll-down": {Keys: []string{"j"}},
865+ },
866+ }
867+
868+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
869+ var repeatTick glib.SourceFunc
870+ var removedTimer uint
871+ h.pageScrollRepeatAdd = func(intervalMS uint, cb *glib.SourceFunc) uint {
872+ if intervalMS != uint(pageScrollRepeatInterval/time.Millisecond) {
873+ t.Fatalf("repeat interval = %dms, want %dms", intervalMS, uint(pageScrollRepeatInterval/time.Millisecond))
874+ }
875+ repeatTick = *cb
876+ return 99
877+ }
878+ h.pageScrollRepeatRemove = func(id uint) bool {
879+ removedTimer = id
880+ return true
881+ }
882+
883+ actionCalls := 0
884+ h.SetOnAction(func(ctx context.Context, action Action) error {
885+ actionCalls++
886+ if action != ActionPageScrollDown {
887+ t.Fatalf("action = %s, want %s", action, ActionPageScrollDown)
888+ }
889+ return nil
890+ })
891+
892+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
893+ t.Fatal("Ctrl+Y should be consumed")
894+ }
895+ if !h.handleKeyPress(uint('j'), 0, 0) {
896+ t.Fatal("first J press should be consumed")
897+ }
898+ if actionCalls != 1 {
899+ t.Fatalf("actionCalls after first press = %d, want 1", actionCalls)
900+ }
901+ if h.pageScrollRepeatTimer != 0 {
902+ t.Fatal("smooth repeater should not start before the first held repeat arrives")
903+ }
904+
905+ if !h.handleKeyPress(uint('j'), 0, 0) {
906+ t.Fatal("held J repeat should be consumed")
907+ }
908+ if actionCalls != 2 {
909+ t.Fatalf("actionCalls after held repeat = %d, want 2", actionCalls)
910+ }
911+ if h.pageScrollRepeatTimer != 99 {
912+ t.Fatalf("pageScrollRepeatTimer = %d, want 99", h.pageScrollRepeatTimer)
913+ }
914+ if repeatTick == nil {
915+ t.Fatal("smooth repeater callback should be registered")
916+ }
917+
918+ if !repeatTick(0) {
919+ t.Fatal("smooth repeater tick should continue while key remains held")
920+ }
921+ if actionCalls != 3 {
922+ t.Fatalf("actionCalls after smooth repeater tick = %d, want 3", actionCalls)
923+ }
924+
925+ if !h.handleKeyPress(uint('j'), 0, 0) {
926+ t.Fatal("subsequent physical repeats should remain consumed")
927+ }
928+ if actionCalls != 3 {
929+ t.Fatalf("physical repeats should defer to smooth repeater; got %d calls", actionCalls)
930+ }
931+
932+ h.handleKeyRelease(uint('j'))
933+ if removedTimer != 99 {
934+ t.Fatalf("removed timer id = %d, want 99", removedTimer)
935+ }
936+ if h.pageScrollRepeatTimer != 0 {
937+ t.Fatal("smooth repeater should stop on key release")
938+ }
939+ if repeatTick(0) {
940+ t.Fatal("smooth repeater tick should stop after key release")
941+ }
942+ if actionCalls != 3 {
943+ t.Fatalf("no extra actions expected after release, got %d", actionCalls)
944+ }
945+}
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