detached b7de353

feat(cef): inertial scrolling with keyboard support (#409)

* build(deps): pin inertial scrolling bridge revision

* feat(cef): integrate inertial scrolling controls and cancellation

* build(deps): repin bridge with monotonic scroll clock and trace

* build(deps): repin bridge with anchor tolerance

* chore(deps): pin purego-cef2gtk with pointer-decoupled wheel bursts

* chore(deps): pin purego-cef2gtk with review fixes

Picks up the session-epoch emission fix, View.input thread safety, tick-backend retirement, and CodeRabbit hardening on top of pointer-decoupled wheel bursts.

* feat(vim): route key scrolling through the inertial engine

With wheel smoothing enabled, CEF ScrollPage forwards one engine impulse per press or held-key step instead of the legacy JS queue: repeats join the live wheel burst and release coasts. Key release intentionally sends nothing; CancelPageScroll keeps draining only the legacy queue, which stays empty while injection succeeds. Falls back to legacy paths when smoothing is off, the bridge is absent, or injection is rejected.

* chore(deps): pin purego-cef2gtk with review fixes and inject support

Merges the PR 36 review round (epoch-scoped routing, inputMu, tick retirement) with keyboard-scroll injection on top.

* fix(review): adapter lock scope, faithful cancel fake, spelling

Copilot: snapshot the view before View.InjectScroll instead of holding viewMu.RLock across its GTK hop, which could freeze Destroy; test fake retains the retired gesture until matching cleanup so CancelScrollEpoch exercises the real contract. Lint: fix misspelling flagged by misspell.
commit
b7de3539868c90d481ee98ef24d9420813e04ddc
author
bnema · · 2026-09-07 14:11 UTC
committer
GitHub · · 2026-09-07 14:11 UTC
parent
607ffa3
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
20 files · +824 -57
M docs/config/index.md +2 -0
@@ -276,6 +276,8 @@ CEF is the default browser engine. WebKitGTK remains available as a fallback via
276276 | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | `> 0` | Horizontal scroll sensitivity multiplier |
277277 | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | `> 0` | Vertical scroll sensitivity multiplier; combines with `scroll_precise_multiplier` for touchpads |
278278 | `engine.cef.input.scroll_max_delta` | int32 | `0` | `>= 0` | Maximum absolute scroll delta after scaling; 0 disables clamping |
279+| `engine.cef.input.scroll_touchpad_inertia` | bool | `true` | - | Direct touchpad tracking with exponential release decay |
280+| `engine.cef.input.scroll_wheel_smoothing` | bool | `true` | - | Interpolate accepted wheel impulses across frames without a long coast |
279281 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation |
280282 | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | `> 0` | Minimum accumulated horizontal swipe delta required for navigation |
281283 | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | `> 0` | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes |
@@ -276,6 +276,8 @@ CEF is the default browser engine. WebKitGTK remains available as a fallback via
276 | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | `> 0` | Horizontal scroll sensitivity multiplier |276 | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | `> 0` | Horizontal scroll sensitivity multiplier |
277 | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | `> 0` | Vertical scroll sensitivity multiplier; combines with `scroll_precise_multiplier` for touchpads |277 | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | `> 0` | Vertical scroll sensitivity multiplier; combines with `scroll_precise_multiplier` for touchpads |
278 | `engine.cef.input.scroll_max_delta` | int32 | `0` | `>= 0` | Maximum absolute scroll delta after scaling; 0 disables clamping |278 | `engine.cef.input.scroll_max_delta` | int32 | `0` | `>= 0` | Maximum absolute scroll delta after scaling; 0 disables clamping |
279+| `engine.cef.input.scroll_touchpad_inertia` | bool | `true` | - | Direct touchpad tracking with exponential release decay |
280+| `engine.cef.input.scroll_wheel_smoothing` | bool | `true` | - | Interpolate accepted wheel impulses across frames without a long coast |
279 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation |281 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation |
280 | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | `> 0` | Minimum accumulated horizontal swipe delta required for navigation |282 | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | `> 0` | Minimum accumulated horizontal swipe delta required for navigation |
281 | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | `> 0` | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes |283 | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | `> 0` | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes |
M docs/reference/configuration.md +2 -0
@@ -71,6 +71,8 @@
7171 | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 |
7272 | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 |
7373 | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 |
74+| `engine.cef.input.scroll_touchpad_inertia` | bool | `true` | |
75+| `engine.cef.input.scroll_wheel_smoothing` | bool | `true` | |
7476 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | |
7577 | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | > 0 |
7678 | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 |
@@ -71,6 +71,8 @@
71 | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 |71 | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 |
72 | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 |72 | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 |
73 | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 |73 | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 |
74+| `engine.cef.input.scroll_touchpad_inertia` | bool | `true` | |
75+| `engine.cef.input.scroll_wheel_smoothing` | bool | `true` | |
74 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | |76 | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | |
75 | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | > 0 |77 | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | > 0 |
76 | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 |78 | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 |
M go.mod +1 -1
@@ -7,7 +7,7 @@ require (
77 github.com/andybalholm/brotli v1.2.3
88 github.com/bnema/purego v0.11.0-bnema.4
99 github.com/bnema/purego-cef v0.14.2
10- github.com/bnema/purego-cef2gtk v0.9.3
10+ github.com/bnema/purego-cef2gtk v0.9.4-0.20260907134332-33edf6236a95
1111 github.com/bnema/purego-pipewire v0.1.6
1212 github.com/bnema/purego-sqlite v0.1.5
1313 github.com/bnema/purego-webp v0.2.1
@@ -7,7 +7,7 @@ require (
7 github.com/andybalholm/brotli v1.2.37 github.com/andybalholm/brotli v1.2.3
8 github.com/bnema/purego v0.11.0-bnema.48 github.com/bnema/purego v0.11.0-bnema.4
9 github.com/bnema/purego-cef v0.14.29 github.com/bnema/purego-cef v0.14.2
10- github.com/bnema/purego-cef2gtk v0.9.310+ github.com/bnema/purego-cef2gtk v0.9.4-0.20260907134332-33edf6236a95
11 github.com/bnema/purego-pipewire v0.1.611 github.com/bnema/purego-pipewire v0.1.6
12 github.com/bnema/purego-sqlite v0.1.512 github.com/bnema/purego-sqlite v0.1.5
13 github.com/bnema/purego-webp v0.2.113 github.com/bnema/purego-webp v0.2.1
M go.sum +2 -2 generated file: collapsed
M internal/bootstrap/engine.go +2 -0
@@ -99,6 +99,8 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
9999 ScrollHorizontalMultiplier: cfg.Engine.CEF.Input.ScrollHorizontalMultiplier,
100100 ScrollVerticalMultiplier: cfg.Engine.CEF.Input.ScrollVerticalMultiplier,
101101 ScrollMaxDelta: cfg.Engine.CEF.Input.ScrollMaxDelta,
102+ ScrollTouchpadInertia: cfg.Engine.CEF.Input.ScrollTouchpadInertia,
103+ ScrollWheelSmoothing: cfg.Engine.CEF.Input.ScrollWheelSmoothing,
102104 TouchpadNavigationEnabled: cfg.Engine.CEF.Input.TouchpadNavigationEnabled,
103105 TouchpadNavigationMinDelta: cfg.Engine.CEF.Input.TouchpadNavigationMinDelta,
104106 TouchpadNavigationMaxVerticalRatio: cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio,
@@ -99,6 +99,8 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
99 ScrollHorizontalMultiplier: cfg.Engine.CEF.Input.ScrollHorizontalMultiplier,99 ScrollHorizontalMultiplier: cfg.Engine.CEF.Input.ScrollHorizontalMultiplier,
100 ScrollVerticalMultiplier: cfg.Engine.CEF.Input.ScrollVerticalMultiplier,100 ScrollVerticalMultiplier: cfg.Engine.CEF.Input.ScrollVerticalMultiplier,
101 ScrollMaxDelta: cfg.Engine.CEF.Input.ScrollMaxDelta,101 ScrollMaxDelta: cfg.Engine.CEF.Input.ScrollMaxDelta,
102+ ScrollTouchpadInertia: cfg.Engine.CEF.Input.ScrollTouchpadInertia,
103+ ScrollWheelSmoothing: cfg.Engine.CEF.Input.ScrollWheelSmoothing,
102 TouchpadNavigationEnabled: cfg.Engine.CEF.Input.TouchpadNavigationEnabled,104 TouchpadNavigationEnabled: cfg.Engine.CEF.Input.TouchpadNavigationEnabled,
103 TouchpadNavigationMinDelta: cfg.Engine.CEF.Input.TouchpadNavigationMinDelta,105 TouchpadNavigationMinDelta: cfg.Engine.CEF.Input.TouchpadNavigationMinDelta,
104 TouchpadNavigationMaxVerticalRatio: cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio,106 TouchpadNavigationMaxVerticalRatio: cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio,
M internal/infrastructure/cef/cef2gtk_adapter.go +63 -0
@@ -299,6 +299,69 @@ func (a *Cef2gtkAdapter) SetInputHost(host purecef.BrowserHost) error {
299299 return a.view.SetInputHost(host)
300300 }
301301
302+// InjectScroll feeds a synthetic scroll impulse (keyboard scrolling) into
303+// the animated wheel engine from any thread. It reports whether the
304+// impulse was accepted. Destroyed or absent views report false.
305+func (a *Cef2gtkAdapter) InjectScroll(dx, dy float64) bool {
306+ if a == nil || a.destroyed.Load() {
307+ return false
308+ }
309+ // Snapshot under lock, invoke outside it: View.InjectScroll hops to
310+ // the GTK thread when called off-thread, and GTK teardown waits on
311+ // this same lock — holding it across the call would freeze Destroy.
312+ a.viewMu.RLock()
313+ view := a.view
314+ a.viewMu.RUnlock()
315+ if view == nil {
316+ return false
317+ }
318+ return view.InjectScroll(dx, dy)
319+}
320+
321+// InvalidateScroll immediately retires animated scroll motion from any
322+// thread, returning the retired epoch. Pass that epoch to
323+// CancelScrollEpoch for GTK cleanup. Destroyed or absent views report zero.
324+func (a *Cef2gtkAdapter) InvalidateScroll() uint64 {
325+ if a == nil || a.destroyed.Load() {
326+ return 0
327+ }
328+ a.viewMu.RLock()
329+ defer a.viewMu.RUnlock()
330+ if a.view == nil {
331+ return 0
332+ }
333+ return a.view.InvalidateScroll()
334+}
335+
336+// CancelScroll synchronously invalidates and cleans up scroll motion.
337+// Must be called on the GTK main thread.
338+func (a *Cef2gtkAdapter) CancelScroll() {
339+ if a == nil || a.destroyed.Load() {
340+ return
341+ }
342+ a.viewMu.RLock()
343+ defer a.viewMu.RUnlock()
344+ if a.view == nil {
345+ return
346+ }
347+ a.view.CancelScroll()
348+}
349+
350+// CancelScrollEpoch performs GTK-only cleanup for a retired epoch. It never
351+// clears a newer session and reports whether cleanup ran. Must be called on
352+// the GTK main thread.
353+func (a *Cef2gtkAdapter) CancelScrollEpoch(epoch uint64) bool {
354+ if a == nil || a.destroyed.Load() {
355+ return false
356+ }
357+ a.viewMu.RLock()
358+ defer a.viewMu.RUnlock()
359+ if a.view == nil {
360+ return false
361+ }
362+ return a.view.CancelScrollEpoch(epoch)
363+}
364+
302365 // DetachInput removes GTK input controllers attached by AttachInput.
303366 // Must be called on the GTK main thread.
304367 func (a *Cef2gtkAdapter) DetachInput() error {
@@ -299,6 +299,69 @@ func (a *Cef2gtkAdapter) SetInputHost(host purecef.BrowserHost) error {
299 return a.view.SetInputHost(host)299 return a.view.SetInputHost(host)
300 }300 }
301 301
302+// InjectScroll feeds a synthetic scroll impulse (keyboard scrolling) into
303+// the animated wheel engine from any thread. It reports whether the
304+// impulse was accepted. Destroyed or absent views report false.
305+func (a *Cef2gtkAdapter) InjectScroll(dx, dy float64) bool {
306+ if a == nil || a.destroyed.Load() {
307+ return false
308+ }
309+ // Snapshot under lock, invoke outside it: View.InjectScroll hops to
310+ // the GTK thread when called off-thread, and GTK teardown waits on
311+ // this same lock — holding it across the call would freeze Destroy.
312+ a.viewMu.RLock()
313+ view := a.view
314+ a.viewMu.RUnlock()
315+ if view == nil {
316+ return false
317+ }
318+ return view.InjectScroll(dx, dy)
319+}
320+
321+// InvalidateScroll immediately retires animated scroll motion from any
322+// thread, returning the retired epoch. Pass that epoch to
323+// CancelScrollEpoch for GTK cleanup. Destroyed or absent views report zero.
324+func (a *Cef2gtkAdapter) InvalidateScroll() uint64 {
325+ if a == nil || a.destroyed.Load() {
326+ return 0
327+ }
328+ a.viewMu.RLock()
329+ defer a.viewMu.RUnlock()
330+ if a.view == nil {
331+ return 0
332+ }
333+ return a.view.InvalidateScroll()
334+}
335+
336+// CancelScroll synchronously invalidates and cleans up scroll motion.
337+// Must be called on the GTK main thread.
338+func (a *Cef2gtkAdapter) CancelScroll() {
339+ if a == nil || a.destroyed.Load() {
340+ return
341+ }
342+ a.viewMu.RLock()
343+ defer a.viewMu.RUnlock()
344+ if a.view == nil {
345+ return
346+ }
347+ a.view.CancelScroll()
348+}
349+
350+// CancelScrollEpoch performs GTK-only cleanup for a retired epoch. It never
351+// clears a newer session and reports whether cleanup ran. Must be called on
352+// the GTK main thread.
353+func (a *Cef2gtkAdapter) CancelScrollEpoch(epoch uint64) bool {
354+ if a == nil || a.destroyed.Load() {
355+ return false
356+ }
357+ a.viewMu.RLock()
358+ defer a.viewMu.RUnlock()
359+ if a.view == nil {
360+ return false
361+ }
362+ return a.view.CancelScrollEpoch(epoch)
363+}
364+
302 // DetachInput removes GTK input controllers attached by AttachInput.365 // DetachInput removes GTK input controllers attached by AttachInput.
303 // Must be called on the GTK main thread.366 // Must be called on the GTK main thread.
304 func (a *Cef2gtkAdapter) DetachInput() error {367 func (a *Cef2gtkAdapter) DetachInput() error {
M internal/infrastructure/cef/runtime_config.go +2 -0
@@ -34,6 +34,8 @@ type RuntimeInputConfig struct {
3434 ScrollHorizontalMultiplier float64
3535 ScrollVerticalMultiplier float64
3636 ScrollMaxDelta int32
37+ ScrollTouchpadInertia bool
38+ ScrollWheelSmoothing bool
3739 TouchpadNavigationEnabled bool
3840 TouchpadNavigationMinDelta float64
3941 TouchpadNavigationMaxVerticalRatio float64
@@ -34,6 +34,8 @@ type RuntimeInputConfig struct {
34 ScrollHorizontalMultiplier float6434 ScrollHorizontalMultiplier float64
35 ScrollVerticalMultiplier float6435 ScrollVerticalMultiplier float64
36 ScrollMaxDelta int3236 ScrollMaxDelta int32
37+ ScrollTouchpadInertia bool
38+ ScrollWheelSmoothing bool
37 TouchpadNavigationEnabled bool39 TouchpadNavigationEnabled bool
38 TouchpadNavigationMinDelta float6440 TouchpadNavigationMinDelta float64
39 TouchpadNavigationMaxVerticalRatio float6441 TouchpadNavigationMaxVerticalRatio float64
M internal/infrastructure/cef/webview.go +88 -54
@@ -189,6 +189,14 @@ type WebView struct {
189189 gtkSyncIsOwner func() bool
190190 gtkSyncTimeout time.Duration
191191
192+ // scrollCancelSeam overrides the adapter for scroll cancellation in
193+ // tests. Production leaves it nil so the live view bridge is used.
194+ scrollCancelSeam scrollCancelBridge
195+
196+ // scrollInjectSeam overrides the adapter for scroll injection in
197+ // tests. Production leaves it nil so the live view bridge is used.
198+ scrollInjectSeam scrollInjectBridge
199+
192200 // crashCount tracks consecutive renderer crashes to prevent infinite
193201 // crash → redirect → crash loops.
194202 crashCount atomic.Int32
@@ -354,17 +362,19 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
354362 return errDestroyed
355363 }
356364 actualURI := toActualInternalURL(uri)
357- wv.mu.Lock()
358- browser := wv.browser
359- // Always remember the latest requested URI so a browser/main-frame race
360- // cannot strand startup on about:blank.
361- wv.setPendingNavigationLocked(actualURI, time.Now())
362- wv.mu.Unlock()
363- if browser == nil {
365+ return wv.runNavigationWithScrollCancel("cef.load_uri", func() error {
366+ wv.mu.Lock()
367+ browser := wv.browser
368+ // Always remember the latest requested URI so a browser/main-frame race
369+ // cannot strand startup on about:blank.
370+ wv.setPendingNavigationLocked(actualURI, time.Now())
371+ wv.mu.Unlock()
372+ if browser == nil {
373+ return nil
374+ }
375+ wv.schedulePendingNavigationReplay(0)
364376 return nil
365- }
366- wv.schedulePendingNavigationReplay(0)
367- return nil
377+ })
368378 }
369379
370380 // LoadHTML loads HTML content with an optional base URI (ignored in Phase 1).
@@ -378,31 +388,35 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
378388 Int("content_len", len(content)).
379389 Msg("cef: LoadHTML content exceeds 1MB, data URL may fail")
380390 }
381- wv.mu.RLock()
382- browser := wv.browser
383- wv.mu.RUnlock()
384- if browser == nil {
385- return errNoBrowser
386- }
387- dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))
388- if frame := browser.GetMainFrame(); frame != nil {
389- frame.LoadURL(dataURL)
390- }
391- return nil
391+ return wv.runNavigationWithScrollCancel("cef.load_html", func() error {
392+ wv.mu.RLock()
393+ browser := wv.browser
394+ wv.mu.RUnlock()
395+ if browser == nil {
396+ return errNoBrowser
397+ }
398+ dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))
399+ if frame := browser.GetMainFrame(); frame != nil {
400+ frame.LoadURL(dataURL)
401+ }
402+ return nil
403+ })
392404 }
393405
394406 func (wv *WebView) Reload(_ context.Context) error {
395407 if wv.destroyed.Load() {
396408 return errDestroyed
397409 }
398- wv.mu.RLock()
399- browser := wv.browser
400- wv.mu.RUnlock()
401- if browser == nil {
402- return errNoBrowser
403- }
404- browser.Reload()
405- return nil
410+ return wv.runNavigationWithScrollCancel("cef.reload", func() error {
411+ wv.mu.RLock()
412+ browser := wv.browser
413+ wv.mu.RUnlock()
414+ if browser == nil {
415+ return errNoBrowser
416+ }
417+ browser.Reload()
418+ return nil
419+ })
406420 }
407421
408422 // ReloadBypassCache reloads the current page, bypassing cache.
@@ -410,14 +424,16 @@ func (wv *WebView) ReloadBypassCache(_ context.Context) error {
410424 if wv.destroyed.Load() {
411425 return errDestroyed
412426 }
413- wv.mu.RLock()
414- browser := wv.browser
415- wv.mu.RUnlock()
416- if browser == nil {
417- return errNoBrowser
418- }
419- browser.ReloadIgnoreCache()
420- return nil
427+ return wv.runNavigationWithScrollCancel("cef.reload_bypass_cache", func() error {
428+ wv.mu.RLock()
429+ browser := wv.browser
430+ wv.mu.RUnlock()
431+ if browser == nil {
432+ return errNoBrowser
433+ }
434+ browser.ReloadIgnoreCache()
435+ return nil
436+ })
421437 }
422438
423439 // Stop stops the current page load.
@@ -440,14 +456,16 @@ func (wv *WebView) GoBack(_ context.Context) error {
440456 if wv.destroyed.Load() {
441457 return errDestroyed
442458 }
443- wv.mu.RLock()
444- browser := wv.browser
445- wv.mu.RUnlock()
446- if browser == nil {
447- return errNoBrowser
448- }
449- browser.GoBack()
450- return nil
459+ return wv.runNavigationWithScrollCancel("cef.go_back", func() error {
460+ wv.mu.RLock()
461+ browser := wv.browser
462+ wv.mu.RUnlock()
463+ if browser == nil {
464+ return errNoBrowser
465+ }
466+ browser.GoBack()
467+ return nil
468+ })
451469 }
452470
453471 // GoForward navigates forward in history.
@@ -455,14 +473,16 @@ func (wv *WebView) GoForward(_ context.Context) error {
455473 if wv.destroyed.Load() {
456474 return errDestroyed
457475 }
458- wv.mu.RLock()
459- browser := wv.browser
460- wv.mu.RUnlock()
461- if browser == nil {
462- return errNoBrowser
463- }
464- browser.GoForward()
465- return nil
476+ return wv.runNavigationWithScrollCancel("cef.go_forward", func() error {
477+ wv.mu.RLock()
478+ browser := wv.browser
479+ wv.mu.RUnlock()
480+ if browser == nil {
481+ return errNoBrowser
482+ }
483+ browser.GoForward()
484+ return nil
485+ })
466486 }
467487
468488 // ---------------------------------------------------------------------------
@@ -1236,6 +1256,10 @@ func (wv *WebView) Destroy() {
12361256 if !wv.destroyed.CompareAndSwap(false, true) {
12371257 return
12381258 }
1259+ // Retire scroll motion the moment destruction begins, regardless of
1260+ // calling thread; GTK-only cleanup follows through the owning
1261+ // dispatcher without waiting for the deferred native browser close.
1262+ wv.invalidateScrollMotion()
12391263 wv.shutdownAccessibilityCapture()
12401264 wv.resetPageScrollQueue()
12411265 wv.syntheticPopupMu.Lock()
@@ -1591,6 +1615,8 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
15911615 HorizontalMultiplier: wv.inputConfig.ScrollHorizontalMultiplier,
15921616 VerticalMultiplier: wv.inputConfig.ScrollVerticalMultiplier,
15931617 MaxDelta: wv.inputConfig.ScrollMaxDelta,
1618+ TouchpadInertia: wv.inputConfig.ScrollTouchpadInertia,
1619+ WheelSmoothing: wv.inputConfig.ScrollWheelSmoothing,
15941620 },
15951621 OnMiddleClick: func(_, _ float64) bool {
15961622 return wv.handleMiddleClickFromBridge()
@@ -2433,7 +2459,10 @@ func pageScrollWheelDeltas(request port.PageScrollRequest) (deltaX, deltaY int32
24332459
24342460 // ScrollPage resolves a DOM scroll target when the browser is ready so an
24352461 // exhausted nested scroller can hand off automatically to its ancestor or the
2436-// document. Native precision-wheel input remains the pre-frame fallback.
2462+// document. With wheel smoothing enabled the request becomes one inertial
2463+// engine impulse (key repeat joins the live burst, release coasts); the
2464+// legacy queue stays reserved for fallback. Native precision-wheel input
2465+// remains the pre-frame fallback.
24372466 func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
24382467 if wv == nil {
24392468 return errors.New("cef: webview is nil")
@@ -2448,6 +2477,11 @@ func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollReques
24482477 return errDestroyed
24492478 }
24502479
2480+ deltaX, deltaY := pageScrollWheelDeltas(request)
2481+ if wv.injectPageScroll(deltaX, deltaY) {
2482+ return nil
2483+ }
2484+
24512485 wv.mu.RLock()
24522486 host := wv.host
24532487 browser := wv.browser
@@ -189,6 +189,14 @@ type WebView struct {
189 gtkSyncIsOwner func() bool189 gtkSyncIsOwner func() bool
190 gtkSyncTimeout time.Duration190 gtkSyncTimeout time.Duration
191 191
192+ // scrollCancelSeam overrides the adapter for scroll cancellation in
193+ // tests. Production leaves it nil so the live view bridge is used.
194+ scrollCancelSeam scrollCancelBridge
195+
196+ // scrollInjectSeam overrides the adapter for scroll injection in
197+ // tests. Production leaves it nil so the live view bridge is used.
198+ scrollInjectSeam scrollInjectBridge
199+
192 // crashCount tracks consecutive renderer crashes to prevent infinite200 // crashCount tracks consecutive renderer crashes to prevent infinite
193 // crash → redirect → crash loops.201 // crash → redirect → crash loops.
194 crashCount atomic.Int32202 crashCount atomic.Int32
@@ -354,17 +362,19 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
354 return errDestroyed362 return errDestroyed
355 }363 }
356 actualURI := toActualInternalURL(uri)364 actualURI := toActualInternalURL(uri)
357- wv.mu.Lock()365+ return wv.runNavigationWithScrollCancel("cef.load_uri", func() error {
358- browser := wv.browser366+ wv.mu.Lock()
359- // Always remember the latest requested URI so a browser/main-frame race367+ browser := wv.browser
360- // cannot strand startup on about:blank.368+ // Always remember the latest requested URI so a browser/main-frame race
361- wv.setPendingNavigationLocked(actualURI, time.Now())369+ // cannot strand startup on about:blank.
362- wv.mu.Unlock()370+ wv.setPendingNavigationLocked(actualURI, time.Now())
363- if browser == nil {371+ wv.mu.Unlock()
372+ if browser == nil {
373+ return nil
374+ }
375+ wv.schedulePendingNavigationReplay(0)
364 return nil376 return nil
365- }377+ })
366- wv.schedulePendingNavigationReplay(0)
367- return nil
368 }378 }
369 379
370 // LoadHTML loads HTML content with an optional base URI (ignored in Phase 1).380 // LoadHTML loads HTML content with an optional base URI (ignored in Phase 1).
@@ -378,31 +388,35 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
378 Int("content_len", len(content)).388 Int("content_len", len(content)).
379 Msg("cef: LoadHTML content exceeds 1MB, data URL may fail")389 Msg("cef: LoadHTML content exceeds 1MB, data URL may fail")
380 }390 }
381- wv.mu.RLock()391+ return wv.runNavigationWithScrollCancel("cef.load_html", func() error {
382- browser := wv.browser392+ wv.mu.RLock()
383- wv.mu.RUnlock()393+ browser := wv.browser
384- if browser == nil {394+ wv.mu.RUnlock()
385- return errNoBrowser395+ if browser == nil {
386- }396+ return errNoBrowser
387- dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))397+ }
388- if frame := browser.GetMainFrame(); frame != nil {398+ dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))
389- frame.LoadURL(dataURL)399+ if frame := browser.GetMainFrame(); frame != nil {
390- }400+ frame.LoadURL(dataURL)
391- return nil401+ }
402+ return nil
403+ })
392 }404 }
393 405
394 func (wv *WebView) Reload(_ context.Context) error {406 func (wv *WebView) Reload(_ context.Context) error {
395 if wv.destroyed.Load() {407 if wv.destroyed.Load() {
396 return errDestroyed408 return errDestroyed
397 }409 }
398- wv.mu.RLock()410+ return wv.runNavigationWithScrollCancel("cef.reload", func() error {
399- browser := wv.browser411+ wv.mu.RLock()
400- wv.mu.RUnlock()412+ browser := wv.browser
401- if browser == nil {413+ wv.mu.RUnlock()
402- return errNoBrowser414+ if browser == nil {
403- }415+ return errNoBrowser
404- browser.Reload()416+ }
405- return nil417+ browser.Reload()
418+ return nil
419+ })
406 }420 }
407 421
408 // ReloadBypassCache reloads the current page, bypassing cache.422 // ReloadBypassCache reloads the current page, bypassing cache.
@@ -410,14 +424,16 @@ func (wv *WebView) ReloadBypassCache(_ context.Context) error {
410 if wv.destroyed.Load() {424 if wv.destroyed.Load() {
411 return errDestroyed425 return errDestroyed
412 }426 }
413- wv.mu.RLock()427+ return wv.runNavigationWithScrollCancel("cef.reload_bypass_cache", func() error {
414- browser := wv.browser428+ wv.mu.RLock()
415- wv.mu.RUnlock()429+ browser := wv.browser
416- if browser == nil {430+ wv.mu.RUnlock()
417- return errNoBrowser431+ if browser == nil {
418- }432+ return errNoBrowser
419- browser.ReloadIgnoreCache()433+ }
420- return nil434+ browser.ReloadIgnoreCache()
435+ return nil
436+ })
421 }437 }
422 438
423 // Stop stops the current page load.439 // Stop stops the current page load.
@@ -440,14 +456,16 @@ func (wv *WebView) GoBack(_ context.Context) error {
440 if wv.destroyed.Load() {456 if wv.destroyed.Load() {
441 return errDestroyed457 return errDestroyed
442 }458 }
443- wv.mu.RLock()459+ return wv.runNavigationWithScrollCancel("cef.go_back", func() error {
444- browser := wv.browser460+ wv.mu.RLock()
445- wv.mu.RUnlock()461+ browser := wv.browser
446- if browser == nil {462+ wv.mu.RUnlock()
447- return errNoBrowser463+ if browser == nil {
448- }464+ return errNoBrowser
449- browser.GoBack()465+ }
450- return nil466+ browser.GoBack()
467+ return nil
468+ })
451 }469 }
452 470
453 // GoForward navigates forward in history.471 // GoForward navigates forward in history.
@@ -455,14 +473,16 @@ func (wv *WebView) GoForward(_ context.Context) error {
455 if wv.destroyed.Load() {473 if wv.destroyed.Load() {
456 return errDestroyed474 return errDestroyed
457 }475 }
458- wv.mu.RLock()476+ return wv.runNavigationWithScrollCancel("cef.go_forward", func() error {
459- browser := wv.browser477+ wv.mu.RLock()
460- wv.mu.RUnlock()478+ browser := wv.browser
461- if browser == nil {479+ wv.mu.RUnlock()
462- return errNoBrowser480+ if browser == nil {
463- }481+ return errNoBrowser
464- browser.GoForward()482+ }
465- return nil483+ browser.GoForward()
484+ return nil
485+ })
466 }486 }
467 487
468 // ---------------------------------------------------------------------------488 // ---------------------------------------------------------------------------
@@ -1236,6 +1256,10 @@ func (wv *WebView) Destroy() {
1236 if !wv.destroyed.CompareAndSwap(false, true) {1256 if !wv.destroyed.CompareAndSwap(false, true) {
1237 return1257 return
1238 }1258 }
1259+ // Retire scroll motion the moment destruction begins, regardless of
1260+ // calling thread; GTK-only cleanup follows through the owning
1261+ // dispatcher without waiting for the deferred native browser close.
1262+ wv.invalidateScrollMotion()
1239 wv.shutdownAccessibilityCapture()1263 wv.shutdownAccessibilityCapture()
1240 wv.resetPageScrollQueue()1264 wv.resetPageScrollQueue()
1241 wv.syntheticPopupMu.Lock()1265 wv.syntheticPopupMu.Lock()
@@ -1591,6 +1615,8 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
1591 HorizontalMultiplier: wv.inputConfig.ScrollHorizontalMultiplier,1615 HorizontalMultiplier: wv.inputConfig.ScrollHorizontalMultiplier,
1592 VerticalMultiplier: wv.inputConfig.ScrollVerticalMultiplier,1616 VerticalMultiplier: wv.inputConfig.ScrollVerticalMultiplier,
1593 MaxDelta: wv.inputConfig.ScrollMaxDelta,1617 MaxDelta: wv.inputConfig.ScrollMaxDelta,
1618+ TouchpadInertia: wv.inputConfig.ScrollTouchpadInertia,
1619+ WheelSmoothing: wv.inputConfig.ScrollWheelSmoothing,
1594 },1620 },
1595 OnMiddleClick: func(_, _ float64) bool {1621 OnMiddleClick: func(_, _ float64) bool {
1596 return wv.handleMiddleClickFromBridge()1622 return wv.handleMiddleClickFromBridge()
@@ -2433,7 +2459,10 @@ func pageScrollWheelDeltas(request port.PageScrollRequest) (deltaX, deltaY int32
2433 2459
2434 // ScrollPage resolves a DOM scroll target when the browser is ready so an2460 // ScrollPage resolves a DOM scroll target when the browser is ready so an
2435 // exhausted nested scroller can hand off automatically to its ancestor or the2461 // exhausted nested scroller can hand off automatically to its ancestor or the
2436-// document. Native precision-wheel input remains the pre-frame fallback.2462+// document. With wheel smoothing enabled the request becomes one inertial
2463+// engine impulse (key repeat joins the live burst, release coasts); the
2464+// legacy queue stays reserved for fallback. Native precision-wheel input
2465+// remains the pre-frame fallback.
2437 func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {2466 func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
2438 if wv == nil {2467 if wv == nil {
2439 return errors.New("cef: webview is nil")2468 return errors.New("cef: webview is nil")
@@ -2448,6 +2477,11 @@ func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollReques
2448 return errDestroyed2477 return errDestroyed
2449 }2478 }
2450 2479
2480+ deltaX, deltaY := pageScrollWheelDeltas(request)
2481+ if wv.injectPageScroll(deltaX, deltaY) {
2482+ return nil
2483+ }
2484+
2451 wv.mu.RLock()2485 wv.mu.RLock()
2452 host := wv.host2486 host := wv.host
2453 browser := wv.browser2487 browser := wv.browser
M internal/infrastructure/cef/webview_handlers.go +11 -0
@@ -453,6 +453,11 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
453453 if frame == nil || !frame.IsMain() {
454454 return
455455 }
456+ // Post-commit fallback for navigation paths that bypass OnBeforeBrowse
457+ // (same-page, error, and helper-driven commits). Main frame only.
458+ if h.wv != nil {
459+ h.wv.invalidateScrollMotion()
460+ }
456461 bridgeNonce := h.wv.ensureBridgeNonce()
457462 if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {
458463 if h.wv != nil && h.wv.ctx != nil {
@@ -1026,6 +1031,12 @@ func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame
10261031 if frame == nil || !frame.IsMain() || request == nil {
10271032 return false
10281033 }
1034+ // Pre-commit invalidation for every main-frame navigation. The allow /
1035+ // cancel decision below is unchanged; subframe navigations never reach
1036+ // this point and do not cancel the main page's gesture.
1037+ if h.wv != nil {
1038+ h.wv.invalidateScrollMotion()
1039+ }
10291040
10301041 handler := h.downloadHandler()
10311042 if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") {
@@ -453,6 +453,11 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
453 if frame == nil || !frame.IsMain() {453 if frame == nil || !frame.IsMain() {
454 return454 return
455 }455 }
456+ // Post-commit fallback for navigation paths that bypass OnBeforeBrowse
457+ // (same-page, error, and helper-driven commits). Main frame only.
458+ if h.wv != nil {
459+ h.wv.invalidateScrollMotion()
460+ }
456 bridgeNonce := h.wv.ensureBridgeNonce()461 bridgeNonce := h.wv.ensureBridgeNonce()
457 if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {462 if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {
458 if h.wv != nil && h.wv.ctx != nil {463 if h.wv != nil && h.wv.ctx != nil {
@@ -1026,6 +1031,12 @@ func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame
1026 if frame == nil || !frame.IsMain() || request == nil {1031 if frame == nil || !frame.IsMain() || request == nil {
1027 return false1032 return false
1028 }1033 }
1034+ // Pre-commit invalidation for every main-frame navigation. The allow /
1035+ // cancel decision below is unchanged; subframe navigations never reach
1036+ // this point and do not cancel the main page's gesture.
1037+ if h.wv != nil {
1038+ h.wv.invalidateScrollMotion()
1039+ }
1029 1040
1030 handler := h.downloadHandler()1041 handler := h.downloadHandler()
1031 if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") {1042 if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") {
M internal/infrastructure/cef/webview_input_test.go +4 -0
@@ -23,6 +23,8 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T)
2323 ScrollHorizontalMultiplier: 0.75,
2424 ScrollVerticalMultiplier: 1.5,
2525 ScrollMaxDelta: 120,
26+ ScrollTouchpadInertia: true,
27+ ScrollWheelSmoothing: true,
2628 TouchpadNavigationEnabled: true,
2729 TouchpadNavigationMinDelta: 80,
2830 TouchpadNavigationMaxVerticalRatio: 0.5,
@@ -37,6 +39,8 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T)
3739 require.InDelta(t, 0.75, opts.Scroll.HorizontalMultiplier, 0.001)
3840 require.InDelta(t, 1.5, opts.Scroll.VerticalMultiplier, 0.001)
3941 require.Equal(t, int32(120), opts.Scroll.MaxDelta)
42+ require.True(t, opts.Scroll.TouchpadInertia)
43+ require.True(t, opts.Scroll.WheelSmoothing)
4044 require.NotNil(t, opts.OnMiddleClick)
4145 require.NotNil(t, opts.OnScroll)
4246 require.False(t, opts.NavigationSwipe.Enabled)
@@ -23,6 +23,8 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T)
23 ScrollHorizontalMultiplier: 0.75,23 ScrollHorizontalMultiplier: 0.75,
24 ScrollVerticalMultiplier: 1.5,24 ScrollVerticalMultiplier: 1.5,
25 ScrollMaxDelta: 120,25 ScrollMaxDelta: 120,
26+ ScrollTouchpadInertia: true,
27+ ScrollWheelSmoothing: true,
26 TouchpadNavigationEnabled: true,28 TouchpadNavigationEnabled: true,
27 TouchpadNavigationMinDelta: 80,29 TouchpadNavigationMinDelta: 80,
28 TouchpadNavigationMaxVerticalRatio: 0.5,30 TouchpadNavigationMaxVerticalRatio: 0.5,
@@ -37,6 +39,8 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T)
37 require.InDelta(t, 0.75, opts.Scroll.HorizontalMultiplier, 0.001)39 require.InDelta(t, 0.75, opts.Scroll.HorizontalMultiplier, 0.001)
38 require.InDelta(t, 1.5, opts.Scroll.VerticalMultiplier, 0.001)40 require.InDelta(t, 1.5, opts.Scroll.VerticalMultiplier, 0.001)
39 require.Equal(t, int32(120), opts.Scroll.MaxDelta)41 require.Equal(t, int32(120), opts.Scroll.MaxDelta)
42+ require.True(t, opts.Scroll.TouchpadInertia)
43+ require.True(t, opts.Scroll.WheelSmoothing)
40 require.NotNil(t, opts.OnMiddleClick)44 require.NotNil(t, opts.OnMiddleClick)
41 require.NotNil(t, opts.OnScroll)45 require.NotNil(t, opts.OnScroll)
42 require.False(t, opts.NavigationSwipe.Enabled)46 require.False(t, opts.NavigationSwipe.Enabled)
A internal/infrastructure/cef/webview_scroll_cancel.go +106 -0
@@ -0,0 +1,106 @@
1+package cef
2+
3+import (
4+ "github.com/bnema/dumber/internal/shared/syncdispatch"
5+)
6+
7+// This file owns inertial-scroll cancellation ordering for CEF WebViews.
8+//
9+// Threading contract: scrollMotionRetired is safe from any thread (it only
10+// bumps the library epoch and never touches GTK). GTK cleanup runs either
11+// inline inside runNavigationWithScrollCancel's ordered GTK operation or via
12+// runOnGTK-queued epoch-scoped cleanup. Library imports stay inside this
13+// infrastructure boundary; only the Cef2gtkAdapter seam is extended.
14+
15+// scrollCancelBridge is the narrow adapter seam for scroll cancellation.
16+// *Cef2gtkAdapter satisfies it in production; tests inject a recording
17+// fake. It covers only invalidation and epoch-scoped cleanup.
18+type scrollCancelBridge interface {
19+ InvalidateScroll() uint64
20+ CancelScrollEpoch(epoch uint64) bool
21+}
22+
23+// scrollCancelTarget resolves the live adapter for scroll cancellation.
24+func (wv *WebView) scrollCancelTarget() scrollCancelBridge {
25+ if wv == nil {
26+ return nil
27+ }
28+ if wv.scrollCancelSeam != nil {
29+ return wv.scrollCancelSeam
30+ }
31+ if wv.viewBridge == nil {
32+ return nil
33+ }
34+ return wv.viewBridge
35+}
36+
37+// scrollMotionRetired immediately retires animated scroll motion and release
38+// eligibility, returning the retired epoch for epoch-scoped GTK cleanup.
39+// Safe to call from any thread, including during destruction: a destroyed
40+// or absent bridge reports zero, so no destroyed check is needed here.
41+func (wv *WebView) scrollMotionRetired() uint64 {
42+ target := wv.scrollCancelTarget()
43+ if wv == nil || target == nil {
44+ return 0
45+ }
46+ return target.InvalidateScroll()
47+}
48+
49+// cancelScrollEpochOnGTK performs GTK-only cleanup for a retired epoch. It
50+// never clears a newer session. Call only on the GTK thread.
51+func (wv *WebView) cancelScrollEpochOnGTK(epoch uint64) {
52+ if wv == nil || epoch == 0 {
53+ return
54+ }
55+ target := wv.scrollCancelTarget()
56+ if target == nil {
57+ return
58+ }
59+ target.CancelScrollEpoch(epoch)
60+}
61+
62+// invalidateScrollMotion retires motion immediately and queues epoch-scoped
63+// GTK cleanup through the owning dispatcher. Safe to call from any thread;
64+// used by lifecycle callbacks (load handlers, visibility) where navigation
65+// dispatch is not part of the call.
66+func (wv *WebView) invalidateScrollMotion() uint64 {
67+ epoch := wv.scrollMotionRetired()
68+ if epoch == 0 {
69+ return 0
70+ }
71+ target := wv.scrollCancelTarget()
72+ wv.runOnGTK(func() {
73+ if target == nil {
74+ return
75+ }
76+ target.CancelScrollEpoch(epoch)
77+ })
78+ return epoch
79+}
80+
81+// runNavigationWithScrollCancel invalidates scroll motion immediately, then
82+// performs GTK cleanup followed by the existing navigation dispatch within
83+// one ordered GTK operation. Calls already on the GTK thread execute inline;
84+// off-thread calls use the existing bounded synchronous dispatch, which
85+// skips abandoned queued work so a timed-out dispatch can never navigate
86+// late. Dispatch errors preserve the command's existing error contract;
87+// dispatch failure reports the existing GTK-sync error form.
88+func (wv *WebView) runNavigationWithScrollCancel(label string, dispatch func() error) error {
89+ if wv.destroyed.Load() {
90+ return errDestroyed
91+ }
92+ epoch := wv.scrollMotionRetired()
93+ var dispatchErr error
94+ result := wv.runOnGTKSyncLabel(label, func() {
95+ wv.cancelScrollEpochOnGTK(epoch)
96+ dispatchErr = dispatch()
97+ })
98+ switch result.Status {
99+ case syncdispatch.SyncDispatchInline,
100+ syncdispatch.SyncDispatchCompleted,
101+ syncdispatch.SyncDispatchCompletedAfterTimeout:
102+ return dispatchErr
103+ default:
104+ return errGTKSyncDispatchIncomplete(label, result)
105+ }
106+}
@@ -0,0 +1,106 @@
1+package cef
2+
3+import (
4+ "github.com/bnema/dumber/internal/shared/syncdispatch"
5+)
6+
7+// This file owns inertial-scroll cancellation ordering for CEF WebViews.
8+//
9+// Threading contract: scrollMotionRetired is safe from any thread (it only
10+// bumps the library epoch and never touches GTK). GTK cleanup runs either
11+// inline inside runNavigationWithScrollCancel's ordered GTK operation or via
12+// runOnGTK-queued epoch-scoped cleanup. Library imports stay inside this
13+// infrastructure boundary; only the Cef2gtkAdapter seam is extended.
14+
15+// scrollCancelBridge is the narrow adapter seam for scroll cancellation.
16+// *Cef2gtkAdapter satisfies it in production; tests inject a recording
17+// fake. It covers only invalidation and epoch-scoped cleanup.
18+type scrollCancelBridge interface {
19+ InvalidateScroll() uint64
20+ CancelScrollEpoch(epoch uint64) bool
21+}
22+
23+// scrollCancelTarget resolves the live adapter for scroll cancellation.
24+func (wv *WebView) scrollCancelTarget() scrollCancelBridge {
25+ if wv == nil {
26+ return nil
27+ }
28+ if wv.scrollCancelSeam != nil {
29+ return wv.scrollCancelSeam
30+ }
31+ if wv.viewBridge == nil {
32+ return nil
33+ }
34+ return wv.viewBridge
35+}
36+
37+// scrollMotionRetired immediately retires animated scroll motion and release
38+// eligibility, returning the retired epoch for epoch-scoped GTK cleanup.
39+// Safe to call from any thread, including during destruction: a destroyed
40+// or absent bridge reports zero, so no destroyed check is needed here.
41+func (wv *WebView) scrollMotionRetired() uint64 {
42+ target := wv.scrollCancelTarget()
43+ if wv == nil || target == nil {
44+ return 0
45+ }
46+ return target.InvalidateScroll()
47+}
48+
49+// cancelScrollEpochOnGTK performs GTK-only cleanup for a retired epoch. It
50+// never clears a newer session. Call only on the GTK thread.
51+func (wv *WebView) cancelScrollEpochOnGTK(epoch uint64) {
52+ if wv == nil || epoch == 0 {
53+ return
54+ }
55+ target := wv.scrollCancelTarget()
56+ if target == nil {
57+ return
58+ }
59+ target.CancelScrollEpoch(epoch)
60+}
61+
62+// invalidateScrollMotion retires motion immediately and queues epoch-scoped
63+// GTK cleanup through the owning dispatcher. Safe to call from any thread;
64+// used by lifecycle callbacks (load handlers, visibility) where navigation
65+// dispatch is not part of the call.
66+func (wv *WebView) invalidateScrollMotion() uint64 {
67+ epoch := wv.scrollMotionRetired()
68+ if epoch == 0 {
69+ return 0
70+ }
71+ target := wv.scrollCancelTarget()
72+ wv.runOnGTK(func() {
73+ if target == nil {
74+ return
75+ }
76+ target.CancelScrollEpoch(epoch)
77+ })
78+ return epoch
79+}
80+
81+// runNavigationWithScrollCancel invalidates scroll motion immediately, then
82+// performs GTK cleanup followed by the existing navigation dispatch within
83+// one ordered GTK operation. Calls already on the GTK thread execute inline;
84+// off-thread calls use the existing bounded synchronous dispatch, which
85+// skips abandoned queued work so a timed-out dispatch can never navigate
86+// late. Dispatch errors preserve the command's existing error contract;
87+// dispatch failure reports the existing GTK-sync error form.
88+func (wv *WebView) runNavigationWithScrollCancel(label string, dispatch func() error) error {
89+ if wv.destroyed.Load() {
90+ return errDestroyed
91+ }
92+ epoch := wv.scrollMotionRetired()
93+ var dispatchErr error
94+ result := wv.runOnGTKSyncLabel(label, func() {
95+ wv.cancelScrollEpochOnGTK(epoch)
96+ dispatchErr = dispatch()
97+ })
98+ switch result.Status {
99+ case syncdispatch.SyncDispatchInline,
100+ syncdispatch.SyncDispatchCompleted,
101+ syncdispatch.SyncDispatchCompletedAfterTimeout:
102+ return dispatchErr
103+ default:
104+ return errGTKSyncDispatchIncomplete(label, result)
105+ }
106+}
A internal/infrastructure/cef/webview_scroll_cancel_test.go +327 -0
@@ -0,0 +1,327 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
11+ cef2gtk "github.com/bnema/purego-cef2gtk"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// recordingScrollBridge mirrors the library retired-epoch contract: Invalidate
16+// retires the live gesture and returns its epoch while retaining it for
17+// epoch-scoped cleanup; cleanup only clears the matching live gesture,
18+// never a newer one, and reports whether it cleared anything.
19+type recordingScrollBridge struct {
20+ mu sync.Mutex
21+ current uint64
22+ live uint64
23+ invalidations int
24+ cleanups []uint64
25+}
26+
27+func (f *recordingScrollBridge) startGesture() uint64 {
28+ f.mu.Lock()
29+ defer f.mu.Unlock()
30+ f.current++
31+ f.live = f.current
32+ return f.live
33+}
34+
35+func (f *recordingScrollBridge) InvalidateScroll() uint64 {
36+ f.mu.Lock()
37+ defer f.mu.Unlock()
38+ f.invalidations++
39+ // Retain the retired gesture until matching cleanup clears it, like
40+ // the library session that stays stale until cleanupEpoch runs.
41+ return f.live
42+}
43+
44+func (f *recordingScrollBridge) CancelScrollEpoch(epoch uint64) bool {
45+ f.mu.Lock()
46+ defer f.mu.Unlock()
47+ f.cleanups = append(f.cleanups, epoch)
48+ if epoch == 0 || epoch != f.live {
49+ return false
50+ }
51+ f.live = 0
52+ return true
53+}
54+
55+func (f *recordingScrollBridge) stats() (invalidations int, cleanups []uint64, live uint64) {
56+ f.mu.Lock()
57+ defer f.mu.Unlock()
58+ return f.invalidations, append([]uint64(nil), f.cleanups...), f.live
59+}
60+
61+func TestScrollCancelOptionsMapping(t *testing.T) {
62+ wv := &WebView{
63+ inputConfig: RuntimeInputConfig{
64+ ScrollTouchpadInertia: true,
65+ ScrollWheelSmoothing: true,
66+ },
67+ }
68+ opts := wv.bridgeInputOptions()
69+ require.True(t, opts.Scroll.TouchpadInertia)
70+ require.True(t, opts.Scroll.WheelSmoothing)
71+
72+ wvDisabled := &WebView{inputConfig: RuntimeInputConfig{}}
73+ disabled := wvDisabled.bridgeInputOptions()
74+ require.False(t, disabled.Scroll.TouchpadInertia, "explicit false must survive mapping")
75+ require.False(t, disabled.Scroll.WheelSmoothing, "explicit false must survive mapping")
76+}
77+
78+func TestNavCommandGTKInlineInvalidatesBeforeDispatch(t *testing.T) {
79+ fake := &recordingScrollBridge{}
80+ fake.startGesture()
81+ var order []string
82+ browser := cefmocks.NewMockBrowser(t)
83+ browser.EXPECT().Reload().Run(func() { order = append(order, "dispatch") }).Return().Once()
84+
85+ wv := &WebView{ctx: context.Background(), browser: browser, scrollCancelSeam: fakeWrap(fake, &order)}
86+
87+ require.NoError(t, wv.Reload(context.Background()))
88+
89+ invalidations, cleanups, live := fake.stats()
90+ require.Equal(t, 1, invalidations)
91+ require.Len(t, cleanups, 1)
92+ require.Zero(t, live)
93+ require.Equal(t, []string{"invalidate", "cleanup", "dispatch"}, order)
94+}
95+
96+// fakeWrap records invalidate/cleanup ordering around the fake.
97+func fakeWrap(fake *recordingScrollBridge, order *[]string) *orderScrollBridge {
98+ return &orderScrollBridge{fake: fake, order: order}
99+}
100+
101+type orderScrollBridge struct {
102+ fake *recordingScrollBridge
103+ order *[]string
104+}
105+
106+func (o *orderScrollBridge) InvalidateScroll() uint64 {
107+ *o.order = append(*o.order, "invalidate")
108+ return o.fake.InvalidateScroll()
109+}
110+
111+func (o *orderScrollBridge) CancelScrollEpoch(epoch uint64) bool {
112+ *o.order = append(*o.order, "cleanup")
113+ return o.fake.CancelScrollEpoch(epoch)
114+}
115+
116+func TestNavCommandOffThreadDispatchCompletes(t *testing.T) {
117+ fake := &recordingScrollBridge{}
118+ fake.startGesture()
119+ browser := cefmocks.NewMockBrowser(t)
120+ browser.EXPECT().GoBack().Return().Once()
121+
122+ wv := &WebView{
123+ ctx: context.Background(),
124+ engine: &Engine{},
125+ browser: browser,
126+ scrollCancelSeam: fake,
127+ gtkSyncIsOwner: func() bool { return false },
128+ gtkSyncDispatch: func(fn func()) { go fn() },
129+ gtkSyncTimeout: 2 * time.Second,
130+ }
131+
132+ require.NoError(t, wv.GoBack(context.Background()))
133+ invalidations, cleanups, live := fake.stats()
134+ require.Equal(t, 1, invalidations)
135+ require.Len(t, cleanups, 1)
136+ require.Zero(t, live)
137+}
138+
139+func TestNavCommandDispatchTimeoutSkipsNavigation(t *testing.T) {
140+ fake := &recordingScrollBridge{}
141+ fake.startGesture()
142+
143+ wv := &WebView{
144+ ctx: context.Background(),
145+ engine: &Engine{},
146+ scrollCancelSeam: fake,
147+ gtkSyncIsOwner: func() bool { return false },
148+ gtkSyncDispatch: func(func()) {},
149+ gtkSyncTimeout: 20 * time.Millisecond,
150+ }
151+
152+ err := wv.LoadURI(context.Background(), "https://example.com/")
153+ require.Error(t, err)
154+ require.Contains(t, err.Error(), "did not complete")
155+ require.Empty(t, wv.pendingNavigationURI(), "abandoned queued work must never navigate")
156+ invalidations, _, _ := fake.stats()
157+ require.Equal(t, 1, invalidations, "motion retires even when dispatch fails")
158+}
159+
160+func TestNavCommandPreservesErrorContract(t *testing.T) {
161+ fake := &recordingScrollBridge{}
162+ destroyed := &WebView{}
163+ destroyed.destroyed.Store(true)
164+ require.ErrorIs(t, destroyed.Reload(context.Background()), errDestroyed)
165+
166+ noBrowser := &WebView{ctx: context.Background(), scrollCancelSeam: fake}
167+ require.ErrorIs(t, noBrowser.Reload(context.Background()), errNoBrowser)
168+ require.ErrorIs(t, noBrowser.GoBack(context.Background()), errNoBrowser)
169+ require.ErrorIs(t, noBrowser.GoForward(context.Background()), errNoBrowser)
170+ require.ErrorIs(t, noBrowser.ReloadBypassCache(context.Background()), errNoBrowser)
171+ require.ErrorIs(t, noBrowser.LoadHTML(context.Background(), "<p>hi</p>", ""), errNoBrowser)
172+}
173+
174+func TestQueuedCleanupAfterFreshGesture(t *testing.T) {
175+ fake := &recordingScrollBridge{}
176+ wv := &WebView{ctx: context.Background(), scrollCancelSeam: fake}
177+
178+ first := fake.startGesture()
179+ retired := wv.scrollMotionRetired()
180+ require.Equal(t, first, retired)
181+
182+ second := fake.startGesture()
183+ require.NotEqual(t, first, second)
184+ // The stale cleanup cannot cancel the fresh gesture.
185+ wv.cancelScrollEpochOnGTK(retired)
186+ _, _, live := fake.stats()
187+ require.Equal(t, second, live)
188+ require.True(t, func() bool {
189+ fake.mu.Lock()
190+ defer fake.mu.Unlock()
191+ for _, e := range fake.cleanups {
192+ if e == retired {
193+ return true
194+ }
195+ }
196+ return false
197+ }())
198+}
199+
200+func TestInvalidateScrollMotionQueuesEpochCleanup(t *testing.T) {
201+ fake := &recordingScrollBridge{}
202+ wv := &WebView{ctx: context.Background(), scrollCancelSeam: fake}
203+ live := fake.startGesture()
204+
205+ // engine==nil runs runOnGTK inline, so the queued cleanup applies now.
206+ require.Equal(t, live, wv.invalidateScrollMotion())
207+ _, cleanups, current := fake.stats()
208+ require.Equal(t, []uint64{live}, cleanups)
209+ require.Zero(t, current)
210+}
211+
212+func TestPreBrowseInvalidationKeepsDecision(t *testing.T) {
213+ fake := &recordingScrollBridge{}
214+ fake.startGesture()
215+ frame := cefmocks.NewMockFrame(t)
216+ frame.EXPECT().IsMain().Return(true)
217+ // downloadHandler is nil without an engine, so the request method is
218+ // never consulted; the request only needs to be non-nil to reach
219+ // pre-commit invalidation.
220+ request := cefmocks.NewMockRequest(t)
221+ h := &handlerSet{wv: &WebView{ctx: context.Background(), scrollCancelSeam: fake}}
222+
223+ require.False(t, h.OnBeforeBrowse(nil, frame, request, 0, 0))
224+ invalidations, _, _ := fake.stats()
225+ require.Equal(t, 1, invalidations, "main-frame pre-commit invalidates")
226+}
227+
228+func TestPreBrowseSubframeDoesNotCancel(t *testing.T) {
229+ fake := &recordingScrollBridge{}
230+ fake.startGesture()
231+ frame := cefmocks.NewMockFrame(t)
232+ frame.EXPECT().IsMain().Return(false)
233+ h := &handlerSet{wv: &WebView{ctx: context.Background(), scrollCancelSeam: fake}}
234+
235+ require.False(t, h.OnBeforeBrowse(nil, frame, nil, 0, 0))
236+ invalidations, _, live := fake.stats()
237+ require.Zero(t, invalidations, "subframe navigation must not cancel the main gesture")
238+ require.NotZero(t, live)
239+}
240+
241+func TestLoadStartFallbackInvalidates(t *testing.T) {
242+ fake := &recordingScrollBridge{}
243+ fake.startGesture()
244+ frame := cefmocks.NewMockFrame(t)
245+ frame.EXPECT().IsMain().Return(true).Once()
246+ frame.EXPECT().GetURL().Return("https://example.com/").Twice()
247+ h := &handlerSet{wv: &WebView{ctx: context.Background(), scrollCancelSeam: fake}}
248+
249+ h.OnLoadStart(nil, frame, 0)
250+ invalidations, _, _ := fake.stats()
251+ require.Equal(t, 1, invalidations, "post-commit fallback invalidates")
252+}
253+
254+func TestDestroyInvalidatesBeforeNativeClose(t *testing.T) {
255+ fake := &recordingScrollBridge{}
256+ fake.startGesture()
257+ var order []string
258+ host := &orderBrowserHost{order: &order}
259+ wv := &WebView{
260+ viewBridge: &Cef2gtkAdapter{},
261+ host: host,
262+ scrollCancelSeam: &orderInvalidator{fake: fake, order: &order},
263+ }
264+
265+ wv.Destroy()
266+
267+ require.Equal(t, []string{"invalidate", "close"}, order)
268+ _, _, live := fake.stats()
269+ require.Zero(t, live)
270+}
271+
272+type orderInvalidator struct {
273+ fake *recordingScrollBridge
274+ order *[]string
275+}
276+
277+func (o *orderInvalidator) InvalidateScroll() uint64 {
278+ *o.order = append(*o.order, "invalidate")
279+ return o.fake.InvalidateScroll()
280+}
281+
282+func (o *orderInvalidator) CancelScrollEpoch(epoch uint64) bool {
283+ return o.fake.CancelScrollEpoch(epoch)
284+}
285+
286+type orderBrowserHost struct {
287+ purecef.BrowserHost
288+ order *[]string
289+}
290+
291+func (h *orderBrowserHost) CloseBrowser(_ int32) {
292+ *h.order = append(*h.order, "close")
293+}
294+
295+func TestVisibilityHiddenTransitionInvalidates(t *testing.T) {
296+ fake := &recordingScrollBridge{}
297+ fake.startGesture()
298+ host := &viewportSyncOrderHost{}
299+ wv := &WebView{scrollCancelSeam: fake}
300+
301+ wv.applyEffectiveVisibility(host, false)
302+ invalidations, _, live := fake.stats()
303+ require.Equal(t, 1, invalidations)
304+ require.Zero(t, live)
305+
306+ wv.applyEffectiveVisibility(host, true)
307+ invalidations, _, _ = fake.stats()
308+ require.Equal(t, 1, invalidations, "re-showing must not invalidate again")
309+}
310+
311+func TestGestureHistoryCommitInvalidates(t *testing.T) {
312+ fake := &recordingScrollBridge{}
313+ fake.startGesture()
314+ browser := cefmocks.NewMockBrowser(t)
315+ browser.EXPECT().GoBack().Return().Once()
316+ wv := &WebView{
317+ ctx: context.Background(),
318+ browser: browser,
319+ canGoBack: true,
320+ scrollCancelSeam: fake,
321+ }
322+
323+ wv.handleNavigationSwipeAction(cef2gtk.NavigationSwipeBack)
324+
325+ invalidations, _, _ := fake.stats()
326+ require.Equal(t, 1, invalidations, "gesture history commit invalidates")
327+}
@@ -0,0 +1,327 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
11+ cef2gtk "github.com/bnema/purego-cef2gtk"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// recordingScrollBridge mirrors the library retired-epoch contract: Invalidate
16+// retires the live gesture and returns its epoch while retaining it for
17+// epoch-scoped cleanup; cleanup only clears the matching live gesture,
18+// never a newer one, and reports whether it cleared anything.
19+type recordingScrollBridge struct {
20+ mu sync.Mutex
21+ current uint64
22+ live uint64
23+ invalidations int
24+ cleanups []uint64
25+}
26+
27+func (f *recordingScrollBridge) startGesture() uint64 {
28+ f.mu.Lock()
29+ defer f.mu.Unlock()
30+ f.current++
31+ f.live = f.current
32+ return f.live
33+}
34+
35+func (f *recordingScrollBridge) InvalidateScroll() uint64 {
36+ f.mu.Lock()
37+ defer f.mu.Unlock()
38+ f.invalidations++
39+ // Retain the retired gesture until matching cleanup clears it, like
40+ // the library session that stays stale until cleanupEpoch runs.
41+ return f.live
42+}
43+
44+func (f *recordingScrollBridge) CancelScrollEpoch(epoch uint64) bool {
45+ f.mu.Lock()
46+ defer f.mu.Unlock()
47+ f.cleanups = append(f.cleanups, epoch)
48+ if epoch == 0 || epoch != f.live {
49+ return false
50+ }
51+ f.live = 0
52+ return true
53+}
54+
55+func (f *recordingScrollBridge) stats() (invalidations int, cleanups []uint64, live uint64) {
56+ f.mu.Lock()
57+ defer f.mu.Unlock()
58+ return f.invalidations, append([]uint64(nil), f.cleanups...), f.live
59+}
60+
61+func TestScrollCancelOptionsMapping(t *testing.T) {
62+ wv := &WebView{
63+ inputConfig: RuntimeInputConfig{
64+ ScrollTouchpadInertia: true,
65+ ScrollWheelSmoothing: true,
66+ },
67+ }
68+ opts := wv.bridgeInputOptions()
69+ require.True(t, opts.Scroll.TouchpadInertia)
70+ require.True(t, opts.Scroll.WheelSmoothing)
71+
72+ wvDisabled := &WebView{inputConfig: RuntimeInputConfig{}}
73+ disabled := wvDisabled.bridgeInputOptions()
74+ require.False(t, disabled.Scroll.TouchpadInertia, "explicit false must survive mapping")
75+ require.False(t, disabled.Scroll.WheelSmoothing, "explicit false must survive mapping")
76+}
77+
78+func TestNavCommandGTKInlineInvalidatesBeforeDispatch(t *testing.T) {
79+ fake := &recordingScrollBridge{}
80+ fake.startGesture()
81+ var order []string
82+ browser := cefmocks.NewMockBrowser(t)
83+ browser.EXPECT().Reload().Run(func() { order = append(order, "dispatch") }).Return().Once()
84+
85+ wv := &WebView{ctx: context.Background(), browser: browser, scrollCancelSeam: fakeWrap(fake, &order)}
86+
87+ require.NoError(t, wv.Reload(context.Background()))
88+
89+ invalidations, cleanups, live := fake.stats()
90+ require.Equal(t, 1, invalidations)
91+ require.Len(t, cleanups, 1)
92+ require.Zero(t, live)
93+ require.Equal(t, []string{"invalidate", "cleanup", "dispatch"}, order)
94+}
95+
96+// fakeWrap records invalidate/cleanup ordering around the fake.
97+func fakeWrap(fake *recordingScrollBridge, order *[]string) *orderScrollBridge {
98+ return &orderScrollBridge{fake: fake, order: order}
99+}
100+
101+type orderScrollBridge struct {
102+ fake *recordingScrollBridge
103+ order *[]string
104+}
105+
106+func (o *orderScrollBridge) InvalidateScroll() uint64 {
107+ *o.order = append(*o.order, "invalidate")
108+ return o.fake.InvalidateScroll()
109+}
110+
111+func (o *orderScrollBridge) CancelScrollEpoch(epoch uint64) bool {
112+ *o.order = append(*o.order, "cleanup")
113+ return o.fake.CancelScrollEpoch(epoch)
114+}
115+
116+func TestNavCommandOffThreadDispatchCompletes(t *testing.T) {
117+ fake := &recordingScrollBridge{}
118+ fake.startGesture()
119+ browser := cefmocks.NewMockBrowser(t)
120+ browser.EXPECT().GoBack().Return().Once()
121+
122+ wv := &WebView{
123+ ctx: context.Background(),
124+ engine: &Engine{},
125+ browser: browser,
126+ scrollCancelSeam: fake,
127+ gtkSyncIsOwner: func() bool { return false },
128+ gtkSyncDispatch: func(fn func()) { go fn() },
129+ gtkSyncTimeout: 2 * time.Second,
130+ }
131+
132+ require.NoError(t, wv.GoBack(context.Background()))
133+ invalidations, cleanups, live := fake.stats()
134+ require.Equal(t, 1, invalidations)
135+ require.Len(t, cleanups, 1)
136+ require.Zero(t, live)
137+}
138+
139+func TestNavCommandDispatchTimeoutSkipsNavigation(t *testing.T) {
140+ fake := &recordingScrollBridge{}
141+ fake.startGesture()
142+
143+ wv := &WebView{
144+ ctx: context.Background(),
145+ engine: &Engine{},
146+ scrollCancelSeam: fake,
147+ gtkSyncIsOwner: func() bool { return false },
148+ gtkSyncDispatch: func(func()) {},
149+ gtkSyncTimeout: 20 * time.Millisecond,
150+ }
151+
152+ err := wv.LoadURI(context.Background(), "https://example.com/")
153+ require.Error(t, err)
154+ require.Contains(t, err.Error(), "did not complete")
155+ require.Empty(t, wv.pendingNavigationURI(), "abandoned queued work must never navigate")
156+ invalidations, _, _ := fake.stats()
157+ require.Equal(t, 1, invalidations, "motion retires even when dispatch fails")
158+}
159+
160+func TestNavCommandPreservesErrorContract(t *testing.T) {
161+ fake := &recordingScrollBridge{}
162+ destroyed := &WebView{}
163+ destroyed.destroyed.Store(true)
164+ require.ErrorIs(t, destroyed.Reload(context.Background()), errDestroyed)
165+
166+ noBrowser := &WebView{ctx: context.Background(), scrollCancelSeam: fake}
167+ require.ErrorIs(t, noBrowser.Reload(context.Background()), errNoBrowser)
168+ require.ErrorIs(t, noBrowser.GoBack(context.Background()), errNoBrowser)
169+ require.ErrorIs(t, noBrowser.GoForward(context.Background()), errNoBrowser)
170+ require.ErrorIs(t, noBrowser.ReloadBypassCache(context.Background()), errNoBrowser)
171+ require.ErrorIs(t, noBrowser.LoadHTML(context.Background(), "<p>hi</p>", ""), errNoBrowser)
172+}
173+
174+func TestQueuedCleanupAfterFreshGesture(t *testing.T) {
175+ fake := &recordingScrollBridge{}
176+ wv := &WebView{ctx: context.Background(), scrollCancelSeam: fake}
177+
178+ first := fake.startGesture()
179+ retired := wv.scrollMotionRetired()
180+ require.Equal(t, first, retired)
181+
182+ second := fake.startGesture()
183+ require.NotEqual(t, first, second)
184+ // The stale cleanup cannot cancel the fresh gesture.
185+ wv.cancelScrollEpochOnGTK(retired)
186+ _, _, live := fake.stats()
187+ require.Equal(t, second, live)
188+ require.True(t, func() bool {
189+ fake.mu.Lock()
190+ defer fake.mu.Unlock()
191+ for _, e := range fake.cleanups {
192+ if e == retired {
193+ return true
194+ }
195+ }
196+ return false
197+ }())
198+}
199+
200+func TestInvalidateScrollMotionQueuesEpochCleanup(t *testing.T) {
201+ fake := &recordingScrollBridge{}
202+ wv := &WebView{ctx: context.Background(), scrollCancelSeam: fake}
203+ live := fake.startGesture()
204+
205+ // engine==nil runs runOnGTK inline, so the queued cleanup applies now.
206+ require.Equal(t, live, wv.invalidateScrollMotion())
207+ _, cleanups, current := fake.stats()
208+ require.Equal(t, []uint64{live}, cleanups)
209+ require.Zero(t, current)
210+}
211+
212+func TestPreBrowseInvalidationKeepsDecision(t *testing.T) {
213+ fake := &recordingScrollBridge{}
214+ fake.startGesture()
215+ frame := cefmocks.NewMockFrame(t)
216+ frame.EXPECT().IsMain().Return(true)
217+ // downloadHandler is nil without an engine, so the request method is
218+ // never consulted; the request only needs to be non-nil to reach
219+ // pre-commit invalidation.
220+ request := cefmocks.NewMockRequest(t)
221+ h := &handlerSet{wv: &WebView{ctx: context.Background(), scrollCancelSeam: fake}}
222+
223+ require.False(t, h.OnBeforeBrowse(nil, frame, request, 0, 0))
224+ invalidations, _, _ := fake.stats()
225+ require.Equal(t, 1, invalidations, "main-frame pre-commit invalidates")
226+}
227+
228+func TestPreBrowseSubframeDoesNotCancel(t *testing.T) {
229+ fake := &recordingScrollBridge{}
230+ fake.startGesture()
231+ frame := cefmocks.NewMockFrame(t)
232+ frame.EXPECT().IsMain().Return(false)
233+ h := &handlerSet{wv: &WebView{ctx: context.Background(), scrollCancelSeam: fake}}
234+
235+ require.False(t, h.OnBeforeBrowse(nil, frame, nil, 0, 0))
236+ invalidations, _, live := fake.stats()
237+ require.Zero(t, invalidations, "subframe navigation must not cancel the main gesture")
238+ require.NotZero(t, live)
239+}
240+
241+func TestLoadStartFallbackInvalidates(t *testing.T) {
242+ fake := &recordingScrollBridge{}
243+ fake.startGesture()
244+ frame := cefmocks.NewMockFrame(t)
245+ frame.EXPECT().IsMain().Return(true).Once()
246+ frame.EXPECT().GetURL().Return("https://example.com/").Twice()
247+ h := &handlerSet{wv: &WebView{ctx: context.Background(), scrollCancelSeam: fake}}
248+
249+ h.OnLoadStart(nil, frame, 0)
250+ invalidations, _, _ := fake.stats()
251+ require.Equal(t, 1, invalidations, "post-commit fallback invalidates")
252+}
253+
254+func TestDestroyInvalidatesBeforeNativeClose(t *testing.T) {
255+ fake := &recordingScrollBridge{}
256+ fake.startGesture()
257+ var order []string
258+ host := &orderBrowserHost{order: &order}
259+ wv := &WebView{
260+ viewBridge: &Cef2gtkAdapter{},
261+ host: host,
262+ scrollCancelSeam: &orderInvalidator{fake: fake, order: &order},
263+ }
264+
265+ wv.Destroy()
266+
267+ require.Equal(t, []string{"invalidate", "close"}, order)
268+ _, _, live := fake.stats()
269+ require.Zero(t, live)
270+}
271+
272+type orderInvalidator struct {
273+ fake *recordingScrollBridge
274+ order *[]string
275+}
276+
277+func (o *orderInvalidator) InvalidateScroll() uint64 {
278+ *o.order = append(*o.order, "invalidate")
279+ return o.fake.InvalidateScroll()
280+}
281+
282+func (o *orderInvalidator) CancelScrollEpoch(epoch uint64) bool {
283+ return o.fake.CancelScrollEpoch(epoch)
284+}
285+
286+type orderBrowserHost struct {
287+ purecef.BrowserHost
288+ order *[]string
289+}
290+
291+func (h *orderBrowserHost) CloseBrowser(_ int32) {
292+ *h.order = append(*h.order, "close")
293+}
294+
295+func TestVisibilityHiddenTransitionInvalidates(t *testing.T) {
296+ fake := &recordingScrollBridge{}
297+ fake.startGesture()
298+ host := &viewportSyncOrderHost{}
299+ wv := &WebView{scrollCancelSeam: fake}
300+
301+ wv.applyEffectiveVisibility(host, false)
302+ invalidations, _, live := fake.stats()
303+ require.Equal(t, 1, invalidations)
304+ require.Zero(t, live)
305+
306+ wv.applyEffectiveVisibility(host, true)
307+ invalidations, _, _ = fake.stats()
308+ require.Equal(t, 1, invalidations, "re-showing must not invalidate again")
309+}
310+
311+func TestGestureHistoryCommitInvalidates(t *testing.T) {
312+ fake := &recordingScrollBridge{}
313+ fake.startGesture()
314+ browser := cefmocks.NewMockBrowser(t)
315+ browser.EXPECT().GoBack().Return().Once()
316+ wv := &WebView{
317+ ctx: context.Background(),
318+ browser: browser,
319+ canGoBack: true,
320+ scrollCancelSeam: fake,
321+ }
322+
323+ wv.handleNavigationSwipeAction(cef2gtk.NavigationSwipeBack)
324+
325+ invalidations, _, _ := fake.stats()
326+ require.Equal(t, 1, invalidations, "gesture history commit invalidates")
327+}
A internal/infrastructure/cef/webview_scroll_inject.go +46 -0
@@ -0,0 +1,46 @@
1+package cef
2+
3+// This file owns inertial-scroll injection for keyboard-driven page scrolls
4+// (Vim Mode J/K, arrows). A scroll request becomes one engine impulse per
5+// press or held-key step: repeats join the live wheel burst and the tail
6+// coasts after release, so key scrolling feels like wheel scrolling.
7+//
8+// Threading contract: InjectScroll hops to the GTK thread internally and is
9+// safe from any thread. Key release sends nothing on purpose: canceling
10+// the burst would kill the coast, so CancelPageScroll only drains the
11+// legacy JS queue (which stays empty while injection succeeds).
12+
13+// scrollInjectBridge is the narrow adapter seam for scroll injection.
14+// *Cef2gtkAdapter satisfies it in production; tests inject a recording
15+// fake. It covers only impulse acceptance, mirroring scrollCancelBridge.
16+type scrollInjectBridge interface {
17+ InjectScroll(dx, dy float64) bool
18+}
19+
20+// scrollInjectTarget resolves the live adapter for scroll injection.
21+func (wv *WebView) scrollInjectTarget() scrollInjectBridge {
22+ if wv == nil {
23+ return nil
24+ }
25+ if wv.scrollInjectSeam != nil {
26+ return wv.scrollInjectSeam
27+ }
28+ if wv.viewBridge == nil {
29+ return nil
30+ }
31+ return wv.viewBridge
32+}
33+
34+// injectPageScroll forwards one key-scroll step to the inertial engine.
35+// It reports whether the engine accepted the impulse; callers fall back to
36+// the legacy queue when it does not (smoothing disabled, no bridge).
37+func (wv *WebView) injectPageScroll(deltaX, deltaY int32) bool {
38+ if wv == nil || !wv.inputConfig.ScrollWheelSmoothing {
39+ return false
40+ }
41+ target := wv.scrollInjectTarget()
42+ if target == nil {
43+ return false
44+ }
45+ return target.InjectScroll(float64(deltaX), float64(deltaY))
46+}
@@ -0,0 +1,46 @@
1+package cef
2+
3+// This file owns inertial-scroll injection for keyboard-driven page scrolls
4+// (Vim Mode J/K, arrows). A scroll request becomes one engine impulse per
5+// press or held-key step: repeats join the live wheel burst and the tail
6+// coasts after release, so key scrolling feels like wheel scrolling.
7+//
8+// Threading contract: InjectScroll hops to the GTK thread internally and is
9+// safe from any thread. Key release sends nothing on purpose: canceling
10+// the burst would kill the coast, so CancelPageScroll only drains the
11+// legacy JS queue (which stays empty while injection succeeds).
12+
13+// scrollInjectBridge is the narrow adapter seam for scroll injection.
14+// *Cef2gtkAdapter satisfies it in production; tests inject a recording
15+// fake. It covers only impulse acceptance, mirroring scrollCancelBridge.
16+type scrollInjectBridge interface {
17+ InjectScroll(dx, dy float64) bool
18+}
19+
20+// scrollInjectTarget resolves the live adapter for scroll injection.
21+func (wv *WebView) scrollInjectTarget() scrollInjectBridge {
22+ if wv == nil {
23+ return nil
24+ }
25+ if wv.scrollInjectSeam != nil {
26+ return wv.scrollInjectSeam
27+ }
28+ if wv.viewBridge == nil {
29+ return nil
30+ }
31+ return wv.viewBridge
32+}
33+
34+// injectPageScroll forwards one key-scroll step to the inertial engine.
35+// It reports whether the engine accepted the impulse; callers fall back to
36+// the legacy queue when it does not (smoothing disabled, no bridge).
37+func (wv *WebView) injectPageScroll(deltaX, deltaY int32) bool {
38+ if wv == nil || !wv.inputConfig.ScrollWheelSmoothing {
39+ return false
40+ }
41+ target := wv.scrollInjectTarget()
42+ if target == nil {
43+ return false
44+ }
45+ return target.InjectScroll(float64(deltaX), float64(deltaY))
46+}
A internal/infrastructure/cef/webview_scroll_inject_test.go +115 -0
@@ -0,0 +1,115 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+type recordingInjectBridge struct {
11+ accepted bool
12+ calls [][2]float64
13+}
14+
15+func (f *recordingInjectBridge) InjectScroll(dx, dy float64) bool {
16+ f.calls = append(f.calls, [2]float64{dx, dy})
17+ return f.accepted
18+}
19+
20+func TestScrollPage_SmoothingOn_InjectsImpulse(t *testing.T) {
21+ host := &scrollRecorderHost{}
22+ fake := &recordingInjectBridge{accepted: true}
23+ wv := &WebView{
24+ host: host,
25+ inputConfig: RuntimeInputConfig{ScrollWheelSmoothing: true},
26+ scrollInjectSeam: fake,
27+ }
28+
29+ req := port.PageScrollRequest{
30+ Command: port.PageScrollCommandDown,
31+ FallbackDX: 0,
32+ FallbackDY: 80,
33+ }
34+ if err := wv.ScrollPage(context.Background(), req); err != nil {
35+ t.Fatalf("ScrollPage() unexpected error: %v", err)
36+ }
37+ if len(fake.calls) != 1 {
38+ t.Fatalf("inject calls = %d, want 1", len(fake.calls))
39+ }
40+ if fake.calls[0][0] != 0 || fake.calls[0][1] != -80 {
41+ t.Fatalf("inject deltas = %v, want (0,-80) CEF wheel sign", fake.calls[0])
42+ }
43+ if len(host.recordedEvents()) != 0 {
44+ t.Fatal("injected scroll leaked into the legacy native path")
45+ }
46+ if q := &wv.pageScrollQueue; q.tapDX != 0 || q.tapDY != 0 || q.heldDX != 0 || q.heldDY != 0 {
47+ t.Fatal("injected scroll leaked into the legacy JS queue")
48+ }
49+}
50+
51+func TestScrollPage_SmoothingOn_InjectRejectedFallsBack(t *testing.T) {
52+ host := &scrollRecorderHost{}
53+ fake := &recordingInjectBridge{accepted: false}
54+ wv := &WebView{
55+ host: host,
56+ inputConfig: RuntimeInputConfig{ScrollWheelSmoothing: true},
57+ scrollInjectSeam: fake,
58+ }
59+
60+ req := port.PageScrollRequest{
61+ Command: port.PageScrollCommandDown,
62+ FallbackDX: 0,
63+ FallbackDY: 80,
64+ }
65+ if err := wv.ScrollPage(context.Background(), req); err != nil {
66+ t.Fatalf("ScrollPage() unexpected error: %v", err)
67+ }
68+ if len(host.recordedEvents()) != 1 {
69+ t.Fatalf("expected legacy fallback wheel event, got %d", len(host.recordedEvents()))
70+ }
71+}
72+
73+func TestScrollPage_SmoothingOff_SkipsInjection(t *testing.T) {
74+ host := &scrollRecorderHost{}
75+ fake := &recordingInjectBridge{accepted: true}
76+ wv := &WebView{
77+ host: host,
78+ scrollInjectSeam: fake,
79+ }
80+
81+ req := port.PageScrollRequest{
82+ Command: port.PageScrollCommandDown,
83+ FallbackDX: 0,
84+ FallbackDY: 80,
85+ }
86+ if err := wv.ScrollPage(context.Background(), req); err != nil {
87+ t.Fatalf("ScrollPage() unexpected error: %v", err)
88+ }
89+ if len(fake.calls) != 0 {
90+ t.Fatal("injection ran with smoothing disabled")
91+ }
92+ if len(host.recordedEvents()) != 1 {
93+ t.Fatalf("expected legacy wheel event, got %d", len(host.recordedEvents()))
94+ }
95+}
96+
97+func TestScrollPage_SmoothingOn_NoBridgeFallsBack(t *testing.T) {
98+ host := &scrollRecorderHost{}
99+ wv := &WebView{
100+ host: host,
101+ inputConfig: RuntimeInputConfig{ScrollWheelSmoothing: true},
102+ }
103+
104+ req := port.PageScrollRequest{
105+ Command: port.PageScrollCommandUpFast,
106+ FallbackDY: -320,
107+ }
108+ if err := wv.ScrollPage(context.Background(), req); err != nil {
109+ t.Fatalf("ScrollPage() unexpected error: %v", err)
110+ }
111+ events := host.recordedEvents()
112+ if len(events) != 1 || events[0].deltaY != 320 {
113+ t.Fatalf("expected legacy fallback (0,320), got %+v", events)
114+ }
115+}
@@ -0,0 +1,115 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+type recordingInjectBridge struct {
11+ accepted bool
12+ calls [][2]float64
13+}
14+
15+func (f *recordingInjectBridge) InjectScroll(dx, dy float64) bool {
16+ f.calls = append(f.calls, [2]float64{dx, dy})
17+ return f.accepted
18+}
19+
20+func TestScrollPage_SmoothingOn_InjectsImpulse(t *testing.T) {
21+ host := &scrollRecorderHost{}
22+ fake := &recordingInjectBridge{accepted: true}
23+ wv := &WebView{
24+ host: host,
25+ inputConfig: RuntimeInputConfig{ScrollWheelSmoothing: true},
26+ scrollInjectSeam: fake,
27+ }
28+
29+ req := port.PageScrollRequest{
30+ Command: port.PageScrollCommandDown,
31+ FallbackDX: 0,
32+ FallbackDY: 80,
33+ }
34+ if err := wv.ScrollPage(context.Background(), req); err != nil {
35+ t.Fatalf("ScrollPage() unexpected error: %v", err)
36+ }
37+ if len(fake.calls) != 1 {
38+ t.Fatalf("inject calls = %d, want 1", len(fake.calls))
39+ }
40+ if fake.calls[0][0] != 0 || fake.calls[0][1] != -80 {
41+ t.Fatalf("inject deltas = %v, want (0,-80) CEF wheel sign", fake.calls[0])
42+ }
43+ if len(host.recordedEvents()) != 0 {
44+ t.Fatal("injected scroll leaked into the legacy native path")
45+ }
46+ if q := &wv.pageScrollQueue; q.tapDX != 0 || q.tapDY != 0 || q.heldDX != 0 || q.heldDY != 0 {
47+ t.Fatal("injected scroll leaked into the legacy JS queue")
48+ }
49+}
50+
51+func TestScrollPage_SmoothingOn_InjectRejectedFallsBack(t *testing.T) {
52+ host := &scrollRecorderHost{}
53+ fake := &recordingInjectBridge{accepted: false}
54+ wv := &WebView{
55+ host: host,
56+ inputConfig: RuntimeInputConfig{ScrollWheelSmoothing: true},
57+ scrollInjectSeam: fake,
58+ }
59+
60+ req := port.PageScrollRequest{
61+ Command: port.PageScrollCommandDown,
62+ FallbackDX: 0,
63+ FallbackDY: 80,
64+ }
65+ if err := wv.ScrollPage(context.Background(), req); err != nil {
66+ t.Fatalf("ScrollPage() unexpected error: %v", err)
67+ }
68+ if len(host.recordedEvents()) != 1 {
69+ t.Fatalf("expected legacy fallback wheel event, got %d", len(host.recordedEvents()))
70+ }
71+}
72+
73+func TestScrollPage_SmoothingOff_SkipsInjection(t *testing.T) {
74+ host := &scrollRecorderHost{}
75+ fake := &recordingInjectBridge{accepted: true}
76+ wv := &WebView{
77+ host: host,
78+ scrollInjectSeam: fake,
79+ }
80+
81+ req := port.PageScrollRequest{
82+ Command: port.PageScrollCommandDown,
83+ FallbackDX: 0,
84+ FallbackDY: 80,
85+ }
86+ if err := wv.ScrollPage(context.Background(), req); err != nil {
87+ t.Fatalf("ScrollPage() unexpected error: %v", err)
88+ }
89+ if len(fake.calls) != 0 {
90+ t.Fatal("injection ran with smoothing disabled")
91+ }
92+ if len(host.recordedEvents()) != 1 {
93+ t.Fatalf("expected legacy wheel event, got %d", len(host.recordedEvents()))
94+ }
95+}
96+
97+func TestScrollPage_SmoothingOn_NoBridgeFallsBack(t *testing.T) {
98+ host := &scrollRecorderHost{}
99+ wv := &WebView{
100+ host: host,
101+ inputConfig: RuntimeInputConfig{ScrollWheelSmoothing: true},
102+ }
103+
104+ req := port.PageScrollRequest{
105+ Command: port.PageScrollCommandUpFast,
106+ FallbackDY: -320,
107+ }
108+ if err := wv.ScrollPage(context.Background(), req); err != nil {
109+ t.Fatalf("ScrollPage() unexpected error: %v", err)
110+ }
111+ events := host.recordedEvents()
112+ if len(events) != 1 || events[0].deltaY != 320 {
113+ t.Fatalf("expected legacy fallback (0,320), got %+v", events)
114+ }
115+}
M internal/infrastructure/cef/webview_viewport.go +4 -0
@@ -596,6 +596,10 @@ func (wv *WebView) applyEffectiveVisibility(host viewportSyncBrowserHost, visibl
596596 host.WasHidden(0)
597597 return
598598 }
599+ // The hidden transition retires scroll motion: a re-shown page never
600+ // resumes old motion, and delayed cleanup cannot cancel a newer visible
601+ // session because cleanup is epoch-scoped.
602+ wv.invalidateScrollMotion()
599603 host.WasHidden(1)
600604 }
601605
@@ -596,6 +596,10 @@ func (wv *WebView) applyEffectiveVisibility(host viewportSyncBrowserHost, visibl
596 host.WasHidden(0)596 host.WasHidden(0)
597 return597 return
598 }598 }
599+ // The hidden transition retires scroll motion: a re-shown page never
600+ // resumes old motion, and delayed cleanup cannot cancel a newer visible
601+ // session because cleanup is epoch-scoped.
602+ wv.invalidateScrollMotion()
599 host.WasHidden(1)603 host.WasHidden(1)
600 }604 }
601 605
M internal/infrastructure/config/defaults.go +4 -0
@@ -92,6 +92,8 @@ const (
9292 defaultCEFScrollMultiplier = 1.0
9393 defaultCEFScrollPreciseMultiplier = 2.5
9494 defaultCEFScrollMaxDelta = 0
95+ defaultCEFScrollTouchpadInertia = true
96+ defaultCEFScrollWheelSmoothing = true
9597 defaultCEFTouchpadNavigation = true
9698 // Require a deliberate swipe before history navigation so ordinary
9799 // horizontal scrolling is unlikely to cross the commit threshold.
@@ -246,6 +248,8 @@ func DefaultConfig() *Config {
246248 ScrollHorizontalMultiplier: defaultCEFScrollMultiplier,
247249 ScrollVerticalMultiplier: defaultCEFScrollMultiplier,
248250 ScrollMaxDelta: defaultCEFScrollMaxDelta,
251+ ScrollTouchpadInertia: defaultCEFScrollTouchpadInertia,
252+ ScrollWheelSmoothing: defaultCEFScrollWheelSmoothing,
249253 TouchpadNavigationEnabled: defaultCEFTouchpadNavigation,
250254 TouchpadNavigationMinDelta: defaultCEFTouchpadNavigationDelta,
251255 TouchpadNavigationMaxVerticalRatio: defaultCEFTouchpadNavigationRatio,
@@ -92,6 +92,8 @@ const (
92 defaultCEFScrollMultiplier = 1.092 defaultCEFScrollMultiplier = 1.0
93 defaultCEFScrollPreciseMultiplier = 2.593 defaultCEFScrollPreciseMultiplier = 2.5
94 defaultCEFScrollMaxDelta = 094 defaultCEFScrollMaxDelta = 0
95+ defaultCEFScrollTouchpadInertia = true
96+ defaultCEFScrollWheelSmoothing = true
95 defaultCEFTouchpadNavigation = true97 defaultCEFTouchpadNavigation = true
96 // Require a deliberate swipe before history navigation so ordinary98 // Require a deliberate swipe before history navigation so ordinary
97 // horizontal scrolling is unlikely to cross the commit threshold.99 // horizontal scrolling is unlikely to cross the commit threshold.
@@ -246,6 +248,8 @@ func DefaultConfig() *Config {
246 ScrollHorizontalMultiplier: defaultCEFScrollMultiplier,248 ScrollHorizontalMultiplier: defaultCEFScrollMultiplier,
247 ScrollVerticalMultiplier: defaultCEFScrollMultiplier,249 ScrollVerticalMultiplier: defaultCEFScrollMultiplier,
248 ScrollMaxDelta: defaultCEFScrollMaxDelta,250 ScrollMaxDelta: defaultCEFScrollMaxDelta,
251+ ScrollTouchpadInertia: defaultCEFScrollTouchpadInertia,
252+ ScrollWheelSmoothing: defaultCEFScrollWheelSmoothing,
249 TouchpadNavigationEnabled: defaultCEFTouchpadNavigation,253 TouchpadNavigationEnabled: defaultCEFTouchpadNavigation,
250 TouchpadNavigationMinDelta: defaultCEFTouchpadNavigationDelta,254 TouchpadNavigationMinDelta: defaultCEFTouchpadNavigationDelta,
251 TouchpadNavigationMaxVerticalRatio: defaultCEFTouchpadNavigationRatio,255 TouchpadNavigationMaxVerticalRatio: defaultCEFTouchpadNavigationRatio,
M internal/infrastructure/config/engine.go +2 -0
@@ -125,6 +125,8 @@ type CEFInputConfig struct {
125125 ScrollHorizontalMultiplier float64 `mapstructure:"scroll_horizontal_multiplier" toml:"scroll_horizontal_multiplier" yaml:"scroll_horizontal_multiplier"` //nolint:lll // struct tags exceed lll limit
126126 ScrollVerticalMultiplier float64 `mapstructure:"scroll_vertical_multiplier" toml:"scroll_vertical_multiplier" yaml:"scroll_vertical_multiplier"` //nolint:lll // struct tags exceed lll limit
127127 ScrollMaxDelta int32 `mapstructure:"scroll_max_delta" toml:"scroll_max_delta" yaml:"scroll_max_delta"` //nolint:lll // struct tags exceed lll limit
128+ ScrollTouchpadInertia bool `mapstructure:"scroll_touchpad_inertia" toml:"scroll_touchpad_inertia" yaml:"scroll_touchpad_inertia"` //nolint:lll // struct tags exceed lll limit
129+ ScrollWheelSmoothing bool `mapstructure:"scroll_wheel_smoothing" toml:"scroll_wheel_smoothing" yaml:"scroll_wheel_smoothing"` //nolint:lll // struct tags exceed lll limit
128130 TouchpadNavigationEnabled bool `mapstructure:"touchpad_navigation_enabled" toml:"touchpad_navigation_enabled" yaml:"touchpad_navigation_enabled"` //nolint:lll // struct tags exceed lll limit
129131 TouchpadNavigationMinDelta float64 `mapstructure:"touchpad_navigation_min_delta" toml:"touchpad_navigation_min_delta" yaml:"touchpad_navigation_min_delta"` //nolint:lll // struct tags exceed lll limit
130132 TouchpadNavigationMaxVerticalRatio float64 `mapstructure:"touchpad_navigation_max_vertical_ratio" toml:"touchpad_navigation_max_vertical_ratio" yaml:"touchpad_navigation_max_vertical_ratio"` //nolint:lll // struct tags exceed lll limit
@@ -125,6 +125,8 @@ type CEFInputConfig struct {
125 ScrollHorizontalMultiplier float64 `mapstructure:"scroll_horizontal_multiplier" toml:"scroll_horizontal_multiplier" yaml:"scroll_horizontal_multiplier"` //nolint:lll // struct tags exceed lll limit125 ScrollHorizontalMultiplier float64 `mapstructure:"scroll_horizontal_multiplier" toml:"scroll_horizontal_multiplier" yaml:"scroll_horizontal_multiplier"` //nolint:lll // struct tags exceed lll limit
126 ScrollVerticalMultiplier float64 `mapstructure:"scroll_vertical_multiplier" toml:"scroll_vertical_multiplier" yaml:"scroll_vertical_multiplier"` //nolint:lll // struct tags exceed lll limit126 ScrollVerticalMultiplier float64 `mapstructure:"scroll_vertical_multiplier" toml:"scroll_vertical_multiplier" yaml:"scroll_vertical_multiplier"` //nolint:lll // struct tags exceed lll limit
127 ScrollMaxDelta int32 `mapstructure:"scroll_max_delta" toml:"scroll_max_delta" yaml:"scroll_max_delta"` //nolint:lll // struct tags exceed lll limit127 ScrollMaxDelta int32 `mapstructure:"scroll_max_delta" toml:"scroll_max_delta" yaml:"scroll_max_delta"` //nolint:lll // struct tags exceed lll limit
128+ ScrollTouchpadInertia bool `mapstructure:"scroll_touchpad_inertia" toml:"scroll_touchpad_inertia" yaml:"scroll_touchpad_inertia"` //nolint:lll // struct tags exceed lll limit
129+ ScrollWheelSmoothing bool `mapstructure:"scroll_wheel_smoothing" toml:"scroll_wheel_smoothing" yaml:"scroll_wheel_smoothing"` //nolint:lll // struct tags exceed lll limit
128 TouchpadNavigationEnabled bool `mapstructure:"touchpad_navigation_enabled" toml:"touchpad_navigation_enabled" yaml:"touchpad_navigation_enabled"` //nolint:lll // struct tags exceed lll limit130 TouchpadNavigationEnabled bool `mapstructure:"touchpad_navigation_enabled" toml:"touchpad_navigation_enabled" yaml:"touchpad_navigation_enabled"` //nolint:lll // struct tags exceed lll limit
129 TouchpadNavigationMinDelta float64 `mapstructure:"touchpad_navigation_min_delta" toml:"touchpad_navigation_min_delta" yaml:"touchpad_navigation_min_delta"` //nolint:lll // struct tags exceed lll limit131 TouchpadNavigationMinDelta float64 `mapstructure:"touchpad_navigation_min_delta" toml:"touchpad_navigation_min_delta" yaml:"touchpad_navigation_min_delta"` //nolint:lll // struct tags exceed lll limit
130 TouchpadNavigationMaxVerticalRatio float64 `mapstructure:"touchpad_navigation_max_vertical_ratio" toml:"touchpad_navigation_max_vertical_ratio" yaml:"touchpad_navigation_max_vertical_ratio"` //nolint:lll // struct tags exceed lll limit132 TouchpadNavigationMaxVerticalRatio float64 `mapstructure:"touchpad_navigation_max_vertical_ratio" toml:"touchpad_navigation_max_vertical_ratio" yaml:"touchpad_navigation_max_vertical_ratio"` //nolint:lll // struct tags exceed lll limit
M internal/infrastructure/config/loader.go +2 -0
@@ -845,6 +845,8 @@ func (m *Manager) setCEFInputDefaults(input CEFInputConfig) {
845845 m.viper.SetDefault("engine.cef.input.scroll_horizontal_multiplier", input.ScrollHorizontalMultiplier)
846846 m.viper.SetDefault("engine.cef.input.scroll_vertical_multiplier", input.ScrollVerticalMultiplier)
847847 m.viper.SetDefault("engine.cef.input.scroll_max_delta", input.ScrollMaxDelta)
848+ m.viper.SetDefault("engine.cef.input.scroll_touchpad_inertia", input.ScrollTouchpadInertia)
849+ m.viper.SetDefault("engine.cef.input.scroll_wheel_smoothing", input.ScrollWheelSmoothing)
848850 m.viper.SetDefault("engine.cef.input.touchpad_navigation_enabled", input.TouchpadNavigationEnabled)
849851 m.viper.SetDefault("engine.cef.input.touchpad_navigation_min_delta", input.TouchpadNavigationMinDelta)
850852 m.viper.SetDefault("engine.cef.input.touchpad_navigation_max_vertical_ratio", input.TouchpadNavigationMaxVerticalRatio)
@@ -845,6 +845,8 @@ func (m *Manager) setCEFInputDefaults(input CEFInputConfig) {
845 m.viper.SetDefault("engine.cef.input.scroll_horizontal_multiplier", input.ScrollHorizontalMultiplier)845 m.viper.SetDefault("engine.cef.input.scroll_horizontal_multiplier", input.ScrollHorizontalMultiplier)
846 m.viper.SetDefault("engine.cef.input.scroll_vertical_multiplier", input.ScrollVerticalMultiplier)846 m.viper.SetDefault("engine.cef.input.scroll_vertical_multiplier", input.ScrollVerticalMultiplier)
847 m.viper.SetDefault("engine.cef.input.scroll_max_delta", input.ScrollMaxDelta)847 m.viper.SetDefault("engine.cef.input.scroll_max_delta", input.ScrollMaxDelta)
848+ m.viper.SetDefault("engine.cef.input.scroll_touchpad_inertia", input.ScrollTouchpadInertia)
849+ m.viper.SetDefault("engine.cef.input.scroll_wheel_smoothing", input.ScrollWheelSmoothing)
848 m.viper.SetDefault("engine.cef.input.touchpad_navigation_enabled", input.TouchpadNavigationEnabled)850 m.viper.SetDefault("engine.cef.input.touchpad_navigation_enabled", input.TouchpadNavigationEnabled)
849 m.viper.SetDefault("engine.cef.input.touchpad_navigation_min_delta", input.TouchpadNavigationMinDelta)851 m.viper.SetDefault("engine.cef.input.touchpad_navigation_min_delta", input.TouchpadNavigationMinDelta)
850 m.viper.SetDefault("engine.cef.input.touchpad_navigation_max_vertical_ratio", input.TouchpadNavigationMaxVerticalRatio)852 m.viper.SetDefault("engine.cef.input.touchpad_navigation_max_vertical_ratio", input.TouchpadNavigationMaxVerticalRatio)
M internal/infrastructure/config/loader_test.go +27 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/spf13/viper"
77 "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
89 )
910
1011 func TestSetEngineDefaults(t *testing.T) {
@@ -22,6 +23,10 @@ func TestSetEngineDefaults(t *testing.T) {
2223 assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_horizontal_multiplier"), 0.001)
2324 assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_vertical_multiplier"), 0.001)
2425 assert.Equal(t, defaultCEFScrollMaxDelta, mgr.viper.GetInt("engine.cef.input.scroll_max_delta"))
26+ assert.Equal(t, defaultCEFScrollTouchpadInertia, mgr.viper.GetBool("engine.cef.input.scroll_touchpad_inertia"))
27+ assert.Equal(t, defaultCEFScrollWheelSmoothing, mgr.viper.GetBool("engine.cef.input.scroll_wheel_smoothing"))
28+ assert.True(t, mgr.viper.GetBool("engine.cef.input.scroll_touchpad_inertia"))
29+ assert.True(t, mgr.viper.GetBool("engine.cef.input.scroll_wheel_smoothing"))
2530 assert.Equal(t, defaultCEFTouchpadNavigation, mgr.viper.GetBool("engine.cef.input.touchpad_navigation_enabled"))
2631 assert.InDelta(t, defaultCEFTouchpadNavigationDelta, mgr.viper.GetFloat64("engine.cef.input.touchpad_navigation_min_delta"), 0.001)
2732 assert.InDelta(t, defaultCEFTouchpadNavigationRatio, mgr.viper.GetFloat64("engine.cef.input.touchpad_navigation_max_vertical_ratio"), 0.001)
@@ -39,6 +44,28 @@ func TestSetEngineDefaults(t *testing.T) {
3944 assert.Empty(t, mgr.viper.GetString("runtime.prefix"))
4045 }
4146
47+func TestScrollInertiaOptions_ExplicitFalseSurvivesUnmarshal(t *testing.T) {
48+ mgr := &Manager{viper: viper.New()}
49+ mgr.setDefaults()
50+ mgr.viper.Set("engine.cef.input.scroll_touchpad_inertia", false)
51+ mgr.viper.Set("engine.cef.input.scroll_wheel_smoothing", false)
52+
53+ cfg, err := mgr.unmarshalConfig()
54+ require.NoError(t, err)
55+ assert.False(t, cfg.Engine.CEF.Input.ScrollTouchpadInertia)
56+ assert.False(t, cfg.Engine.CEF.Input.ScrollWheelSmoothing)
57+}
58+
59+func TestScrollInertiaOptions_DefaultTrueThroughUnmarshal(t *testing.T) {
60+ mgr := &Manager{viper: viper.New()}
61+ mgr.setDefaults()
62+
63+ cfg, err := mgr.unmarshalConfig()
64+ require.NoError(t, err)
65+ assert.True(t, cfg.Engine.CEF.Input.ScrollTouchpadInertia)
66+ assert.True(t, cfg.Engine.CEF.Input.ScrollWheelSmoothing)
67+}
68+
4269 func TestNormalizeExternalLinks(t *testing.T) {
4370 cfg := DefaultConfig()
4471 cfg.Workspace.ExternalLinks.Behavior = "invalid"
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/spf13/viper"6 "github.com/spf13/viper"
7 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
8 )9 )
9 10
10 func TestSetEngineDefaults(t *testing.T) {11 func TestSetEngineDefaults(t *testing.T) {
@@ -22,6 +23,10 @@ func TestSetEngineDefaults(t *testing.T) {
22 assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_horizontal_multiplier"), 0.001)23 assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_horizontal_multiplier"), 0.001)
23 assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_vertical_multiplier"), 0.001)24 assert.InDelta(t, defaultCEFScrollMultiplier, mgr.viper.GetFloat64("engine.cef.input.scroll_vertical_multiplier"), 0.001)
24 assert.Equal(t, defaultCEFScrollMaxDelta, mgr.viper.GetInt("engine.cef.input.scroll_max_delta"))25 assert.Equal(t, defaultCEFScrollMaxDelta, mgr.viper.GetInt("engine.cef.input.scroll_max_delta"))
26+ assert.Equal(t, defaultCEFScrollTouchpadInertia, mgr.viper.GetBool("engine.cef.input.scroll_touchpad_inertia"))
27+ assert.Equal(t, defaultCEFScrollWheelSmoothing, mgr.viper.GetBool("engine.cef.input.scroll_wheel_smoothing"))
28+ assert.True(t, mgr.viper.GetBool("engine.cef.input.scroll_touchpad_inertia"))
29+ assert.True(t, mgr.viper.GetBool("engine.cef.input.scroll_wheel_smoothing"))
25 assert.Equal(t, defaultCEFTouchpadNavigation, mgr.viper.GetBool("engine.cef.input.touchpad_navigation_enabled"))30 assert.Equal(t, defaultCEFTouchpadNavigation, mgr.viper.GetBool("engine.cef.input.touchpad_navigation_enabled"))
26 assert.InDelta(t, defaultCEFTouchpadNavigationDelta, mgr.viper.GetFloat64("engine.cef.input.touchpad_navigation_min_delta"), 0.001)31 assert.InDelta(t, defaultCEFTouchpadNavigationDelta, mgr.viper.GetFloat64("engine.cef.input.touchpad_navigation_min_delta"), 0.001)
27 assert.InDelta(t, defaultCEFTouchpadNavigationRatio, mgr.viper.GetFloat64("engine.cef.input.touchpad_navigation_max_vertical_ratio"), 0.001)32 assert.InDelta(t, defaultCEFTouchpadNavigationRatio, mgr.viper.GetFloat64("engine.cef.input.touchpad_navigation_max_vertical_ratio"), 0.001)
@@ -39,6 +44,28 @@ func TestSetEngineDefaults(t *testing.T) {
39 assert.Empty(t, mgr.viper.GetString("runtime.prefix"))44 assert.Empty(t, mgr.viper.GetString("runtime.prefix"))
40 }45 }
41 46
47+func TestScrollInertiaOptions_ExplicitFalseSurvivesUnmarshal(t *testing.T) {
48+ mgr := &Manager{viper: viper.New()}
49+ mgr.setDefaults()
50+ mgr.viper.Set("engine.cef.input.scroll_touchpad_inertia", false)
51+ mgr.viper.Set("engine.cef.input.scroll_wheel_smoothing", false)
52+
53+ cfg, err := mgr.unmarshalConfig()
54+ require.NoError(t, err)
55+ assert.False(t, cfg.Engine.CEF.Input.ScrollTouchpadInertia)
56+ assert.False(t, cfg.Engine.CEF.Input.ScrollWheelSmoothing)
57+}
58+
59+func TestScrollInertiaOptions_DefaultTrueThroughUnmarshal(t *testing.T) {
60+ mgr := &Manager{viper: viper.New()}
61+ mgr.setDefaults()
62+
63+ cfg, err := mgr.unmarshalConfig()
64+ require.NoError(t, err)
65+ assert.True(t, cfg.Engine.CEF.Input.ScrollTouchpadInertia)
66+ assert.True(t, cfg.Engine.CEF.Input.ScrollWheelSmoothing)
67+}
68+
42 func TestNormalizeExternalLinks(t *testing.T) {69 func TestNormalizeExternalLinks(t *testing.T) {
43 cfg := DefaultConfig()70 cfg := DefaultConfig()
44 cfg.Workspace.ExternalLinks.Behavior = "invalid"71 cfg.Workspace.ExternalLinks.Behavior = "invalid"
M internal/infrastructure/config/schema_provider.go +14 -0
@@ -1157,6 +1157,20 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
11571157 Range: ">=0",
11581158 Section: SectionPerformance,
11591159 },
1160+ {
1161+ Key: "engine.cef.input.scroll_touchpad_inertia",
1162+ Type: "bool",
1163+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.Input.ScrollTouchpadInertia),
1164+ Description: "Enable direct touchpad tracking with exponential release decay for CEF views",
1165+ Section: SectionPerformance,
1166+ },
1167+ {
1168+ Key: "engine.cef.input.scroll_wheel_smoothing",
1169+ Type: "bool",
1170+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.Input.ScrollWheelSmoothing),
1171+ Description: "Interpolate accepted CEF wheel impulses across frames without a long coast",
1172+ Section: SectionPerformance,
1173+ },
11601174 {
11611175 Key: "engine.cef.input.touchpad_navigation_enabled",
11621176 Type: "bool",
@@ -1157,6 +1157,20 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
1157 Range: ">=0",1157 Range: ">=0",
1158 Section: SectionPerformance,1158 Section: SectionPerformance,
1159 },1159 },
1160+ {
1161+ Key: "engine.cef.input.scroll_touchpad_inertia",
1162+ Type: "bool",
1163+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.Input.ScrollTouchpadInertia),
1164+ Description: "Enable direct touchpad tracking with exponential release decay for CEF views",
1165+ Section: SectionPerformance,
1166+ },
1167+ {
1168+ Key: "engine.cef.input.scroll_wheel_smoothing",
1169+ Type: "bool",
1170+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.Input.ScrollWheelSmoothing),
1171+ Description: "Interpolate accepted CEF wheel impulses across frames without a long coast",
1172+ Section: SectionPerformance,
1173+ },
1160 {1174 {
1161 Key: "engine.cef.input.touchpad_navigation_enabled",1175 Key: "engine.cef.input.touchpad_navigation_enabled",
1162 Type: "bool",1176 Type: "bool",

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