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:
5959 SessionSpawner: {}
6060 WindowStateProvider: {}
6161 WebView: {}
62+ PageScrollable: {}
6263 WebViewFactory: {}
6364 WebViewPool: {}
6465 MemoryPressureApplier: {}
M README.md +13 -2
@@ -24,7 +24,7 @@ Dumber uses three layout levels:
2424 - **Workspaces** hold a layout of panes.
2525 - **Panes** display web pages and can be split, stacked, moved, resized, or closed.
2626
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.
2828
2929 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.
3030
@@ -46,7 +46,7 @@ The browser chrome stays out of the way by default. Open the omnibox when you wa
4646
4747 ### Keyboard workflow
4848
49-- Pane, tab, resize, and session modes
49+- Pane, tab, page, resize, and session modes
5050 - Vim/Zellij-style navigation patterns
5151 - Search bangs such as `!g`, `!gi`, and `!ddg`
5252 - Launcher integration for `rofi`, `fuzzel`, and `dmenu`
@@ -99,6 +99,7 @@ Dumber uses modal keybindings for browser management.
9999 |------|-------------|----------|
100100 | Pane | `Ctrl+P` | Split, stack, close, and move panes |
101101 | 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 |
102103 | Resize | `Ctrl+N` | Resize panes with `hjkl` or arrow keys |
103104 | Session | `Ctrl+O` | Snapshot, restore, and browse sessions |
104105
@@ -166,6 +167,16 @@ WebKit can be selected explicitly:
166167 type = "webkit"
167168 ```
168169
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+
169180 ### Rendering notes
170181
171182 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"]
398398
399399 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
400400
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+
401453 ### Resize Mode
402454
403455 | Key | Type | Default | Description |
@@ -529,12 +581,12 @@ desc = "Open floating pane on GitHub"
529581 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
530582 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
531583 | `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 |
533585 | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
534586 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
535587 | `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 |
537-| `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
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 |
589+| `workspace.styling.transition_duration` | int | `120` | Border and pulse transition duration (ms) |
538590
539591 **Example:**
540592 ```toml
@@ -542,7 +594,7 @@ desc = "Open floating pane on GitHub"
542594 border_width = 1
543595 border_color = "@theme_selected_bg_color"
544596 mode_border_width = 4
545-pane_mode_color = "#4A90E2" # Blue for pane mode
597+pane_mode_color = "#4A90E2" # Blue for pane mode and Page mode local accent
546598 tab_mode_color = "#FFA500" # Orange for tab mode
547599 session_mode_color = "#9B59B6" # Purple for session mode
548600 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:
1616 |------|------------|---------|
1717 | Pane Mode | `Ctrl+P` | Split, close, focus panes |
1818 | 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 |
1920 | Resize Mode | `Ctrl+N` | Resize pane splits |
2021 | Session Mode | `Ctrl+O` | Session management |
2122
2223 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.
2325
2426 ## Pane Mode Quick Reference
2527
@@ -28,6 +30,20 @@ Press `Escape` or `Enter` to exit any mode.
2830 3. `Shift+arrows` to focus adjacent panes
2931 4. `X` to close current pane
3032
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+
3147 ## Omnibox
3248
3349 Press `Ctrl+L` to open the omnibox for:
M docs/reference/configuration.md +3 -0
@@ -98,6 +98,9 @@
9898 | `workspace.tab_mode.activation_shortcut` | string | `ctrl+t` | |
9999 | `workspace.tab_mode.timeout_ms` | int | `3000` | |
100100 | `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 |
101104 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |
102105 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
103106 | `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
88 |------|-----|---------|
99 | Pane Mode | `Ctrl+P` | Split, close, focus panes |
1010 | 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 |
1112 | Resize Mode | `Ctrl+N` | Resize pane splits |
1213 | Session Mode | `Ctrl+O` | Session management |
1314
1415 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.
1517
1618 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.
1719
@@ -51,6 +53,25 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke
5153 | Confirm | `Enter` |
5254 | Cancel | `Escape` |
5355
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+
5475 ## Resize Mode (`Ctrl+N`)
5576
5677 | Action | Keys |
@@ -118,6 +139,28 @@ All keybindings can be customized in `~/.config/dumber/config.toml`:
118139 split-right = ["arrowright", "r"]
119140 close-pane = ["x", "q"]
120141
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+
121164 [workspace.shortcuts.actions.close-pane]
122165 keys = ["ctrl+w"]
123166
M internal/application/port/mocks/webview.go +84 -0
@@ -3112,3 +3112,87 @@ func (_c *MockTextInputTargetProvider_TextInputTarget_Call) RunAndReturn(run fun
31123112 _c.Call.Return(run)
31133113 return _c
31143114 }
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 {
142142 // OnTouchpadNavigationGesture is called while a two-finger touchpad history
143143 // navigation gesture is progressing or finishing.
144144 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)
145149 }
146150
147151 // FindOptions configures search behavior.
@@ -390,3 +394,36 @@ type OAuthCallbackCapable interface {
390394 type TextInputTargetProvider interface {
391395 TextInputTarget() TextInputTarget
392396 }
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 {
7878 return keyBindingsFromActions(t.Actions)
7979 }
8080
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+
8193 // ResizeModeConfig holds resize mode shortcut configuration.
8294 type ResizeModeConfig struct {
8395 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 {
202214 NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
203215 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
204216 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"`
205218 ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
206219 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
207220 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 (
2121 "github.com/bnema/dumber/internal/application/dto"
2222 "github.com/bnema/dumber/internal/application/port"
2323 "github.com/bnema/dumber/internal/domain/entity"
24+ "github.com/bnema/dumber/internal/infrastructure/webutil"
2425 "github.com/bnema/dumber/internal/logging"
2526 "github.com/bnema/dumber/internal/shared/syncdispatch"
2627 )
@@ -34,6 +35,7 @@ var (
3435 _ port.PopupOpenerCapable = (*WebView)(nil)
3536 _ port.ViewportSyncCapable = (*WebView)(nil)
3637 _ port.OAuthCallbackCapable = (*WebView)(nil)
38+ _ port.PageScrollable = (*WebView)(nil)
3739 )
3840
3941 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -1423,23 +1425,28 @@ func (wv *WebView) setEditableFocus(editable bool) {
14231425 wv.mu.Lock()
14241426 previous := wv.focusedEditable
14251427 wv.focusedEditable = editable
1428+ cb := wv.callbacks
1429+ var timer stoppableTimer
14261430 if editable {
14271431 wv.selectionDebounceSeq++
1428- timer := wv.selectionDebounceTimer
1432+ timer = wv.selectionDebounceTimer
14291433 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
14381434 }
14391435 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 {
14411443 logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
14421444 }
1445+ if cb != nil && cb.OnEditableFocusChanged != nil {
1446+ wv.runOnGTK(func() {
1447+ cb.OnEditableFocusChanged(editable)
1448+ })
1449+ }
14431450 }
14441451
14451452 func (wv *WebView) cancelSelectionDebounce() {
@@ -2368,6 +2375,56 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
23682375 return pc
23692376 }
23702377
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+
23712428 // beginAudioStreamStart invalidates any prior stream before creating a new
23722429 // one. A stream created for the returned generation may only be published if
23732430 // 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
472472 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
473473 Msg("cef: OnLoadStart")
474474 }
475+ h.wv.setEditableFocus(false)
475476 h.wv.updateURI(frame.GetURL())
476477 h.wv.mu.RLock()
477478 cb := h.wv.callbacks
M internal/infrastructure/cef/webview_handlers_test.go +34 -0
@@ -544,6 +544,40 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
544544 require.Equal(t, "free selection", recorder.selection.Text)
545545 }
546546
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+
547581 func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) {
548582 called := false
549583 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 (
4444 defaultTabTimeoutMilliseconds = 3000
4545 defaultResizeActivationShortcut = "ctrl+n"
4646 defaultResizeTimeoutMilliseconds = 3000
47+ defaultPageActivationShortcut = "ctrl+y"
48+ defaultPageTimeoutMilliseconds = 0
4749 defaultResizeStepPercent = 5.0
4850 defaultResizeMinPanePercent = 10.0
4951 defaultTabBarPosition = "bottom"
@@ -300,6 +302,20 @@ func DefaultConfig() *Config {
300302 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
301303 },
302304 },
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+ },
303319 ResizeMode: ResizeModeConfig{
304320 ActivationShortcut: defaultResizeActivationShortcut,
305321 TimeoutMilliseconds: defaultResizeTimeoutMilliseconds,
M internal/infrastructure/config/keybindings_gateway.go +23 -0
@@ -32,6 +32,7 @@ func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.Keybindin
3232 g.buildGlobalGroup(cfg, defaults),
3333 g.buildPaneModeGroup(cfg, defaults),
3434 g.buildTabModeGroup(cfg, defaults),
35+ g.buildPageModeGroup(cfg, defaults),
3536 g.buildResizeModeGroup(cfg, defaults),
3637 g.buildSessionModeGroup(cfg, defaults),
3738 }
@@ -48,6 +49,7 @@ func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.Ke
4849 g.buildGlobalGroup(defaults, defaults),
4950 g.buildPaneModeGroup(defaults, defaults),
5051 g.buildTabModeGroup(defaults, defaults),
52+ g.buildPageModeGroup(defaults, defaults),
5153 g.buildResizeModeGroup(defaults, defaults),
5254 g.buildSessionModeGroup(defaults, defaults),
5355 }
@@ -108,6 +110,7 @@ func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error {
108110
109111 cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions
110112 cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions
113+ cfg.Workspace.PageMode.Actions = defaults.Workspace.PageMode.Actions
111114 cfg.Workspace.ResizeMode.Actions = defaults.Workspace.ResizeMode.Actions
112115 cfg.Workspace.Shortcuts = defaults.Workspace.Shortcuts
113116 cfg.Session.SessionMode.Actions = defaults.Session.SessionMode.Actions
@@ -144,6 +147,16 @@ func (g *KeybindingsGateway) buildTabModeGroup(cfg, defaults *Config) port.Keybi
144147 }
145148 }
146149
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+
147160 // buildResizeModeGroup builds the resize mode group.
148161 func (g *KeybindingsGateway) buildResizeModeGroup(cfg, defaults *Config) port.KeybindingGroup {
149162 return port.KeybindingGroup{
@@ -208,6 +221,11 @@ func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingR
208221 existing.Keys = req.Keys
209222 cfg.Workspace.TabMode.Actions[req.Action] = existing
210223 }
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+ }
211229 case "resize":
212230 if existing, ok := cfg.Workspace.ResizeMode.Actions[req.Action]; ok {
213231 existing.Keys = req.Keys
@@ -236,6 +254,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string)
236254 if binding, ok := defaults.Workspace.TabMode.Actions[action]; ok {
237255 return binding.Keys
238256 }
257+ case "page":
258+ if binding, ok := defaults.Workspace.PageMode.Actions[action]; ok {
259+ return binding.Keys
260+ }
239261 case "resize":
240262 if binding, ok := defaults.Workspace.ResizeMode.Actions[action]; ok {
241263 return binding.Keys
@@ -269,6 +291,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction
269291 addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)
270292 addBindings("pane", cfg.Workspace.PaneMode.Actions)
271293 addBindings("tab", cfg.Workspace.TabMode.Actions)
294+ addBindings("page", cfg.Workspace.PageMode.Actions)
272295 addBindings("resize", cfg.Workspace.ResizeMode.Actions)
273296 addBindings("session", cfg.Session.SessionMode.Actions)
274297
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) {
695695 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
696696 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
697697 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)
698701 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
699702 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
700703 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 {
299299 {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
300300 {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
301301 {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
302+ {key: "workspace.page_mode.actions", actions: m.defaultConfig.Workspace.PageMode.Actions},
302303 {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},
303304 {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},
304305 }
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
2424 // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
2525 type ResizeModeConfig = entity.ResizeModeConfig
2626
27+// PageModeConfig defines modal behavior for page scrolling.
28+type PageModeConfig = entity.PageModeConfig
29+
2730 // SessionModeConfig defines modal behavior for session management.
2831 type SessionModeConfig = entity.SessionModeConfig
2932
M internal/infrastructure/config/schema_provider.go +23 -0
@@ -472,6 +472,29 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
472472 Description: "Key bindings for tab mode actions",
473473 Section: SectionWorkspace,
474474 },
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+ },
475498 // Resize mode
476499 {
477500 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) {
146146 "search_shortcuts": "search_shortcuts.<key>",
147147 "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>",
148148 "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>",
149+ "workspace.page_mode.actions": "workspace.page_mode.actions.<action>",
149150 "workspace.resize_mode.actions": "workspace.resize_mode.actions.<action>",
150151 "workspace.floating_pane.profiles": "workspace.floating_pane.profiles.<name>",
151152 "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 {
2828 validationErrors = append(validationErrors, validatePaneMode(config)...)
2929 validationErrors = append(validationErrors, validateTabBar(config)...)
3030 validationErrors = append(validationErrors, validateTabMode(config)...)
31+ validationErrors = append(validationErrors, validatePageMode(config)...)
3132 validationErrors = append(validationErrors, validateFloatingPane(config)...)
3233 validationErrors = append(validationErrors, validateLogging(config)...)
3334 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
@@ -268,6 +269,35 @@ func validateTabMode(config *Config) []string {
268269 return validationErrors
269270 }
270271
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+
271301 func validateFloatingPane(config *Config) []string {
272302 var validationErrors []string
273303 seenProfileKeys := make(map[string]string)
M internal/infrastructure/webkit/injector.go +58 -17
@@ -182,28 +182,33 @@ const explicitCopyScript = `(function() {
182182 // accentDetectionScript is built at init from entity.AccentMap so the JS
183183 // filter stays in sync with the Go-side accent table.
184184 var accentDetectionScript string
185+var accentDetectionScriptKeys string
185186
186187 func buildExplicitCopyScript() string {
187188 return explicitCopyScript
188189 }
189190
190-func init() {
191- // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"
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() {
191+func buildAccentDetectionScript(token string) string {
192+ return fmt.Sprintf(`(function() {
199193 'use strict';
200194 const accentKeys = new Set([%s]);
195+ const editableFocusToken = %q;
201196 let pressedKey = null;
202197
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+
203206 function isEditableTarget(target) {
204207 const el = target instanceof Element ? target : null;
205208 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;
207212 if (el.closest('textarea')) return true;
208213 const input = el.closest('input');
209214 if (!input) return false;
@@ -234,12 +239,44 @@ func init() {
234239 }
235240 }, true);
236241
237- document.addEventListener('focusin', function(e) {
238- if (isEditableTarget(e.target)) {
239- window.__dumber_lastEditableEl = e.target;
240- }
241- }, true);
242-})();`, strings.Join(keys, ","))
242+ document.addEventListener('focusin', function(e) {
243+ if (e && e.isTrusted === false) return;
244+ if (isEditableTarget(e.target)) {
245+ window.__dumber_lastEditableEl = e.target;
246+ postEditableFocus(true);
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("")
243280 }
244281
245282 // accentDetectionInjectionMode controls which frames receive the accent detection script.
@@ -449,9 +486,13 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
449486
450487 // 8. Inject accent key detection for all pages and all frames (unconditional).
451488 // 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+ }
452493 addScript(
453494 webkit.NewUserScript(
454- accentDetectionScript,
495+ accentScript,
455496 accentDetectionInjectionMode,
456497 webkit.UserScriptInjectAtDocumentEndValue,
457498 nil, // all pages
M internal/infrastructure/webkit/injector_accent_test.go +14 -0
@@ -29,6 +29,20 @@ func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) {
2929 "document.addEventListener('focusin'",
3030 "accent detection script must register a focusin listener",
3131 )
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`)
3246 }
3347
3448 func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
M internal/infrastructure/webkit/messaging.go +66 -26
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/dumber/internal/logging"
1212 "github.com/bnema/puregotk/v4/javascriptcore"
1313 "github.com/bnema/puregotk/v4/webkit"
14+ "github.com/rs/zerolog"
1415 )
1516
1617 // Message is the JS -> Go message envelope sent via postMessage.
@@ -187,32 +188,8 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
187188
188189 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry")
189190
190- if valuePtr == 0 {
191- log.Warn().Msg("received script message with nil value pointer")
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")
191+ msg, ok := decodeScriptMessage(valuePtr, log)
192+ if !ok {
216193 return
217194 }
218195
@@ -221,6 +198,9 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
221198 log.Warn().Msg("rejecting script message from unknown sender")
222199 return
223200 }
201+ if r.handleAllowlistedBridgeMessage(senderWV, msg) {
202+ return
203+ }
224204 if !isTrustedBridgeWebView(senderWV) {
225205 log.Warn().
226206 Str("type", msg.Type).
@@ -279,6 +259,66 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager,
279259 }
280260 }
281261
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+
282322 func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
283323 ptr := senderUCM.GoPointer()
284324 if ptr == 0 {
M internal/infrastructure/webkit/messaging_test.go +51 -0
@@ -2,9 +2,11 @@ package webkit
22
33 import (
44 "context"
5+ "encoding/json"
56 "sync"
67 "testing"
78
9+ "github.com/bnema/dumber/internal/application/port"
810 "github.com/stretchr/testify/require"
911 )
1012
@@ -35,6 +37,55 @@ func TestMessageRouterBaseContextConcurrentUpdate(t *testing.T) {
3537 wg.Wait()
3638 }
3739
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+
3889 func TestIsTrustedBridgeURI(t *testing.T) {
3990 t.Parallel()
4091
M internal/infrastructure/webkit/pool.go +5 -0
@@ -179,6 +179,11 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
179179
180180 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
181181 _ = 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+ }
182187 // Apply filters if available (may not have been applied during prewarm)
183188 if p.filterApplier != nil {
184189 p.filterApplier.ApplyTo(ctx, wv.ucm)
M internal/infrastructure/webkit/webview.go +63 -13
@@ -2,6 +2,8 @@ package webkit
22
33 import (
44 "context"
5+ "crypto/rand"
6+ "encoding/hex"
57 "fmt"
68 "net/url"
79 "os"
@@ -16,6 +18,7 @@ import (
1618 "github.com/bnema/dumber/internal/domain/entity"
1719 urlutil "github.com/bnema/dumber/internal/domain/url"
1820 "github.com/bnema/dumber/internal/infrastructure/desktop"
21+ "github.com/bnema/dumber/internal/infrastructure/webutil"
1922 "github.com/bnema/dumber/internal/logging"
2023 "github.com/bnema/puregotk/v4/gdk"
2124 "github.com/bnema/puregotk/v4/gio"
@@ -34,6 +37,12 @@ var _ port.Printer = (*WebView)(nil)
3437 var _ port.PopupLifecycleCapable = (*WebView)(nil)
3538 var _ port.PopupFeatureResolver = (*WebView)(nil)
3639 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+}
3746
3847 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
3948 // Infrastructure layer uses the type defined in the application port.
@@ -163,6 +172,7 @@ type WebView struct {
163172 OnEnterFullscreen func() bool // Return true to prevent fullscreen
164173 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
165174 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
175+ OnEditableFocusChanged func(editable bool) // Called when page editable focus changes
166176 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
167177 OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
168178 browsingContextDecision dto.HostDecision
@@ -179,8 +189,9 @@ type WebView struct {
179189 logger zerolog.Logger
180190 mu sync.RWMutex
181191
182- frontendAttached atomic.Bool
183- navigationActive atomic.Bool
192+ frontendAttached atomic.Bool
193+ navigationActive atomic.Bool
194+ editableFocusBridgeToken string
184195
185196 // asyncCallbacks keeps references to async JS callbacks to prevent GC
186197 asyncCallbacks []any
@@ -212,8 +223,18 @@ const (
212223 runJSNonFatalLogInterval = 30 * time.Second
213224 runJSAggregateLogEvery = 20
214225 runJSUnknown = "unknown"
226+
227+ editableFocusBridgeTokenBytes = 16
215228 )
216229
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+
217238 type findControllerAdapter struct {
218239 fc *webkit.FindController
219240 }
@@ -346,11 +367,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
346367 }
347368
348369 wv := &WebView{
349- inner: inner,
350- ucm: inner.GetUserContentManager(),
351- logger: log.With().Str("component", "webview").Logger(),
352- signalIDs: make([]uintptr, 0, 4),
353- runJSErrorStats: make(map[string]runJSErrorStat),
370+ inner: inner,
371+ ucm: inner.GetUserContentManager(),
372+ logger: log.With().Str("component", "webview").Logger(),
373+ signalIDs: make([]uintptr, 0, 4),
374+ runJSErrorStats: make(map[string]runJSErrorStat),
375+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
354376 }
355377
356378 // Register in global registry
@@ -396,12 +418,13 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti
396418 Msg("related webview created, checking pointers")
397419
398420 wv := &WebView{
399- inner: inner,
400- isRelated: true, // Shares web process with parent - must not terminate process on destroy
401- ucm: inner.GetUserContentManager(),
402- logger: log.With().Str("component", "webview-popup").Logger(),
403- signalIDs: make([]uintptr, 0, 6),
404- runJSErrorStats: make(map[string]runJSErrorStat),
421+ inner: inner,
422+ isRelated: true, // Shares web process with parent - must not terminate process on destroy
423+ ucm: inner.GetUserContentManager(),
424+ logger: log.With().Str("component", "webview-popup").Logger(),
425+ signalIDs: make([]uintptr, 0, 6),
426+ runJSErrorStats: make(map[string]runJSErrorStat),
427+ editableFocusBridgeToken: newEditableFocusBridgeToken(),
405428 }
406429
407430 wv.id = globalRegistry.register(wv)
@@ -461,6 +484,7 @@ func (wv *WebView) connectLoadChangedSignal() {
461484 wv.navigationActive.Store(true)
462485 wv.isLoading = true
463486 wv.logger.Debug().Str("uri", uri).Msg("load started")
487+ wv.dispatchEditableFocusChanged(false)
464488 case webkit.LoadRedirectedValue:
465489 wv.logger.Debug().Str("uri", uri).Msg("load redirected")
466490 case webkit.LoadCommittedValue:
@@ -1762,6 +1786,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
17621786 wv.OnEnterFullscreen = nil
17631787 wv.OnLeaveFullscreen = nil
17641788 wv.OnAudioStateChanged = nil
1789+ wv.OnEditableFocusChanged = nil
17651790 return
17661791 }
17671792
@@ -1807,6 +1832,16 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
18071832 wv.OnEnterFullscreen = callbacks.OnEnterFullscreen
18081833 wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen
18091834 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+ }
18101845 }
18111846
18121847 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1999,6 +2034,7 @@ func (wv *WebView) DestroyWithPolicy(policy string) {
19992034 wv.OnEnterFullscreen = nil
20002035 wv.OnLeaveFullscreen = nil
20012036 wv.OnAudioStateChanged = nil
2037+ wv.OnEditableFocusChanged = nil
20022038 wv.OnLinkHover = nil
20032039 wv.OnWebProcessTerminated = nil
20042040 wv.OnPermissionRequest = nil
@@ -2047,6 +2083,7 @@ func (wv *WebView) ResetForPoolReuse() {
20472083 return
20482084 }
20492085 wv.generation.Add(1)
2086+ wv.editableFocusBridgeToken = newEditableFocusBridgeToken()
20502087
20512088 // Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.
20522089 // They will be reconnected when the WebView is re-acquired from the pool.
@@ -2064,6 +2101,7 @@ func (wv *WebView) ResetForPoolReuse() {
20642101 wv.OnEnterFullscreen = nil
20652102 wv.OnLeaveFullscreen = nil
20662103 wv.OnAudioStateChanged = nil
2104+ wv.OnEditableFocusChanged = nil
20672105 wv.OnLinkHover = nil
20682106 wv.OnWebProcessTerminated = nil
20692107 wv.OnPermissionRequest = nil
@@ -2376,3 +2414,15 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
23762414 log.Debug().Msg("frontend assets attached to webview")
23772415 return nil
23782416 }
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 @@
11 package webkit
22
33 import (
4+ "context"
45 "errors"
6+ "fmt"
57 "testing"
68 "time"
79
10+ "github.com/bnema/dumber/internal/application/port"
811 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
913 )
1014
1115 func TestClassifyRunJSEvaluateError(t *testing.T) {
@@ -87,3 +91,99 @@ func TestShouldLogRunJSError_FatalAlwaysLogs(t *testing.T) {
8791 assert.True(t, shouldLog)
8892 assert.Equal(t, uint64(2), count)
8993 }
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 (
5252 floatingPaneIDPrefix = "floating-pane:"
5353 floatingSessionIDDefault = "default"
5454 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
5561 )
5662
5763 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -117,6 +123,9 @@ type App struct {
117123 // Focus tracking stays app-global.
118124 focusMgr *focus.Manager
119125
126+ pageModePolicyUC *usecase.PageModePolicyUseCase
127+ pageEditableFocusByPane map[entity.PaneID]bool
128+
120129 resizeModeBorderTarget layout.Widget
121130
122131 // Omnibox configuration (omnibox is created per workspace view)
@@ -150,6 +159,12 @@ type App struct {
150159 // Accent picker for dead keys support
151160 accentFocusProvider port.FocusedInputProvider
152161
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+
153168 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
154169 deferredInitOnce sync.Once
155170 deferredInitFn func()
@@ -192,15 +207,17 @@ func New(deps *Dependencies) (*App, error) {
192207 ctx, cancel := context.WithCancelCause(deps.Ctx)
193208
194209 app := &App{
195- deps: deps,
196- runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig),
197- tabs: entity.NewTabList(),
198- tabsUC: deps.TabsUC,
199- panesUC: deps.PanesUC,
200- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
201- windowForTab: make(map[entity.TabID]*browserWindow),
202- floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
203- browserWindows: make(map[string]*browserWindow),
210+ deps: deps,
211+ runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig),
212+ tabs: entity.NewTabList(),
213+ tabsUC: deps.TabsUC,
214+ panesUC: deps.PanesUC,
215+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
216+ windowForTab: make(map[entity.TabID]*browserWindow),
217+ floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
218+ browserWindows: make(map[string]*browserWindow),
219+ pageModePolicyUC: usecase.NewPageModePolicyUseCase(),
220+ pageEditableFocusByPane: make(map[entity.PaneID]bool),
204221 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
205222 if fn != nil {
206223 fn()
@@ -210,6 +227,9 @@ func New(deps *Dependencies) (*App, error) {
210227 engine: deps.Engine,
211228 cancel: cancel,
212229 }
230+ if deps.Theme != nil {
231+ deps.Theme.SetTransitionDuration(app.runtimeConfigSnapshot().UI.Workspace.Styling.TransitionDuration)
232+ }
213233 var autoCopyConfig port.AutoCopyConfig
214234 var clipboardOrchestrator port.ClipboardTextOrchestrator
215235 if deps.Clipboard != nil {
@@ -814,6 +834,9 @@ func (a *App) wireSessionManagerShortcut() {
814834 return nil
815835 }
816836 bw.sessionManager.Toggle(ctx)
837+ if bw.sessionManager.IsVisible() {
838+ a.handlePageModeFocusTrigger(ctx, bw, usecase.PageModePolicyTriggerOverlayFocus)
839+ }
817840 return nil
818841 })
819842 }
@@ -1071,7 +1094,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
10711094 })
10721095 bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
10731096 a.activateBrowserWindow(bw)
1074- a.handleModeChange(ctx, from, to)
1097+ a.handleModeChange(ctx, bw, from, to)
10751098 })
10761099 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
10771100 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
@@ -1099,6 +1122,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
10991122
11001123 return input.RouteHandleShortcuts
11011124 })
1125+ bw.keyboardHandler.SetPageModeActivationPassthrough(func() bool {
1126+ return a.shouldBypassPageModeActivation(bw)
1127+ })
11021128 bw.keyboardHandler.SetAccentHandler(a)
11031129 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())
11041130
@@ -1339,6 +1365,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
13391365 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
13401366 }
13411367 }
1368+ a.handlePageModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.PageModePolicyTriggerOmniboxFocus)
13421369 },
13431370 OnFocusOut: func() {
13441371 // When omnibox loses focus, set WebView as the focused input
@@ -1377,6 +1404,7 @@ func (a *App) initFindBarConfig(ctx context.Context) {
13771404 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
13781405 }
13791406 }
1407+ a.handlePageModeFocusTrigger(ctx, a.lastFocusedBrowserWindow(), usecase.PageModePolicyTriggerFindBarFocus)
13801408 },
13811409 OnFocusOut: func() {
13821410 // When find bar loses focus, set WebView as the focused input
@@ -1400,6 +1428,9 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
14001428 return
14011429 }
14021430 bw.sessionManager.Toggle(ctx)
1431+ if bw.sessionManager.IsVisible() {
1432+ a.handlePageModeFocusTrigger(ctx, bw, usecase.PageModePolicyTriggerOverlayFocus)
1433+ }
14031434 }
14041435
14051436 func (a *App) attachTabPickerToActivePane() {
@@ -1498,6 +1529,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
14981529
14991530 a.attachTabPickerToActivePane()
15001531 bw.tabPicker.Show(ctx, items)
1532+ a.handlePageModeFocusTrigger(ctx, bw, usecase.PageModePolicyTriggerOverlayFocus)
15011533 return nil
15021534 }
15031535
@@ -3072,6 +3104,7 @@ func (a *App) initTabCoordinator(ctx context.Context) {
30723104 // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.
30733105 if bw := a.browserWindowForTabTarget(target); bw != nil {
30743106 a.activateBrowserWindow(bw)
3107+ a.handlePageModeTabSwitch(ctx, bw)
30753108 }
30763109 a.switchWorkspaceView(ctx, tab.ID)
30773110 })
@@ -3209,8 +3242,13 @@ func (a *App) initCoordinators(ctx context.Context) {
32093242 a.deps.HistoryRecorderUC,
32103243 a.contentCoord,
32113244 )
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())
32123249 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
32133250 a.navCoord.ClearPaneHistory(paneID)
3251+ a.clearPageEditableFocusState(paneID)
32143252 })
32153253
32163254 // Wire title updates to history persistence
@@ -3220,6 +3258,7 @@ func (a *App) initCoordinators(ctx context.Context) {
32203258
32213259 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
32223260 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
3261+ a.clearPageEditableFocusState(paneID)
32233262 a.navCoord.RecordHistory(ctx, paneID, url)
32243263 })
32253264
@@ -3235,6 +3274,9 @@ func (a *App) initCoordinators(ctx context.Context) {
32353274 // Mark dirty so snapshot captures the new URI
32363275 a.MarkDirty()
32373276 })
3277+ a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
3278+ a.handlePageEditableFocusChanged(ctx, paneID, editable)
3279+ })
32383280
32393281 // Hide loading skeleton once the WebView paints
32403282 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
@@ -3347,6 +3389,9 @@ func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bo
33473389
33483390 func (a *App) wireKeyboardActions() {
33493391 a.kbDispatcher.SetOnQuit(a.Quit)
3392+ a.kbDispatcher.SetOnPageModePulse(func(ctx context.Context, fast bool) {
3393+ a.triggerPageModePulse(ctx, fast)
3394+ })
33503395 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
33513396 a.ToggleFindBar(ctx)
33523397 return nil
@@ -3678,8 +3723,128 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
36783723 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
36793724 }
36803725
3681-// handleModeChange is called when the input mode changes.
3682-func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
3726+func (a *App) pageModePolicy() *usecase.PageModePolicyUseCase {
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) {
36833848 log := logging.FromContext(ctx)
36843849 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
36853850
@@ -3691,48 +3856,204 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
36913856 a.applyResizeModeBorder(ctx, a.activeWorkspace())
36923857 }
36933858
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+
36943863 // Update global border overlay visibility based on mode.
36953864 // 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 {
36973867 bw.borderMgr.OnModeChange(ctx, from, to)
36983868 }
36993869
3700- // Show/hide mode indicator toaster based on config.
3701- a.updateModeIndicatorToaster(ctx, to)
3870+ // Show/hide this window's mode indicator toaster based on mode and config.
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+ }
37023961 }
37033962
3704-// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
3705-func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
3963+// triggerPageModePulse triggers a pane-local page mode pulse on the
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+
37063988 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 {
37083994 return
37093995 }
3996+ if pv := wsView.GetPaneView(bw.pageModePaneID); pv != nil {
3997+ if fast {
3998+ pv.TriggerPageModePulseFast()
3999+ } else {
4000+ pv.TriggerPageModePulse()
4001+ }
4002+ }
4003+}
37104004
3711- // Check if mode indicator toaster is enabled in config.
3712- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
3713- bw.modeToaster.Hide()
4005+// clearPageModeOwnership deactivates page mode on the owning pane for a
4006+// specific browser window and resets its tracked pane ID.
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 == "" {
37144011 return
37154012 }
37164013
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 {
37184040 bw.modeToaster.Hide()
37194041 return
37204042 }
37214043
3722- // Show persistent toaster at bottom-left with mode display name.
3723- // Mode class is applied atomically with Show() to avoid visual flicker.
3724- modeClass := getModeToastClass(mode)
4044+ // Modal mode toasts remain visible until that same window exits its mode.
4045+ // The mode class is applied atomically with Show() to avoid visual flicker.
37254046 bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
3726- component.WithDuration(0), // Persistent until mode exits.
4047+ component.WithDuration(0),
37274048 component.WithPosition(component.ToastPositionBottomLeft),
3728- component.WithModeClass(modeClass),
4049+ component.WithModeClass(getModeToastClass(mode)),
37294050 )
37304051 }
37314052
37324053 // getModeToastClass returns the CSS class for the given mode's toast styling.
37334054 func getModeToastClass(mode input.Mode) string {
37344055 switch mode {
3735- case input.ModePane:
4056+ case input.ModePane, input.ModePage:
37364057 return "toast-pane-mode"
37374058 case input.ModeTab:
37384059 return "toast-tab-mode"
@@ -3843,6 +4164,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
38434164 })
38444165 wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {
38454166 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
4167+ if bw := a.browserWindowForTab(tab.ID); bw != nil {
4168+ a.transferPageModeOwnershipToPane(ctx, bw, paneID)
4169+ }
38464170 })
38474171 }
38484172
@@ -4965,6 +5289,11 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
49655289 if bw.globalShortcutHandler != nil {
49665290 bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)
49675291 }
5292+ mode := input.ModeNormal
5293+ if bw.keyboardHandler != nil {
5294+ mode = bw.keyboardHandler.Mode()
5295+ }
5296+ a.updateModeIndicatorToaster(ctx, bw, mode)
49685297 }
49695298 }
49705299
@@ -5033,6 +5362,7 @@ func (a *App) applyThemeAppearance(ctx context.Context) {
50335362 if resolveThemeUC == nil {
50345363 resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource)
50355364 }
5365+ a.deps.Theme.SetTransitionDuration(workspaceStyling.TransitionDuration)
50365366 resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig(
50375367 &appearanceCfg,
50385368 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 {
5252 historySidebarReloader historySidebarReloader
5353 sidebarVisible bool
5454 activeSidebarKind nativeSidebarKind
55+
56+ // pageModePaneID tracks this window's pane-local Page Mode accent/pulse owner.
57+ pageModePaneID entity.PaneID
5558 }
5659
5760 func (bw *browserWindow) detachInputForDestroy() {
@@ -108,6 +111,7 @@ func (bw *browserWindow) clearShellState() {
108111 bw.favoritesSidebar = nil
109112 bw.historySidebarReloader = nil
110113 bw.activeSidebarKind = nativeSidebarNone
114+ bw.pageModePaneID = ""
111115 }
112116
113117 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
M internal/ui/component/pane_view.go +86 -0
@@ -14,6 +14,27 @@ import (
1414 const (
1515 // CSS class applied to active pane's border overlay
1616 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
1738 )
1839
1940 // PaneView is a container for a single WebView with active state indication.
@@ -31,6 +52,10 @@ type PaneView struct {
3152 paneID entity.PaneID
3253 isActive bool
3354
55+ // Page Mode pane-local visual state.
56+ pageMode bool
57+ pulseCycle bool
58+
3459 onFocusIn func(paneID entity.PaneID)
3560 onFocusOut func(paneID entity.PaneID)
3661 onHover func(paneID entity.PaneID)
@@ -92,6 +117,7 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit
92117 loading: loading,
93118 paneID: paneID,
94119 isActive: false,
120+ pageMode: false,
95121 }
96122 }
97123
@@ -520,6 +546,66 @@ func (pv *PaneView) HideLinkStatus() {
520546 }
521547 }
522548
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+
523609 // Cleanup removes the WebView widget from the overlay and clears references.
524610 // This must be called before destroying the WebView to ensure proper GTK cleanup.
525611 // 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(
681681 mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
682682 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
683683 }
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 {
7373 mu sync.RWMutex
7474 }
7575
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+
7686 // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
7787 // so TreeRenderer can create PaneViews.
7888 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
179179 }
180180 }
181181
182+ callbacks.OnEditableFocusChanged = func(editable bool) {
183+ if c.onEditableFocusChanged != nil {
184+ c.onEditableFocusChanged(paneID, editable)
185+ }
186+ }
187+
182188 // Add popup create handler if popup handling is configured
183189 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
184190
M internal/ui/coordinator/content/coordinator.go +8 -0
@@ -109,6 +109,9 @@ type Coordinator struct {
109109 // Callback when WebView gains focus (for accent picker text input targeting)
110110 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
111111
112+ // Callback when a page editable target gains or loses focus.
113+ onEditableFocusChanged func(paneID entity.PaneID, editable bool)
114+
112115 // Callback for first load_started event (triggers deferred initialization)
113116 onFirstLoadStarted func()
114117 loadStartedOnce sync.Once
@@ -243,6 +246,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.
243246 c.onWebViewFocused = fn
244247 }
245248
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+
246254 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.
247255 // This is used to trigger deferred initialization after the initial load_uri()
248256 // 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 {
2323 contextProvider func() context.Context
2424 navigateUC *usecase.NavigateUseCase
2525 historyRecorder *usecase.HistoryRecorderUseCase
26+ pageScrollUC *usecase.PageScrollUseCase
2627 contentCoord *content.Coordinator
2728 omniboxProvider OmniboxProvider
2829 }
@@ -275,6 +276,34 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
275276 c.historyRecorder.ClearPaneHistory(string(paneID))
276277 }
277278
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+
278307 // NotifyZoomChanged updates the omnibox zoom indicator.
279308 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {
280309 if c.omniboxProvider != nil {
M internal/ui/coordinator/navigation_webview_targeted_test.go +74 -0
@@ -6,9 +6,15 @@ import (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/bnema/dumber/internal/application/usecase"
910 "github.com/bnema/dumber/internal/domain/entity"
1011 )
1112
13+type mockScrollableWebView struct {
14+ *mocks.MockWebView
15+ *mocks.MockPageScrollable
16+}
17+
1218 type mockDevToolsWebView struct {
1319 *mocks.MockWebView
1420 *mocks.MockDevToolsOpener
@@ -76,6 +82,15 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi
7682 t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {
7783 testPrintWebViewTargetsProvidedWebView(t, ctx)
7884 })
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+ })
7994 }
8095
8196 func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {
@@ -268,3 +283,62 @@ func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
268283 t.Fatalf("unexpected error: %v", err)
269284 }
270285 }
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 {
5252 onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
5353 onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
5454 onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55+ onPageModePulse func(ctx context.Context, fast bool)
5556 onToggleHistorySidebar func(ctx context.Context) error
5657 onToggleFavoritesSidebar func(ctx context.Context) error
5758 onToggleCurrentFavorite func(ctx context.Context) error
@@ -156,6 +157,22 @@ func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context
156157 d.onOpenFloating = fn
157158 }
158159
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+
159176 func (d *KeyboardDispatcher) initActionHandlers() {
160177 const (
161178 firstTabIndex = 0
@@ -249,6 +266,63 @@ func (d *KeyboardDispatcher) initActionHandlers() {
249266 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
250267 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
251268 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+ },
252326 // UI
253327 input.ActionOpenOmnibox: d.navCoord.OpenOmnibox,
254328 input.ActionOpenFind: d.handleFindOpen,
M internal/ui/dispatcher/keyboard_test.go +99 -0
@@ -5,6 +5,9 @@ import (
55 "fmt"
66 "testing"
77
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"
811 "github.com/bnema/dumber/internal/domain/entity"
912 "github.com/bnema/dumber/internal/ui/coordinator"
1013 "github.com/bnema/dumber/internal/ui/input"
@@ -202,3 +205,99 @@ func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
202205 })
203206 }
204207 }
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)
6161 bm.borderOverlay.RemoveCssClass(tabModeClass)
6262 case input.ModeSession:
6363 bm.borderOverlay.RemoveCssClass(sessionModeClass)
64+ case input.ModePage:
65+ // Page mode never activates the global border overlay.
66+ // No class to remove.
6467 }
6568
6669 // Add new mode class and set visibility
@@ -74,6 +77,11 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
7477 case input.ModeSession:
7578 bm.borderOverlay.AddCssClass(sessionModeClass)
7679 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)
7785 case input.ModeNormal:
7886 bm.borderOverlay.SetVisible(false)
7987 default:
M internal/ui/input/global_shortcuts.go +28 -4
@@ -113,10 +113,16 @@ func NewGlobalShortcutHandler(
113113 }
114114 }
115115
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.)
117117 // 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.
118121 shortcuts := NewShortcutSet(ctx, workspace, session)
119122 for binding, action := range shortcuts.Global {
123+ if !shouldRegisterGTKGlobalShortcut(action) {
124+ continue
125+ }
120126 if _, exists := h.registered[binding]; exists {
121127 continue
122128 }
@@ -292,6 +298,14 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action,
292298 Msg("inactive window global shortcut callback ignored")
293299 return false
294300 }
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+ }
295309 eventInfo := h.inspectCurrentShortcutEvent()
296310 if !shouldDispatchGlobalShortcutEvent(eventInfo) {
297311 appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
@@ -318,8 +332,7 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action,
318332 // Mode-enter/exit actions go through KeyboardHandler for modal state.
319333 if isModeAction(actionToDispatch) {
320334 if h.kbHandler != nil {
321- h.kbHandler.DispatchAction(actionToDispatch)
322- return true
335+ return h.kbHandler.DispatchAction(actionToDispatch)
323336 }
324337 log.Warn().
325338 Str("action", string(actionToDispatch)).
@@ -481,6 +494,14 @@ func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, act
481494 return evt
482495 }
483496
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+
484505 func globalShortcutActionMap() map[string]Action {
485506 return map[string]Action{
486507 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -563,6 +584,9 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
563584
564585 shortcuts := NewShortcutSet(ctx, workspace, session)
565586 for binding, action := range shortcuts.Global {
587+ if !shouldRegisterGTKGlobalShortcut(action) {
588+ continue
589+ }
566590 if _, exists := h.registered[binding]; exists {
567591 continue
568592 }
@@ -816,7 +840,7 @@ func formatEventType(eventType gdk.EventType) string {
816840
817841 func isModeAction(action Action) bool {
818842 switch action {
819- case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:
843+ case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterPageMode, ActionExitMode:
820844 return true
821845 default:
822846 return false
M internal/ui/input/global_shortcuts_test.go +21 -0
@@ -9,6 +9,27 @@ import (
99 "github.com/bnema/puregotk/v4/gtk"
1010 )
1111
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+
1233 func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) {
1334 h := &GlobalShortcutHandler{
1435 registered: make(map[KeyBinding]Action),
M internal/ui/input/keyboard.go +257 -7
@@ -36,6 +36,13 @@ import (
3636 // Return an error if the action fails.
3737 type ActionHandler func(ctx context.Context, action Action) error
3838
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+
3946 // AccentHandler handles long-press accent detection.
4047 // Called for character keys that may have accent variants.
4148 type AccentHandler interface {
@@ -68,6 +75,9 @@ type KeyboardHandler struct {
6875 accentHandler AccentHandler
6976 // Optional escape hook for app-level overlays
7077 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
7181
7282 // GTK controller (nil until attached)
7383 controller *gtk.EventControllerKey
@@ -82,6 +92,15 @@ type KeyboardHandler struct {
8292 activePressedActions map[Action]uint
8393 ctx context.Context
8494 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
85104 }
86105
87106 // NewKeyboardHandler creates a new keyboard handler.
@@ -97,7 +116,12 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
97116 session: session,
98117 activePressedActions: make(map[Action]uint),
99118 ctx: ctx,
119+ pageScrollRepeatAdd: func(intervalMS uint, cb *glib.SourceFunc) uint {
120+ return glib.TimeoutAdd(intervalMS, cb, 0)
121+ },
122+ pageScrollRepeatRemove: glib.SourceRemove,
100123 }
124+ h.SetOnModeChange(nil)
101125
102126 return h
103127 }
@@ -109,9 +133,18 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
109133 h.onAction = fn
110134 }
111135
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+
112144 // ReloadShortcuts rebuilds the shortcut set from new config values.
113145 // This enables hot-reloading of keybindings without restarting.
114146 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
147+ h.stopPageScrollRepeat()
115148 h.mu.Lock()
116149 defer h.mu.Unlock()
117150
@@ -134,6 +167,9 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
134167 } else if from == ModeNormal {
135168 h.setControllerPhase(gtk.PhaseCaptureValue)
136169 }
170+ if to != ModePage {
171+ h.stopPageScrollRepeat()
172+ }
137173 // Forward to app-level callback
138174 if fn != nil {
139175 fn(from, to)
@@ -258,7 +294,6 @@ func (h *KeyboardHandler) DetachForDestroy() {
258294
259295 func (h *KeyboardHandler) detach(removeController bool) {
260296 h.mu.Lock()
261- defer h.mu.Unlock()
262297 if h.controller != nil && h.keyPressedHandlerID != 0 {
263298 h.controller.DisconnectSignal(h.keyPressedHandlerID)
264299 }
@@ -275,6 +310,21 @@ func (h *KeyboardHandler) detach(removeController bool) {
275310 h.keyPressedHandlerID = 0
276311 h.keyReleasedHandlerID = 0
277312 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+ }
278328 }
279329
280330 // handleKeyPress processes a key press event.
@@ -294,17 +344,31 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
294344 }
295345
296346 modifiers := Modifier(state) & modifierMask
347+ mode := h.modal.Mode()
297348
298349 // 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 {
300351 if onEscape != nil && onEscape(h.ctx) {
301352 return true
302353 }
303354 }
304355
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+
305369 // Determine routing for this key event
306370 route := RouteHandleShortcuts // default: process through shortcut system
307- if routeKey != nil && h.modal.Mode() == ModeNormal {
371+ if routeKey != nil && mode == ModeNormal {
308372 route = routeKey(KeyContext{
309373 Keyval: keyval,
310374 Keycode: keycode,
@@ -338,22 +402,132 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
338402 keyval = normalizeKeyval(keyval)
339403
340404 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
341- mode := h.modal.Mode()
342405 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
406+ return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers)
407+}
343408
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 {
344417 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+ }
345422 return mode != ModeNormal // Consume unrecognized keys in modal mode
346423 }
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+ }
347430 if h.suppressHeldAction(action, keyval) {
348431 log.Trace().
349432 Str("action", string(action)).
350433 Msg("held keyboard action repeat suppressed")
351434 return true
352435 }
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+ }
353457
458+ h.stopPageScrollRepeat()
459+ h.mu.Lock()
460+ h.pageScrollRepeatAction = action
461+ h.pageScrollRepeatKeyval = keyval
462+ h.mu.Unlock()
354463 return h.dispatchAction(action, mode)
355464 }
356465
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+
357531 // tryAccentDetection starts long-press detection for accent-eligible keys.
358532 // It is used only for the RouteAccentDetection route (GTK Entry widgets such as
359533 // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;
@@ -382,6 +556,18 @@ func normalizeKeyval(keyval uint) uint {
382556 return keyval
383557 }
384558
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+
385571 // dispatchAction dispatches the action and handles mode-related logic.
386572 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
387573 if h.handleModeAction(action) {
@@ -402,6 +588,9 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
402588 if mode == ModeResize && isResizeAction(action) {
403589 h.modal.ResetTimeout(h.ctx)
404590 }
591+ if mode == ModePage && isPageScrollAction(action) {
592+ h.modal.ResetTimeout(h.ctx)
593+ }
405594
406595 if mode != ModeNormal && ShouldAutoExitMode(action) {
407596 h.modal.ExitMode(h.ctx)
@@ -436,6 +625,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool {
436625 ActionEnterPaneMode,
437626 ActionEnterSessionMode,
438627 ActionEnterResizeMode,
628+ ActionEnterPageMode,
439629 ActionNewTab,
440630 ActionRenameTab,
441631 ActionSplitRight,
@@ -512,6 +702,20 @@ func isResizeAction(action Action) bool {
512702 }
513703 }
514704
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+
515719 func (h *KeyboardHandler) handleModeAction(action Action) bool {
516720 h.mu.RLock()
517721 workspace := h.workspace
@@ -551,7 +755,22 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
551755 }
552756 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
553757 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
554770 case ActionExitMode:
771+ if h.modal.Mode() == ModePage {
772+ h.stopPageScrollRepeat()
773+ }
555774 h.modal.ExitMode(h.ctx)
556775 return true
557776 default:
@@ -598,33 +817,64 @@ func (h *KeyboardHandler) EnterSessionMode() {
598817 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
599818 }
600819
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+
601833 // ExitMode programmatically exits modal mode.
602834 // Useful for testing or programmatic mode changes.
603835 func (h *KeyboardHandler) ExitMode() {
604836 h.modal.ExitMode(h.ctx)
605837 }
606838
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+
607849 // DispatchAction processes an action externally triggered (e.g., by a global
608850 // shortcut). Mode-enter actions update modal state; other actions are forwarded
609-// to the registered action handler.
610-func (h *KeyboardHandler) DispatchAction(action Action) {
851+// to the registered action handler. It returns whether the action was consumed.
852+func (h *KeyboardHandler) DispatchAction(action Action) bool {
611853 mode := h.modal.Mode()
854+ if h.shouldPassthroughPageModeActivation(action, mode) {
855+ return false
856+ }
612857 h.dispatchAction(action, mode)
858+ return true
613859 }
614860
615861 // handleKeyRelease processes a key release event for accent detection.
616862 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
863+ keyval = normalizeKeyval(keyval)
617864 h.mu.Lock()
618865 accentHandler := h.accentHandler
619866 if len(h.activePressedActions) > 0 {
620- keyval = normalizeKeyval(keyval)
621867 for action, pressedKeyval := range h.activePressedActions {
622868 if pressedKeyval == keyval {
623869 delete(h.activePressedActions, action)
624870 }
625871 }
626872 }
873+ stopPageScroll := h.pageScrollRepeatKeyval == keyval
627874 h.mu.Unlock()
875+ if stopPageScroll {
876+ h.stopPageScrollRepeat()
877+ }
628878
629879 if accentHandler == nil {
630880 return
M internal/ui/input/keyboard_test.go +581 -0
@@ -3,9 +3,11 @@ package input
33 import (
44 "context"
55 "testing"
6+ "time"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/bnema/puregotk/v4/gdk"
10+ "github.com/bnema/puregotk/v4/glib"
911 "github.com/bnema/puregotk/v4/gtk"
1012 "github.com/stretchr/testify/assert"
1113 )
@@ -27,6 +29,10 @@ func newTestSession() *entity.SessionConfig {
2729 }
2830 }
2931
32+type fakeModalTimer struct{}
33+
34+func (*fakeModalTimer) Stop() bool { return true }
35+
3036 // stubAccentHandler is a test stub for the AccentHandler interface.
3137 type stubAccentHandler struct {
3238 onKeyPressedResult bool
@@ -362,3 +368,578 @@ func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T
362368 t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
363369 }
364370 }
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 (
88 "github.com/bnema/dumber/internal/logging"
99 )
1010
11+type modalTimer interface {
12+ Stop() bool
13+}
14+
1115 // Mode represents the current input mode.
1216 type Mode int
1317
@@ -22,6 +26,8 @@ const (
2226 ModeSession
2327 // ModeResize is the modal pane resizing mode.
2428 ModeResize
29+ // ModePage is the modal page scrolling mode.
30+ ModePage
2531 )
2632
2733 // String returns a human-readable mode name.
@@ -37,6 +43,8 @@ func (m Mode) String() string {
3743 return "session"
3844 case ModeResize:
3945 return "resize"
46+ case ModePage:
47+ return "page"
4048 default:
4149 return "unknown"
4250 }
@@ -53,6 +61,8 @@ func (m Mode) DisplayName() string {
5361 return "SESSION MODE"
5462 case ModeResize:
5563 return "RESIZE MODE"
64+ case ModePage:
65+ return "PAGE MODE"
5666 default:
5767 return ""
5868 }
@@ -62,9 +72,11 @@ func (m Mode) DisplayName() string {
6272 type ModalState struct {
6373 mode Mode
6474 timeout time.Duration
65- timer *time.Timer
75+ timer modalTimer
6676 timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks
6777
78+ afterFunc func(time.Duration, func()) modalTimer
79+
6880 // Callback for mode changes (called synchronously under lock).
6981 onModeChange func(from, to Mode)
7082
@@ -84,10 +96,16 @@ func NewModalState(ctx context.Context) *ModalState {
8496 log.Debug().Msg("creating modal state")
8597 return &ModalState{
8698 mode: ModeNormal,
99+ afterFunc: func(d time.Duration, fn func()) modalTimer { return time.AfterFunc(d, fn) },
87100 scheduleOnMainThread: func(fn func()) { fn() },
88101 }
89102 }
90103
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+
91109 // SetMainThreadScheduler sets the function used to dispatch timer callbacks
92110 // to the GTK main thread. In the real app this should be a glib.IdleAdd
93111 // wrapper. In tests, the default (direct execution) is used.
@@ -223,7 +241,7 @@ func (m *ModalState) startTimeoutLocked(ctx context.Context, timeout time.Durati
223241 m.timerGen++
224242 gen := m.timerGen
225243 schedule := m.scheduleOnMainThread
226- m.timer = time.AfterFunc(timeout, func() {
244+ m.timer = m.afterFunc(timeout, func() {
227245 schedule(func() {
228246 // Ignore stale callback: time.Timer.Stop can race with the
229247 // timer firing, so a canceled timer's callback may still run.
M internal/ui/input/shortcuts.go +62 -1
@@ -202,6 +202,15 @@ const (
202202
203203 // Application
204204 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"
205214 )
206215
207216 const floatingProfileActionPrefix = "open_floating_profile:"
@@ -222,6 +231,8 @@ type ShortcutSet struct {
222231 SessionMode ShortcutTable
223232 // ResizeMode shortcuts are only active in resize mode.
224233 ResizeMode ShortcutTable
234+ // PageMode shortcuts are only active in page mode.
235+ PageMode ShortcutTable
225236 }
226237
227238 // NewShortcutSet creates a ShortcutSet from workspace and session configuration.
@@ -235,6 +246,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
235246 PaneMode: make(ShortcutTable),
236247 SessionMode: make(ShortcutTable),
237248 ResizeMode: make(ShortcutTable),
249+ PageMode: make(ShortcutTable),
238250 }
239251
240252 set.buildGlobalShortcutsFromParts(ctx, workspace, session)
@@ -242,6 +254,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
242254 set.buildTabModeShortcuts(ctx, workspace)
243255 set.buildPaneModeShortcuts(ctx, workspace)
244256 set.buildResizeModeShortcuts(ctx, workspace)
257+ set.buildPageModeShortcuts(ctx, workspace)
245258 }
246259 if session != nil {
247260 set.buildSessionModeShortcuts(ctx, session)
@@ -253,6 +266,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
253266 Int("pane", len(set.PaneMode)).
254267 Int("resize", len(set.ResizeMode)).
255268 Int("session", len(set.SessionMode)).
269+ Int("page", len(set.PageMode)).
256270 Msg("shortcuts registered")
257271
258272 return set
@@ -288,6 +302,22 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.
288302 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
289303 }
290304
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+
291321 func (s *ShortcutSet) registerActivationShortcutsFromParts(
292322 ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
293323 ) {
@@ -308,6 +338,16 @@ func (s *ShortcutSet) registerActivationShortcutsFromParts(
308338 }
309339 return
310340 }
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+ }
311351 if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
312352 s.Global[binding] = ActionEnterTabMode
313353 log.Trace().
@@ -642,6 +682,20 @@ var configActionToAction = map[string]Action{
642682
643683 // Session actions
644684 "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,
645699 }
646700
647701 // FloatingProfileTarget carries the session identity and URL for a floating profile action.
@@ -791,7 +845,9 @@ func stringToKeyval(s string) (uint, bool) {
791845 }
792846
793847 // 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.
795851 func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
796852 // Normalize the binding modifiers
797853 binding.Modifiers &= modifierMask
@@ -807,12 +863,17 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
807863 modeTable = s.ResizeMode
808864 case ModeSession:
809865 modeTable = s.SessionMode
866+ case ModePage:
867+ modeTable = s.PageMode
810868 }
811869
812870 if modeTable != nil {
813871 if action, ok := modeTable[binding]; ok {
814872 return action, true
815873 }
874+ if mode == ModePage {
875+ return "", false
876+ }
816877 }
817878
818879 // Fall back to global
M internal/ui/input/shortcuts_test.go +141 -0
@@ -1,8 +1,10 @@
11 package input
22
33 import (
4+ "context"
45 "testing"
56
7+ "github.com/bnema/dumber/internal/domain/entity"
68 "github.com/bnema/puregotk/v4/gdk"
79 )
810
@@ -462,3 +464,142 @@ func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) {
462464 })
463465 }
464466 }
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 {
5151 `
5252 }
5353
54+const defaultTransitionDurationMs = 120
55+
5456 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
5557 // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
5658 // Uses default mode colors.
@@ -58,9 +60,16 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
5860 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
5961 }
6062
61-// GenerateCSSFull creates GTK4 CSS using all provided configuration.
63+// GenerateCSSFull creates GTK4 CSS using all provided configuration with the
64+// default transition duration.
6265 // Text scaling is handled via GtkSettings; CSS here stays scale-independent.
6366 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 {
6473 defaults := DefaultFontConfig()
6574 fonts = FontConfig{
6675 SansFont: Coalesce(fonts.SansFont, defaults.SansFont),
@@ -102,6 +111,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
102111 sb.WriteString(generateStackedPaneCSS(p))
103112 sb.WriteString("\n")
104113
114+ // Page mode styling
115+ sb.WriteString(generatePageModeCSS(transitionDurationMs))
116+ sb.WriteString("\n")
117+
105118 // Progress bar styling
106119 sb.WriteString(generateProgressBarCSS(p))
107120 sb.WriteString("\n")
@@ -572,6 +585,69 @@ entry.find-bar-entry:focus-visible {
572585
573586 // generatePaneCSS creates pane border styles.
574587 // 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+
575651 func generatePaneCSS(p Palette) string {
576652 return `/* ===== Pane Styling ===== */
577653
M internal/ui/theme/css_test.go +10 -0
@@ -34,6 +34,16 @@ func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing
3434 }
3535 }
3636
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+
3747 func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) {
3848 css := GenerateCSS(DefaultDarkPalette())
3949
M internal/ui/theme/manager.go +25 -11
@@ -15,15 +15,16 @@ import (
1515 // Manager handles theme state and CSS application.
1616 // Theme precedence and fallback are resolved before data reaches this adapter.
1717 type Manager struct {
18- prefersDark bool // Resolved dark mode preference
19- lightPalette Palette // Light theme colors
20- darkPalette Palette // Dark theme colors
21- uiScale float64 // UI scaling factor (1.0 = 100%)
22- fonts FontConfig
23- gtkFont string
24- modeColors ModeColors // Modal mode indicator colors
25- cssProvider *gtk.CssProvider
26- appliedFont string
18+ prefersDark bool // Resolved dark mode preference
19+ lightPalette Palette // Light theme colors
20+ darkPalette Palette // Dark theme colors
21+ uiScale float64 // UI scaling factor (1.0 = 100%)
22+ fonts FontConfig
23+ gtkFont string
24+ modeColors ModeColors // Modal mode indicator colors
25+ transitionDurationMs int
26+ cssProvider *gtk.CssProvider
27+ appliedFont string
2728 }
2829
2930 // NewManager creates a new theme manager from an already-resolved theme.
@@ -58,6 +59,18 @@ func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) {
5859 }
5960 m.gtkFont = m.fonts.GtkFont
6061 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
6174 }
6275
6376 // PrefersDark returns true if dark mode is active.
@@ -135,9 +148,10 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
135148 return
136149 }
137150
138- // Generate CSS with current palette, UI scale, fonts, and mode colors
151+ // Generate CSS with current palette, UI scale, fonts, mode colors, and the
152+ // configured transition duration.
139153 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)
141155 fontName := formatGTKFontName(m.gtkFont, m.uiScale)
142156
143157 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