fix(cef): keep touchpad gesture completion ordered
- commit
- 257890c2635e9d1d2f5555c622a8ef84329c5dd2
- author
- brice · · 2026-06-18 11:40 UTC
- parent
- bd52731
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 514 | 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 | 577 | func touchpadNavigationScrollEvent(phase cef2gtk.ScrollPhase, dx, dy float64) cef2gtk.ScrollEvent { | |
| 518 | 578 | return touchpadNavigationScrollEventAt(phase, 0, dx, dy) | |
| 519 | 579 | } | |
| @@ -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) { | |||
| 1508 | 1508 | if wv == nil || wv.destroyed.Load() { | |
| 1509 | 1509 | return | |
| 1510 | 1510 | } | |
| 1511 | + | if !wv.inputConfig.TouchpadNavigationEnabled { | |
| 1512 | + | return | |
| 1513 | + | } | |
| 1511 | 1514 | wv.mu.Lock() | |
| 1512 | 1515 | if wv.destroyed.Load() { | |
| 1513 | 1516 | wv.mu.Unlock() | |
| @@ -1550,9 +1553,9 @@ func (wv *WebView) emitTouchpadNavigationGesture(gesture dto.TouchpadNavigationG | |||
| 1550 | 1553 | cb := wv.callbacks | |
| 1551 | 1554 | wv.mu.RUnlock() | |
| 1552 | 1555 | 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) | |
| 1556 | 1559 | } | |
| 1557 | 1560 | } | |
| 1558 | 1561 | ||
| @@ -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 | return | 1509 | 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.callbacks | 1553 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 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 | 10 | "github.com/bnema/purego-cef2gtk" | |
| 8 | 11 | "github.com/bnema/puregotk/v4/gdk" | |
| 9 | 12 | "github.com/bnema/puregotk/v4/gtk" | |
| @@ -71,3 +74,62 @@ func TestWebViewHandleScrollInput_ForwardsOrdinaryHorizontalScroll(t *testing.T) | |||
| 71 | 74 | ||
| 72 | 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 | + | } | |
| @@ -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 | + | } | |||