detached 257890c

fix(cef): keep touchpad gesture completion ordered

commit
257890c2635e9d1d2f5555c622a8ef84329c5dd2
author
brice · · 2026-06-18 11:40 UTC
parent
bd52731
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +128 -3
M internal/infrastructure/cef/touchpad_navigation_test.go +60 -0
@@ -514,6 +514,66 @@ func TestTouchpadNavigationRecognizer_CancelsVerticalGestures(t *testing.T) {
514514 require.False(t, end.HasAction)
515515 }
516516
517+func TestTouchpadNavigationRecognizer_HidesVisibleIndicatorOnVerticalCancelAndResets(t *testing.T) {
518+ recognizer := newTouchpadNavigationRecognizer()
519+ cfg := RuntimeInputConfig{
520+ TouchpadNavigationEnabled: true,
521+ TouchpadNavigationMinDelta: 200,
522+ TouchpadNavigationMaxVerticalRatio: 0.5,
523+ }
524+
525+ _ = recognizer.Handle(touchpadNavigationInput{
526+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
527+ Config: cfg,
528+ CanGoBack: true,
529+ ViewWidth: 1000,
530+ })
531+ shown := recognizer.Handle(touchpadNavigationInput{
532+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -120, 0),
533+ Config: cfg,
534+ CanGoBack: true,
535+ })
536+ require.True(t, shown.HasIndicator)
537+ require.True(t, shown.Indicator.Active)
538+
539+ canceled := recognizer.Handle(touchpadNavigationInput{
540+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, 0, 80),
541+ Config: cfg,
542+ CanGoBack: true,
543+ })
544+ require.True(t, canceled.HasIndicator)
545+ require.False(t, canceled.Indicator.Active)
546+ require.False(t, canceled.HasAction)
547+
548+ end := recognizer.Handle(touchpadNavigationInput{
549+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
550+ Config: cfg,
551+ CanGoBack: true,
552+ })
553+ require.False(t, end.HasIndicator)
554+ require.False(t, end.HasAction)
555+
556+ _ = recognizer.Handle(touchpadNavigationInput{
557+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
558+ Config: cfg,
559+ CanGoBack: true,
560+ ViewWidth: 1000,
561+ })
562+ valid := recognizer.Handle(touchpadNavigationInput{
563+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -240, 0),
564+ Config: cfg,
565+ CanGoBack: true,
566+ })
567+ require.True(t, valid.HasIndicator)
568+ validEnd := recognizer.Handle(touchpadNavigationInput{
569+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
570+ Config: cfg,
571+ CanGoBack: true,
572+ })
573+ require.True(t, validEnd.HasAction)
574+ require.Equal(t, cef2gtk.NavigationSwipeBack, validEnd.Action)
575+}
576+
517577 func touchpadNavigationScrollEvent(phase cef2gtk.ScrollPhase, dx, dy float64) cef2gtk.ScrollEvent {
518578 return touchpadNavigationScrollEventAt(phase, 0, dx, dy)
519579 }
@@ -514,6 +514,66 @@ func TestTouchpadNavigationRecognizer_CancelsVerticalGestures(t *testing.T) {
514 require.False(t, end.HasAction)514 require.False(t, end.HasAction)
515 }515 }
516 516
517+func TestTouchpadNavigationRecognizer_HidesVisibleIndicatorOnVerticalCancelAndResets(t *testing.T) {
518+ recognizer := newTouchpadNavigationRecognizer()
519+ cfg := RuntimeInputConfig{
520+ TouchpadNavigationEnabled: true,
521+ TouchpadNavigationMinDelta: 200,
522+ TouchpadNavigationMaxVerticalRatio: 0.5,
523+ }
524+
525+ _ = recognizer.Handle(touchpadNavigationInput{
526+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
527+ Config: cfg,
528+ CanGoBack: true,
529+ ViewWidth: 1000,
530+ })
531+ shown := recognizer.Handle(touchpadNavigationInput{
532+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -120, 0),
533+ Config: cfg,
534+ CanGoBack: true,
535+ })
536+ require.True(t, shown.HasIndicator)
537+ require.True(t, shown.Indicator.Active)
538+
539+ canceled := recognizer.Handle(touchpadNavigationInput{
540+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, 0, 80),
541+ Config: cfg,
542+ CanGoBack: true,
543+ })
544+ require.True(t, canceled.HasIndicator)
545+ require.False(t, canceled.Indicator.Active)
546+ require.False(t, canceled.HasAction)
547+
548+ end := recognizer.Handle(touchpadNavigationInput{
549+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
550+ Config: cfg,
551+ CanGoBack: true,
552+ })
553+ require.False(t, end.HasIndicator)
554+ require.False(t, end.HasAction)
555+
556+ _ = recognizer.Handle(touchpadNavigationInput{
557+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
558+ Config: cfg,
559+ CanGoBack: true,
560+ ViewWidth: 1000,
561+ })
562+ valid := recognizer.Handle(touchpadNavigationInput{
563+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -240, 0),
564+ Config: cfg,
565+ CanGoBack: true,
566+ })
567+ require.True(t, valid.HasIndicator)
568+ validEnd := recognizer.Handle(touchpadNavigationInput{
569+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
570+ Config: cfg,
571+ CanGoBack: true,
572+ })
573+ require.True(t, validEnd.HasAction)
574+ require.Equal(t, cef2gtk.NavigationSwipeBack, validEnd.Action)
575+}
576+
517 func touchpadNavigationScrollEvent(phase cef2gtk.ScrollPhase, dx, dy float64) cef2gtk.ScrollEvent {577 func touchpadNavigationScrollEvent(phase cef2gtk.ScrollPhase, dx, dy float64) cef2gtk.ScrollEvent {
518 return touchpadNavigationScrollEventAt(phase, 0, dx, dy)578 return touchpadNavigationScrollEventAt(phase, 0, dx, dy)
519 }579 }
M internal/infrastructure/cef/webview.go +6 -3
@@ -1508,6 +1508,9 @@ func (wv *WebView) handleTouchpadNavigationScroll(event cef2gtk.ScrollEvent) {
15081508 if wv == nil || wv.destroyed.Load() {
15091509 return
15101510 }
1511+ if !wv.inputConfig.TouchpadNavigationEnabled {
1512+ return
1513+ }
15111514 wv.mu.Lock()
15121515 if wv.destroyed.Load() {
15131516 wv.mu.Unlock()
@@ -1550,9 +1553,9 @@ func (wv *WebView) emitTouchpadNavigationGesture(gesture dto.TouchpadNavigationG
15501553 cb := wv.callbacks
15511554 wv.mu.RUnlock()
15521555 if cb != nil && cb.OnTouchpadNavigationGesture != nil {
1553- wv.runOnGTK(func() {
1554- cb.OnTouchpadNavigationGesture(gesture)
1555- })
1556+ // Scroll input is delivered from GTK callbacks, so invoking directly keeps
1557+ // progress and final indicators ordered before any navigation action.
1558+ cb.OnTouchpadNavigationGesture(gesture)
15561559 }
15571560 }
15581561
@@ -1508,6 +1508,9 @@ func (wv *WebView) handleTouchpadNavigationScroll(event cef2gtk.ScrollEvent) {
1508 if wv == nil || wv.destroyed.Load() {1508 if wv == nil || wv.destroyed.Load() {
1509 return1509 return
1510 }1510 }
1511+ if !wv.inputConfig.TouchpadNavigationEnabled {
1512+ return
1513+ }
1511 wv.mu.Lock()1514 wv.mu.Lock()
1512 if wv.destroyed.Load() {1515 if wv.destroyed.Load() {
1513 wv.mu.Unlock()1516 wv.mu.Unlock()
@@ -1550,9 +1553,9 @@ func (wv *WebView) emitTouchpadNavigationGesture(gesture dto.TouchpadNavigationG
1550 cb := wv.callbacks1553 cb := wv.callbacks
1551 wv.mu.RUnlock()1554 wv.mu.RUnlock()
1552 if cb != nil && cb.OnTouchpadNavigationGesture != nil {1555 if cb != nil && cb.OnTouchpadNavigationGesture != nil {
1553- wv.runOnGTK(func() {1556+ // Scroll input is delivered from GTK callbacks, so invoking directly keeps
1554- cb.OnTouchpadNavigationGesture(gesture)1557+ // progress and final indicators ordered before any navigation action.
1555- })1558+ cb.OnTouchpadNavigationGesture(gesture)
1556 }1559 }
1557 }1560 }
1558 1561
M internal/infrastructure/cef/webview_input_test.go +62 -0
@@ -4,6 +4,9 @@ import (
44 "context"
55 "testing"
66
7+ "github.com/bnema/dumber/internal/application/dto"
8+ "github.com/bnema/dumber/internal/application/port"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
710 "github.com/bnema/purego-cef2gtk"
811 "github.com/bnema/puregotk/v4/gdk"
912 "github.com/bnema/puregotk/v4/gtk"
@@ -71,3 +74,62 @@ func TestWebViewHandleScrollInput_ForwardsOrdinaryHorizontalScroll(t *testing.T)
7174
7275 require.Equal(t, cef2gtk.ScrollForwardToCEF, decision)
7376 }
77+
78+func TestWebViewHandleScrollInput_EmitsFinalGestureBeforeNavigation(t *testing.T) {
79+ browser := cefmocks.NewMockBrowser(t)
80+ events := make([]string, 0, 2)
81+ browser.EXPECT().GoBack().Run(func() {
82+ events = append(events, "go-back")
83+ }).Once()
84+
85+ wv := &WebView{
86+ ctx: context.Background(),
87+ inputConfig: RuntimeInputConfig{
88+ TouchpadNavigationEnabled: true,
89+ TouchpadNavigationMinDelta: 200,
90+ TouchpadNavigationMaxVerticalRatio: 0.5,
91+ },
92+ canGoBack: true,
93+ browser: browser,
94+ callbacks: &port.WebViewCallbacks{
95+ OnTouchpadNavigationGesture: func(gesture dto.TouchpadNavigationGesture) {
96+ if !gesture.Active && gesture.ThresholdReached {
97+ events = append(events, "indicator-finished")
98+ }
99+ },
100+ },
101+ }
102+
103+ begin := wv.handleScrollInput(cef2gtk.ScrollEvent{
104+ Phase: cef2gtk.ScrollPhaseBegin,
105+ X: 0,
106+ Unit: gdk.ScrollUnitSurfaceValue,
107+ UnitKnown: true,
108+ })
109+ require.Equal(t, cef2gtk.ScrollForwardToCEF, begin)
110+
111+ // Avoid constructing GTK widgets in this package: the WebView integration
112+ // only needs a known gesture width after begin records the starting edge.
113+ wv.mu.Lock()
114+ require.NotNil(t, wv.touchpadNavigation)
115+ wv.touchpadNavigation.gestureViewWidth = 1000
116+ wv.mu.Unlock()
117+
118+ update := wv.handleScrollInput(cef2gtk.ScrollEvent{
119+ Phase: cef2gtk.ScrollPhaseUpdate,
120+ X: 0,
121+ DX: -240,
122+ Unit: gdk.ScrollUnitSurfaceValue,
123+ UnitKnown: true,
124+ })
125+ require.Equal(t, cef2gtk.ScrollForwardToCEF, update)
126+
127+ end := wv.handleScrollInput(cef2gtk.ScrollEvent{
128+ Phase: cef2gtk.ScrollPhaseEnd,
129+ X: 0,
130+ Unit: gdk.ScrollUnitSurfaceValue,
131+ UnitKnown: true,
132+ })
133+ require.Equal(t, cef2gtk.ScrollForwardToCEF, end)
134+ require.Equal(t, []string{"indicator-finished", "go-back"}, events)
135+}
@@ -4,6 +4,9 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
8+ "github.com/bnema/dumber/internal/application/port"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
7 "github.com/bnema/purego-cef2gtk"10 "github.com/bnema/purego-cef2gtk"
8 "github.com/bnema/puregotk/v4/gdk"11 "github.com/bnema/puregotk/v4/gdk"
9 "github.com/bnema/puregotk/v4/gtk"12 "github.com/bnema/puregotk/v4/gtk"
@@ -71,3 +74,62 @@ func TestWebViewHandleScrollInput_ForwardsOrdinaryHorizontalScroll(t *testing.T)
71 74
72 require.Equal(t, cef2gtk.ScrollForwardToCEF, decision)75 require.Equal(t, cef2gtk.ScrollForwardToCEF, decision)
73 }76 }
77+
78+func TestWebViewHandleScrollInput_EmitsFinalGestureBeforeNavigation(t *testing.T) {
79+ browser := cefmocks.NewMockBrowser(t)
80+ events := make([]string, 0, 2)
81+ browser.EXPECT().GoBack().Run(func() {
82+ events = append(events, "go-back")
83+ }).Once()
84+
85+ wv := &WebView{
86+ ctx: context.Background(),
87+ inputConfig: RuntimeInputConfig{
88+ TouchpadNavigationEnabled: true,
89+ TouchpadNavigationMinDelta: 200,
90+ TouchpadNavigationMaxVerticalRatio: 0.5,
91+ },
92+ canGoBack: true,
93+ browser: browser,
94+ callbacks: &port.WebViewCallbacks{
95+ OnTouchpadNavigationGesture: func(gesture dto.TouchpadNavigationGesture) {
96+ if !gesture.Active && gesture.ThresholdReached {
97+ events = append(events, "indicator-finished")
98+ }
99+ },
100+ },
101+ }
102+
103+ begin := wv.handleScrollInput(cef2gtk.ScrollEvent{
104+ Phase: cef2gtk.ScrollPhaseBegin,
105+ X: 0,
106+ Unit: gdk.ScrollUnitSurfaceValue,
107+ UnitKnown: true,
108+ })
109+ require.Equal(t, cef2gtk.ScrollForwardToCEF, begin)
110+
111+ // Avoid constructing GTK widgets in this package: the WebView integration
112+ // only needs a known gesture width after begin records the starting edge.
113+ wv.mu.Lock()
114+ require.NotNil(t, wv.touchpadNavigation)
115+ wv.touchpadNavigation.gestureViewWidth = 1000
116+ wv.mu.Unlock()
117+
118+ update := wv.handleScrollInput(cef2gtk.ScrollEvent{
119+ Phase: cef2gtk.ScrollPhaseUpdate,
120+ X: 0,
121+ DX: -240,
122+ Unit: gdk.ScrollUnitSurfaceValue,
123+ UnitKnown: true,
124+ })
125+ require.Equal(t, cef2gtk.ScrollForwardToCEF, update)
126+
127+ end := wv.handleScrollInput(cef2gtk.ScrollEvent{
128+ Phase: cef2gtk.ScrollPhaseEnd,
129+ X: 0,
130+ Unit: gdk.ScrollUnitSurfaceValue,
131+ UnitKnown: true,
132+ })
133+ require.Equal(t, cef2gtk.ScrollForwardToCEF, end)
134+ require.Equal(t, []string{"indicator-finished", "go-back"}, events)
135+}

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