detached 26e3f49

Merge pull request #309 from bnema/feat/thresholded-touchpad-navigation

feat(cef): improve touchpad history navigation
commit
26e3f495a69f128082c9c24feb5bd22ea0beb21f
author
bnema · · 2026-06-18 20:05 UTC
committer
GitHub · · 2026-06-18 20:05 UTC
merge of
7065e4e + 0b983cb · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
16 files · +1472 -17
M internal/application/port/webview.go +4 -0
@@ -7,6 +7,7 @@ import (
77 "context"
88
99 "github.com/bnema/dumber/internal/application/dto"
10+ "github.com/bnema/dumber/internal/domain/entity"
1011 )
1112
1213 // WebViewID uniquely identifies a WebView instance.
@@ -137,6 +138,9 @@ type WebViewCallbacks struct {
137138
138139 // OnAudioStateChanged is called when audio playback starts or stops.
139140 OnAudioStateChanged func(playing bool)
141+ // OnTouchpadNavigationGesture is called while a two-finger touchpad history
142+ // navigation gesture is progressing or finishing.
143+ OnTouchpadNavigationGesture func(gesture entity.TouchpadNavigationGesture)
140144 }
141145
142146 // FindOptions configures search behavior.
@@ -7,6 +7,7 @@ import (
7 "context"7 "context"
8 8
9 "github.com/bnema/dumber/internal/application/dto"9 "github.com/bnema/dumber/internal/application/dto"
10+ "github.com/bnema/dumber/internal/domain/entity"
10 )11 )
11 12
12 // WebViewID uniquely identifies a WebView instance.13 // WebViewID uniquely identifies a WebView instance.
@@ -137,6 +138,9 @@ type WebViewCallbacks struct {
137 138
138 // OnAudioStateChanged is called when audio playback starts or stops.139 // OnAudioStateChanged is called when audio playback starts or stops.
139 OnAudioStateChanged func(playing bool)140 OnAudioStateChanged func(playing bool)
141+ // OnTouchpadNavigationGesture is called while a two-finger touchpad history
142+ // navigation gesture is progressing or finishing.
143+ OnTouchpadNavigationGesture func(gesture entity.TouchpadNavigationGesture)
140 }144 }
141 145
142 // FindOptions configures search behavior.146 // FindOptions configures search behavior.
A internal/domain/entity/touchpad_navigation.go +33 -0
@@ -0,0 +1,33 @@
1+package entity
2+
3+// TouchpadNavigationAction indicates the direction of a touchpad navigation gesture.
4+type TouchpadNavigationAction int
5+
6+const (
7+ // TouchpadNavigationBack indicates a backward navigation gesture.
8+ TouchpadNavigationBack TouchpadNavigationAction = iota
9+ // TouchpadNavigationForward indicates a forward navigation gesture.
10+ TouchpadNavigationForward
11+)
12+
13+// String returns a human-readable representation of the navigation action.
14+func (a TouchpadNavigationAction) String() string {
15+ switch a {
16+ case TouchpadNavigationBack:
17+ return "back"
18+ case TouchpadNavigationForward:
19+ return "forward"
20+ default:
21+ return "unknown"
22+ }
23+}
24+
25+// TouchpadNavigationGesture describes visual progress for a deliberate
26+// two-finger history navigation gesture.
27+type TouchpadNavigationGesture struct {
28+ Action TouchpadNavigationAction
29+ // Progress is normalized gesture progress from 0.0 to 1.0.
30+ Progress float64
31+ ThresholdReached bool
32+ Active bool
33+}
@@ -0,0 +1,33 @@
1+package entity
2+
3+// TouchpadNavigationAction indicates the direction of a touchpad navigation gesture.
4+type TouchpadNavigationAction int
5+
6+const (
7+ // TouchpadNavigationBack indicates a backward navigation gesture.
8+ TouchpadNavigationBack TouchpadNavigationAction = iota
9+ // TouchpadNavigationForward indicates a forward navigation gesture.
10+ TouchpadNavigationForward
11+)
12+
13+// String returns a human-readable representation of the navigation action.
14+func (a TouchpadNavigationAction) String() string {
15+ switch a {
16+ case TouchpadNavigationBack:
17+ return "back"
18+ case TouchpadNavigationForward:
19+ return "forward"
20+ default:
21+ return "unknown"
22+ }
23+}
24+
25+// TouchpadNavigationGesture describes visual progress for a deliberate
26+// two-finger history navigation gesture.
27+type TouchpadNavigationGesture struct {
28+ Action TouchpadNavigationAction
29+ // Progress is normalized gesture progress from 0.0 to 1.0.
30+ Progress float64
31+ ThresholdReached bool
32+ Active bool
33+}
A internal/infrastructure/cef/touchpad_navigation.go +255 -0
@@ -0,0 +1,255 @@
1+package cef
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ cef2gtk "github.com/bnema/purego-cef2gtk"
8+ "github.com/bnema/puregotk/v4/gdk"
9+)
10+
11+const (
12+ defaultTouchpadNavigationCommitDistance = 320.0
13+ defaultTouchpadNavigationVerticalRatio = 0.5
14+ touchpadNavigationIndicatorActivationFraction = 0.5
15+ touchpadNavigationEdgeFraction = 0.15
16+ touchpadNavigationMaxEdgeDistance = 96.0
17+)
18+
19+type touchpadNavigationRecognizer struct {
20+ cumulativeDX float64
21+ cumulativeDY float64
22+ gestureStartX float64
23+ gestureViewWidth float64
24+ hasGestureStart bool
25+ thresholdReached bool
26+ verticalCanceled bool
27+ indicatorShown bool
28+ lastIndicatorAction entity.TouchpadNavigationAction
29+}
30+
31+type touchpadNavigationInput struct {
32+ Event cef2gtk.ScrollEvent
33+ Config RuntimeInputConfig
34+ CanGoBack bool
35+ CanGoForward bool
36+ ViewWidth float64
37+}
38+
39+type touchpadNavigationResult struct {
40+ HasIndicator bool
41+ Indicator entity.TouchpadNavigationGesture
42+ HasAction bool
43+ Action cef2gtk.NavigationSwipeAction
44+}
45+
46+func newTouchpadNavigationRecognizer() *touchpadNavigationRecognizer {
47+ return &touchpadNavigationRecognizer{}
48+}
49+
50+func (r *touchpadNavigationRecognizer) Handle(input touchpadNavigationInput) touchpadNavigationResult {
51+ if r == nil || !input.Config.TouchpadNavigationEnabled {
52+ return touchpadNavigationResult{}
53+ }
54+
55+ switch input.Event.Phase {
56+ case cef2gtk.ScrollPhaseBegin:
57+ r.reset()
58+ r.setGestureStart(input)
59+ return touchpadNavigationResult{}
60+ case cef2gtk.ScrollPhaseUpdate:
61+ if !isTouchpadNavigationPreciseEvent(input.Event) {
62+ return touchpadNavigationResult{}
63+ }
64+ return r.handleUpdate(input)
65+ case cef2gtk.ScrollPhaseEnd:
66+ return r.handleEnd(input)
67+ default:
68+ return touchpadNavigationResult{}
69+ }
70+}
71+
72+func (r *touchpadNavigationRecognizer) handleUpdate(input touchpadNavigationInput) touchpadNavigationResult {
73+ if r.verticalCanceled {
74+ return touchpadNavigationResult{}
75+ }
76+ if !r.hasGestureStart {
77+ r.setGestureStart(input)
78+ }
79+
80+ // GTK scroll deltas are inverted compared to browser-history direction:
81+ // negative horizontal surface deltas indicate a back swipe, positive indicate
82+ // forward. Match the prior bridge behavior while keeping recognition local so
83+ // normal scroll forwarding remains untouched.
84+ r.cumulativeDX += -input.Event.DX
85+ r.cumulativeDY += input.Event.DY
86+ if r.isTooVertical(input.Config) {
87+ return r.cancelAsTooVertical()
88+ }
89+
90+ action, ok := r.indicatorAction(input)
91+ if !ok {
92+ return touchpadNavigationResult{}
93+ }
94+
95+ threshold := normalizedTouchpadNavigationMinDelta(input.Config.TouchpadNavigationMinDelta)
96+ absDX := math.Abs(r.cumulativeDX)
97+ progress := clampFloat(absDX/threshold, 0, 1)
98+ r.thresholdReached = absDX > threshold
99+ if !r.indicatorShown && progress < touchpadNavigationIndicatorActivationFraction {
100+ return touchpadNavigationResult{}
101+ }
102+ r.indicatorShown = true
103+ r.lastIndicatorAction = action
104+ return touchpadNavigationResult{
105+ HasIndicator: true,
106+ Indicator: entity.TouchpadNavigationGesture{
107+ Action: action,
108+ Progress: progress,
109+ ThresholdReached: r.thresholdReached,
110+ Active: true,
111+ },
112+ }
113+}
114+
115+func (r *touchpadNavigationRecognizer) handleEnd(input touchpadNavigationInput) touchpadNavigationResult {
116+ defer r.reset()
117+ if r.verticalCanceled || !r.thresholdReached || r.isTooVertical(input.Config) {
118+ return r.finishIndicator(input, false)
119+ }
120+ action, ok := r.navigationAction(input)
121+ if !ok {
122+ return r.finishIndicator(input, false)
123+ }
124+ result := r.finishIndicator(input, true)
125+ result.HasAction = true
126+ result.Action = action
127+ return result
128+}
129+
130+func (r *touchpadNavigationRecognizer) finishIndicator(input touchpadNavigationInput, triggered bool) touchpadNavigationResult {
131+ if !r.indicatorShown {
132+ return touchpadNavigationResult{}
133+ }
134+ action, ok := r.indicatorAction(input)
135+ if !ok {
136+ action = r.lastIndicatorAction
137+ }
138+ threshold := normalizedTouchpadNavigationMinDelta(input.Config.TouchpadNavigationMinDelta)
139+ return touchpadNavigationResult{
140+ HasIndicator: true,
141+ Indicator: entity.TouchpadNavigationGesture{
142+ Action: action,
143+ Progress: clampFloat(math.Abs(r.cumulativeDX)/threshold, 0, 1),
144+ ThresholdReached: triggered,
145+ Active: false,
146+ },
147+ }
148+}
149+
150+func (r *touchpadNavigationRecognizer) indicatorAction(input touchpadNavigationInput) (entity.TouchpadNavigationAction, bool) {
151+ return r.resolvedDirection(input)
152+}
153+
154+func (r *touchpadNavigationRecognizer) cancelAsTooVertical() touchpadNavigationResult {
155+ var action entity.TouchpadNavigationAction
156+ hasAction := r.indicatorShown
157+ if hasAction {
158+ action = r.lastIndicatorAction
159+ }
160+ r.reset()
161+ r.verticalCanceled = true
162+ if !hasAction {
163+ return touchpadNavigationResult{}
164+ }
165+ return touchpadNavigationResult{
166+ HasIndicator: true,
167+ Indicator: entity.TouchpadNavigationGesture{
168+ Action: action,
169+ Active: false,
170+ },
171+ }
172+}
173+
174+func (r *touchpadNavigationRecognizer) navigationAction(input touchpadNavigationInput) (cef2gtk.NavigationSwipeAction, bool) {
175+ action, ok := r.resolvedDirection(input)
176+ if !ok {
177+ return cef2gtk.NavigationSwipeBack, false
178+ }
179+ if action == entity.TouchpadNavigationForward {
180+ return cef2gtk.NavigationSwipeForward, true
181+ }
182+ return cef2gtk.NavigationSwipeBack, true
183+}
184+
185+func (r *touchpadNavigationRecognizer) resolvedDirection(input touchpadNavigationInput) (entity.TouchpadNavigationAction, bool) {
186+ if r.cumulativeDX > 0 && input.CanGoBack && r.startedAtNavigationEdge(entity.TouchpadNavigationBack) {
187+ return entity.TouchpadNavigationBack, true
188+ }
189+ if r.cumulativeDX < 0 && input.CanGoForward && r.startedAtNavigationEdge(entity.TouchpadNavigationForward) {
190+ return entity.TouchpadNavigationForward, true
191+ }
192+ return entity.TouchpadNavigationBack, false
193+}
194+
195+func (r *touchpadNavigationRecognizer) setGestureStart(input touchpadNavigationInput) {
196+ r.gestureStartX = input.Event.X
197+ r.gestureViewWidth = input.ViewWidth
198+ r.hasGestureStart = true
199+}
200+
201+func (r *touchpadNavigationRecognizer) startedAtNavigationEdge(action entity.TouchpadNavigationAction) bool {
202+ if r.gestureViewWidth <= 0 {
203+ return false
204+ }
205+ edgeDistance := math.Min(r.gestureViewWidth*touchpadNavigationEdgeFraction, touchpadNavigationMaxEdgeDistance)
206+ if action == entity.TouchpadNavigationForward {
207+ return r.gestureStartX >= r.gestureViewWidth-edgeDistance
208+ }
209+ return r.gestureStartX <= edgeDistance
210+}
211+
212+func (r *touchpadNavigationRecognizer) isTooVertical(cfg RuntimeInputConfig) bool {
213+ absDX, absDY := math.Abs(r.cumulativeDX), math.Abs(r.cumulativeDY)
214+ return absDX == 0 || absDY >= absDX*normalizedTouchpadNavigationRatio(cfg.TouchpadNavigationMaxVerticalRatio)
215+}
216+
217+func (r *touchpadNavigationRecognizer) reset() {
218+ r.cumulativeDX = 0
219+ r.cumulativeDY = 0
220+ r.gestureStartX = 0
221+ r.gestureViewWidth = 0
222+ r.hasGestureStart = false
223+ r.thresholdReached = false
224+ r.verticalCanceled = false
225+ r.indicatorShown = false
226+ r.lastIndicatorAction = entity.TouchpadNavigationBack
227+}
228+
229+func isTouchpadNavigationPreciseEvent(event cef2gtk.ScrollEvent) bool {
230+ return event.UnitKnown && event.Unit == gdk.ScrollUnitSurfaceValue
231+}
232+
233+func normalizedTouchpadNavigationMinDelta(value float64) float64 {
234+ if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
235+ return defaultTouchpadNavigationCommitDistance
236+ }
237+ return value
238+}
239+
240+func normalizedTouchpadNavigationRatio(value float64) float64 {
241+ if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
242+ return defaultTouchpadNavigationVerticalRatio
243+ }
244+ return value
245+}
246+
247+func clampFloat(value, minValue, maxValue float64) float64 {
248+ if value < minValue {
249+ return minValue
250+ }
251+ if value > maxValue {
252+ return maxValue
253+ }
254+ return value
255+}
@@ -0,0 +1,255 @@
1+package cef
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ cef2gtk "github.com/bnema/purego-cef2gtk"
8+ "github.com/bnema/puregotk/v4/gdk"
9+)
10+
11+const (
12+ defaultTouchpadNavigationCommitDistance = 320.0
13+ defaultTouchpadNavigationVerticalRatio = 0.5
14+ touchpadNavigationIndicatorActivationFraction = 0.5
15+ touchpadNavigationEdgeFraction = 0.15
16+ touchpadNavigationMaxEdgeDistance = 96.0
17+)
18+
19+type touchpadNavigationRecognizer struct {
20+ cumulativeDX float64
21+ cumulativeDY float64
22+ gestureStartX float64
23+ gestureViewWidth float64
24+ hasGestureStart bool
25+ thresholdReached bool
26+ verticalCanceled bool
27+ indicatorShown bool
28+ lastIndicatorAction entity.TouchpadNavigationAction
29+}
30+
31+type touchpadNavigationInput struct {
32+ Event cef2gtk.ScrollEvent
33+ Config RuntimeInputConfig
34+ CanGoBack bool
35+ CanGoForward bool
36+ ViewWidth float64
37+}
38+
39+type touchpadNavigationResult struct {
40+ HasIndicator bool
41+ Indicator entity.TouchpadNavigationGesture
42+ HasAction bool
43+ Action cef2gtk.NavigationSwipeAction
44+}
45+
46+func newTouchpadNavigationRecognizer() *touchpadNavigationRecognizer {
47+ return &touchpadNavigationRecognizer{}
48+}
49+
50+func (r *touchpadNavigationRecognizer) Handle(input touchpadNavigationInput) touchpadNavigationResult {
51+ if r == nil || !input.Config.TouchpadNavigationEnabled {
52+ return touchpadNavigationResult{}
53+ }
54+
55+ switch input.Event.Phase {
56+ case cef2gtk.ScrollPhaseBegin:
57+ r.reset()
58+ r.setGestureStart(input)
59+ return touchpadNavigationResult{}
60+ case cef2gtk.ScrollPhaseUpdate:
61+ if !isTouchpadNavigationPreciseEvent(input.Event) {
62+ return touchpadNavigationResult{}
63+ }
64+ return r.handleUpdate(input)
65+ case cef2gtk.ScrollPhaseEnd:
66+ return r.handleEnd(input)
67+ default:
68+ return touchpadNavigationResult{}
69+ }
70+}
71+
72+func (r *touchpadNavigationRecognizer) handleUpdate(input touchpadNavigationInput) touchpadNavigationResult {
73+ if r.verticalCanceled {
74+ return touchpadNavigationResult{}
75+ }
76+ if !r.hasGestureStart {
77+ r.setGestureStart(input)
78+ }
79+
80+ // GTK scroll deltas are inverted compared to browser-history direction:
81+ // negative horizontal surface deltas indicate a back swipe, positive indicate
82+ // forward. Match the prior bridge behavior while keeping recognition local so
83+ // normal scroll forwarding remains untouched.
84+ r.cumulativeDX += -input.Event.DX
85+ r.cumulativeDY += input.Event.DY
86+ if r.isTooVertical(input.Config) {
87+ return r.cancelAsTooVertical()
88+ }
89+
90+ action, ok := r.indicatorAction(input)
91+ if !ok {
92+ return touchpadNavigationResult{}
93+ }
94+
95+ threshold := normalizedTouchpadNavigationMinDelta(input.Config.TouchpadNavigationMinDelta)
96+ absDX := math.Abs(r.cumulativeDX)
97+ progress := clampFloat(absDX/threshold, 0, 1)
98+ r.thresholdReached = absDX > threshold
99+ if !r.indicatorShown && progress < touchpadNavigationIndicatorActivationFraction {
100+ return touchpadNavigationResult{}
101+ }
102+ r.indicatorShown = true
103+ r.lastIndicatorAction = action
104+ return touchpadNavigationResult{
105+ HasIndicator: true,
106+ Indicator: entity.TouchpadNavigationGesture{
107+ Action: action,
108+ Progress: progress,
109+ ThresholdReached: r.thresholdReached,
110+ Active: true,
111+ },
112+ }
113+}
114+
115+func (r *touchpadNavigationRecognizer) handleEnd(input touchpadNavigationInput) touchpadNavigationResult {
116+ defer r.reset()
117+ if r.verticalCanceled || !r.thresholdReached || r.isTooVertical(input.Config) {
118+ return r.finishIndicator(input, false)
119+ }
120+ action, ok := r.navigationAction(input)
121+ if !ok {
122+ return r.finishIndicator(input, false)
123+ }
124+ result := r.finishIndicator(input, true)
125+ result.HasAction = true
126+ result.Action = action
127+ return result
128+}
129+
130+func (r *touchpadNavigationRecognizer) finishIndicator(input touchpadNavigationInput, triggered bool) touchpadNavigationResult {
131+ if !r.indicatorShown {
132+ return touchpadNavigationResult{}
133+ }
134+ action, ok := r.indicatorAction(input)
135+ if !ok {
136+ action = r.lastIndicatorAction
137+ }
138+ threshold := normalizedTouchpadNavigationMinDelta(input.Config.TouchpadNavigationMinDelta)
139+ return touchpadNavigationResult{
140+ HasIndicator: true,
141+ Indicator: entity.TouchpadNavigationGesture{
142+ Action: action,
143+ Progress: clampFloat(math.Abs(r.cumulativeDX)/threshold, 0, 1),
144+ ThresholdReached: triggered,
145+ Active: false,
146+ },
147+ }
148+}
149+
150+func (r *touchpadNavigationRecognizer) indicatorAction(input touchpadNavigationInput) (entity.TouchpadNavigationAction, bool) {
151+ return r.resolvedDirection(input)
152+}
153+
154+func (r *touchpadNavigationRecognizer) cancelAsTooVertical() touchpadNavigationResult {
155+ var action entity.TouchpadNavigationAction
156+ hasAction := r.indicatorShown
157+ if hasAction {
158+ action = r.lastIndicatorAction
159+ }
160+ r.reset()
161+ r.verticalCanceled = true
162+ if !hasAction {
163+ return touchpadNavigationResult{}
164+ }
165+ return touchpadNavigationResult{
166+ HasIndicator: true,
167+ Indicator: entity.TouchpadNavigationGesture{
168+ Action: action,
169+ Active: false,
170+ },
171+ }
172+}
173+
174+func (r *touchpadNavigationRecognizer) navigationAction(input touchpadNavigationInput) (cef2gtk.NavigationSwipeAction, bool) {
175+ action, ok := r.resolvedDirection(input)
176+ if !ok {
177+ return cef2gtk.NavigationSwipeBack, false
178+ }
179+ if action == entity.TouchpadNavigationForward {
180+ return cef2gtk.NavigationSwipeForward, true
181+ }
182+ return cef2gtk.NavigationSwipeBack, true
183+}
184+
185+func (r *touchpadNavigationRecognizer) resolvedDirection(input touchpadNavigationInput) (entity.TouchpadNavigationAction, bool) {
186+ if r.cumulativeDX > 0 && input.CanGoBack && r.startedAtNavigationEdge(entity.TouchpadNavigationBack) {
187+ return entity.TouchpadNavigationBack, true
188+ }
189+ if r.cumulativeDX < 0 && input.CanGoForward && r.startedAtNavigationEdge(entity.TouchpadNavigationForward) {
190+ return entity.TouchpadNavigationForward, true
191+ }
192+ return entity.TouchpadNavigationBack, false
193+}
194+
195+func (r *touchpadNavigationRecognizer) setGestureStart(input touchpadNavigationInput) {
196+ r.gestureStartX = input.Event.X
197+ r.gestureViewWidth = input.ViewWidth
198+ r.hasGestureStart = true
199+}
200+
201+func (r *touchpadNavigationRecognizer) startedAtNavigationEdge(action entity.TouchpadNavigationAction) bool {
202+ if r.gestureViewWidth <= 0 {
203+ return false
204+ }
205+ edgeDistance := math.Min(r.gestureViewWidth*touchpadNavigationEdgeFraction, touchpadNavigationMaxEdgeDistance)
206+ if action == entity.TouchpadNavigationForward {
207+ return r.gestureStartX >= r.gestureViewWidth-edgeDistance
208+ }
209+ return r.gestureStartX <= edgeDistance
210+}
211+
212+func (r *touchpadNavigationRecognizer) isTooVertical(cfg RuntimeInputConfig) bool {
213+ absDX, absDY := math.Abs(r.cumulativeDX), math.Abs(r.cumulativeDY)
214+ return absDX == 0 || absDY >= absDX*normalizedTouchpadNavigationRatio(cfg.TouchpadNavigationMaxVerticalRatio)
215+}
216+
217+func (r *touchpadNavigationRecognizer) reset() {
218+ r.cumulativeDX = 0
219+ r.cumulativeDY = 0
220+ r.gestureStartX = 0
221+ r.gestureViewWidth = 0
222+ r.hasGestureStart = false
223+ r.thresholdReached = false
224+ r.verticalCanceled = false
225+ r.indicatorShown = false
226+ r.lastIndicatorAction = entity.TouchpadNavigationBack
227+}
228+
229+func isTouchpadNavigationPreciseEvent(event cef2gtk.ScrollEvent) bool {
230+ return event.UnitKnown && event.Unit == gdk.ScrollUnitSurfaceValue
231+}
232+
233+func normalizedTouchpadNavigationMinDelta(value float64) float64 {
234+ if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
235+ return defaultTouchpadNavigationCommitDistance
236+ }
237+ return value
238+}
239+
240+func normalizedTouchpadNavigationRatio(value float64) float64 {
241+ if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
242+ return defaultTouchpadNavigationVerticalRatio
243+ }
244+ return value
245+}
246+
247+func clampFloat(value, minValue, maxValue float64) float64 {
248+ if value < minValue {
249+ return minValue
250+ }
251+ if value > maxValue {
252+ return maxValue
253+ }
254+ return value
255+}
A internal/infrastructure/cef/touchpad_navigation_test.go +592 -0
@@ -0,0 +1,592 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ cef2gtk "github.com/bnema/purego-cef2gtk"
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestTouchpadNavigationRecognizer_ReportsProgressAndNavigatesOnReleaseAfterThreshold(t *testing.T) {
13+ recognizer := newTouchpadNavigationRecognizer()
14+ cfg := RuntimeInputConfig{
15+ TouchpadNavigationEnabled: true,
16+ TouchpadNavigationMinDelta: 200,
17+ TouchpadNavigationMaxVerticalRatio: 0.5,
18+ }
19+
20+ begin := recognizer.Handle(touchpadNavigationInput{
21+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
22+ Config: cfg,
23+ CanGoBack: true,
24+ CanGoForward: true,
25+ ViewWidth: 1000,
26+ })
27+ require.False(t, begin.HasIndicator)
28+ require.False(t, begin.HasAction)
29+
30+ ordinaryScroll := recognizer.Handle(touchpadNavigationInput{
31+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -80, 0),
32+ Config: cfg,
33+ CanGoBack: true,
34+ CanGoForward: true,
35+ })
36+ require.False(t, ordinaryScroll.HasIndicator, "ordinary horizontal scrolling below the activation distance should not show navigation UI")
37+ require.False(t, ordinaryScroll.HasAction)
38+
39+ progress := recognizer.Handle(touchpadNavigationInput{
40+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -40, 0),
41+ Config: cfg,
42+ CanGoBack: true,
43+ CanGoForward: true,
44+ })
45+ require.True(t, progress.HasIndicator)
46+ require.Equal(t, entity.TouchpadNavigationBack, progress.Indicator.Action)
47+ require.True(t, progress.Indicator.Active)
48+ require.InDelta(t, 0.6, progress.Indicator.Progress, 0.001)
49+ require.False(t, progress.Indicator.ThresholdReached)
50+ require.False(t, progress.HasAction)
51+
52+ armed := recognizer.Handle(touchpadNavigationInput{
53+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -100, 0),
54+ Config: cfg,
55+ CanGoBack: true,
56+ CanGoForward: true,
57+ })
58+ require.True(t, armed.HasIndicator)
59+ require.Equal(t, entity.TouchpadNavigationBack, armed.Indicator.Action)
60+ require.True(t, armed.Indicator.Active)
61+ require.InDelta(t, 1.0, armed.Indicator.Progress, 0.001)
62+ require.True(t, armed.Indicator.ThresholdReached)
63+ require.False(t, armed.HasAction, "navigation waits for gesture release so the indicator is visible first")
64+
65+ end := recognizer.Handle(touchpadNavigationInput{
66+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
67+ Config: cfg,
68+ CanGoBack: true,
69+ CanGoForward: true,
70+ })
71+ require.True(t, end.HasIndicator)
72+ require.False(t, end.Indicator.Active)
73+ require.True(t, end.HasAction)
74+ require.Equal(t, cef2gtk.NavigationSwipeBack, end.Action)
75+}
76+
77+func TestTouchpadNavigationRecognizer_ReportsForwardProgressAndNavigatesOnReleaseAfterThreshold(t *testing.T) {
78+ recognizer := newTouchpadNavigationRecognizer()
79+ cfg := RuntimeInputConfig{
80+ TouchpadNavigationEnabled: true,
81+ TouchpadNavigationMinDelta: 200,
82+ TouchpadNavigationMaxVerticalRatio: 0.5,
83+ }
84+
85+ begin := recognizer.Handle(touchpadNavigationInput{
86+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 976, 0, 0),
87+ Config: cfg,
88+ CanGoBack: true,
89+ CanGoForward: true,
90+ ViewWidth: 1000,
91+ })
92+ require.False(t, begin.HasIndicator)
93+ require.False(t, begin.HasAction)
94+
95+ armed := recognizer.Handle(touchpadNavigationInput{
96+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 976, 240, 0),
97+ Config: cfg,
98+ CanGoBack: true,
99+ CanGoForward: true,
100+ })
101+ require.True(t, armed.HasIndicator)
102+ require.Equal(t, entity.TouchpadNavigationForward, armed.Indicator.Action)
103+ require.True(t, armed.Indicator.Active)
104+ require.InDelta(t, 1.0, armed.Indicator.Progress, 0.001)
105+ require.True(t, armed.Indicator.ThresholdReached)
106+ require.False(t, armed.HasAction)
107+
108+ end := recognizer.Handle(touchpadNavigationInput{
109+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 976, 0, 0),
110+ Config: cfg,
111+ CanGoBack: true,
112+ CanGoForward: true,
113+ })
114+ require.True(t, end.HasIndicator)
115+ require.False(t, end.Indicator.Active)
116+ require.True(t, end.HasAction)
117+ require.Equal(t, cef2gtk.NavigationSwipeForward, end.Action)
118+}
119+
120+func TestTouchpadNavigationRecognizer_DoesNotNavigateAtExactThreshold(t *testing.T) {
121+ recognizer := newTouchpadNavigationRecognizer()
122+ cfg := RuntimeInputConfig{
123+ TouchpadNavigationEnabled: true,
124+ TouchpadNavigationMinDelta: 200,
125+ TouchpadNavigationMaxVerticalRatio: 0.5,
126+ }
127+
128+ _ = recognizer.Handle(touchpadNavigationInput{
129+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
130+ Config: cfg,
131+ CanGoBack: true,
132+ ViewWidth: 1000,
133+ })
134+ exact := recognizer.Handle(touchpadNavigationInput{
135+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -200, 0),
136+ Config: cfg,
137+ CanGoBack: true,
138+ })
139+ require.True(t, exact.HasIndicator)
140+ require.InDelta(t, 1.0, exact.Indicator.Progress, 0.001)
141+ require.False(t, exact.Indicator.ThresholdReached)
142+
143+ end := recognizer.Handle(touchpadNavigationInput{
144+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
145+ Config: cfg,
146+ CanGoBack: true,
147+ })
148+ require.True(t, end.HasIndicator)
149+ require.False(t, end.Indicator.Active)
150+ require.False(t, end.Indicator.ThresholdReached)
151+ require.False(t, end.HasAction)
152+}
153+
154+func TestTouchpadNavigationRecognizer_DoesNotShowIndicatorBelowActivationDistance(t *testing.T) {
155+ recognizer := newTouchpadNavigationRecognizer()
156+ cfg := RuntimeInputConfig{
157+ TouchpadNavigationEnabled: true,
158+ TouchpadNavigationMinDelta: 200,
159+ TouchpadNavigationMaxVerticalRatio: 0.5,
160+ }
161+
162+ _ = recognizer.Handle(touchpadNavigationInput{
163+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
164+ Config: cfg,
165+ CanGoBack: true,
166+ ViewWidth: 1000,
167+ })
168+ ordinaryScroll := recognizer.Handle(touchpadNavigationInput{
169+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -80, 0),
170+ Config: cfg,
171+ CanGoBack: true,
172+ })
173+ require.False(t, ordinaryScroll.HasIndicator)
174+ require.False(t, ordinaryScroll.HasAction)
175+
176+ end := recognizer.Handle(touchpadNavigationInput{
177+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
178+ Config: cfg,
179+ CanGoBack: true,
180+ })
181+ require.False(t, end.HasAction)
182+ require.False(t, end.HasIndicator)
183+}
184+
185+func TestTouchpadNavigationRecognizer_AllowsIntentionalEdgeSwipeWithViewWidth(t *testing.T) {
186+ recognizer := newTouchpadNavigationRecognizer()
187+ cfg := RuntimeInputConfig{
188+ TouchpadNavigationEnabled: true,
189+ TouchpadNavigationMinDelta: 200,
190+ TouchpadNavigationMaxVerticalRatio: 0.5,
191+ }
192+
193+ _ = recognizer.Handle(touchpadNavigationInput{
194+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 24, 0, 0),
195+ Config: cfg,
196+ CanGoBack: true,
197+ ViewWidth: 1000,
198+ })
199+ armed := recognizer.Handle(touchpadNavigationInput{
200+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 24, -240, 0),
201+ Config: cfg,
202+ CanGoBack: true,
203+ })
204+ require.True(t, armed.HasIndicator)
205+ require.True(t, armed.Indicator.ThresholdReached)
206+
207+ end := recognizer.Handle(touchpadNavigationInput{
208+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 24, 0, 0),
209+ Config: cfg,
210+ CanGoBack: true,
211+ })
212+ require.True(t, end.HasAction)
213+ require.Equal(t, cef2gtk.NavigationSwipeBack, end.Action)
214+}
215+
216+func TestTouchpadNavigationRecognizer_DoesNotTreatInContentHorizontalScrollAsNavigation(t *testing.T) {
217+ recognizer := newTouchpadNavigationRecognizer()
218+ cfg := RuntimeInputConfig{
219+ TouchpadNavigationEnabled: true,
220+ TouchpadNavigationMinDelta: 200,
221+ TouchpadNavigationMaxVerticalRatio: 0.5,
222+ }
223+
224+ _ = recognizer.Handle(touchpadNavigationInput{
225+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 500, 0, 0),
226+ Config: cfg,
227+ CanGoBack: true,
228+ ViewWidth: 1000,
229+ })
230+ ordinaryScroll := recognizer.Handle(touchpadNavigationInput{
231+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 500, -240, 0),
232+ Config: cfg,
233+ CanGoBack: true,
234+ ViewWidth: 1000,
235+ })
236+ require.False(t, ordinaryScroll.HasIndicator)
237+ require.False(t, ordinaryScroll.HasAction)
238+
239+ end := recognizer.Handle(touchpadNavigationInput{
240+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 500, 0, 0),
241+ Config: cfg,
242+ CanGoBack: true,
243+ ViewWidth: 1000,
244+ })
245+ require.False(t, end.HasIndicator)
246+ require.False(t, end.HasAction)
247+}
248+
249+func TestTouchpadNavigationRecognizer_IgnoresNonPreciseScrollEvents(t *testing.T) {
250+ tests := []struct {
251+ name string
252+ event cef2gtk.ScrollEvent
253+ }{
254+ {
255+ name: "unknown unit",
256+ event: cef2gtk.ScrollEvent{
257+ Phase: cef2gtk.ScrollPhaseUpdate,
258+ DX: -240,
259+ Unit: gdk.ScrollUnitSurfaceValue,
260+ UnitKnown: false,
261+ },
262+ },
263+ {
264+ name: "wheel unit",
265+ event: cef2gtk.ScrollEvent{
266+ Phase: cef2gtk.ScrollPhaseUpdate,
267+ DX: -240,
268+ Unit: gdk.ScrollUnitWheelValue,
269+ UnitKnown: true,
270+ },
271+ },
272+ }
273+
274+ for _, tt := range tests {
275+ t.Run(tt.name, func(t *testing.T) {
276+ recognizer := newTouchpadNavigationRecognizer()
277+ cfg := RuntimeInputConfig{
278+ TouchpadNavigationEnabled: true,
279+ TouchpadNavigationMinDelta: 200,
280+ TouchpadNavigationMaxVerticalRatio: 0.5,
281+ }
282+
283+ _ = recognizer.Handle(touchpadNavigationInput{
284+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
285+ Config: cfg,
286+ CanGoBack: true,
287+ ViewWidth: 1000,
288+ })
289+ result := recognizer.Handle(touchpadNavigationInput{
290+ Event: tt.event,
291+ Config: cfg,
292+ CanGoBack: true,
293+ })
294+ require.False(t, result.HasIndicator)
295+ require.False(t, result.HasAction)
296+ })
297+ }
298+}
299+
300+func TestTouchpadNavigationRecognizer_DisabledConfigSuppressesGestureRecognition(t *testing.T) {
301+ recognizer := newTouchpadNavigationRecognizer()
302+ cfg := RuntimeInputConfig{
303+ TouchpadNavigationEnabled: false,
304+ TouchpadNavigationMinDelta: 200,
305+ TouchpadNavigationMaxVerticalRatio: 0.5,
306+ }
307+
308+ begin := recognizer.Handle(touchpadNavigationInput{
309+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
310+ Config: cfg,
311+ CanGoBack: true,
312+ ViewWidth: 1000,
313+ })
314+ require.False(t, begin.HasIndicator)
315+ require.False(t, begin.HasAction)
316+
317+ update := recognizer.Handle(touchpadNavigationInput{
318+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -240, 0),
319+ Config: cfg,
320+ CanGoBack: true,
321+ })
322+ require.False(t, update.HasIndicator)
323+ require.False(t, update.HasAction)
324+
325+ end := recognizer.Handle(touchpadNavigationInput{
326+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
327+ Config: cfg,
328+ CanGoBack: true,
329+ })
330+ require.False(t, end.HasIndicator)
331+ require.False(t, end.HasAction)
332+}
333+
334+func TestTouchpadNavigationRecognizer_BackCapabilitySuppressesBackGesture(t *testing.T) {
335+ recognizer := newTouchpadNavigationRecognizer()
336+ cfg := RuntimeInputConfig{
337+ TouchpadNavigationEnabled: true,
338+ TouchpadNavigationMinDelta: 200,
339+ TouchpadNavigationMaxVerticalRatio: 0.5,
340+ }
341+
342+ _ = recognizer.Handle(touchpadNavigationInput{
343+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
344+ Config: cfg,
345+ CanGoBack: false,
346+ ViewWidth: 1000,
347+ })
348+ update := recognizer.Handle(touchpadNavigationInput{
349+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -240, 0),
350+ Config: cfg,
351+ CanGoBack: false,
352+ })
353+ require.False(t, update.HasIndicator)
354+ require.False(t, update.HasAction)
355+
356+ end := recognizer.Handle(touchpadNavigationInput{
357+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
358+ Config: cfg,
359+ CanGoBack: false,
360+ })
361+ require.False(t, end.HasIndicator)
362+ require.False(t, end.HasAction)
363+}
364+
365+func TestTouchpadNavigationRecognizer_ForwardCapabilitySuppressesForwardGesture(t *testing.T) {
366+ recognizer := newTouchpadNavigationRecognizer()
367+ cfg := RuntimeInputConfig{
368+ TouchpadNavigationEnabled: true,
369+ TouchpadNavigationMinDelta: 200,
370+ TouchpadNavigationMaxVerticalRatio: 0.5,
371+ }
372+
373+ _ = recognizer.Handle(touchpadNavigationInput{
374+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 976, 0, 0),
375+ Config: cfg,
376+ CanGoForward: false,
377+ ViewWidth: 1000,
378+ })
379+ update := recognizer.Handle(touchpadNavigationInput{
380+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 976, 240, 0),
381+ Config: cfg,
382+ CanGoForward: false,
383+ })
384+ require.False(t, update.HasIndicator)
385+ require.False(t, update.HasAction)
386+
387+ end := recognizer.Handle(touchpadNavigationInput{
388+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 976, 0, 0),
389+ Config: cfg,
390+ CanGoForward: false,
391+ })
392+ require.False(t, end.HasIndicator)
393+ require.False(t, end.HasAction)
394+}
395+
396+func TestTouchpadNavigationRecognizer_HidesIndicatorWithoutNavigatingWhenReleasedAfterActivationBelowThreshold(t *testing.T) {
397+ recognizer := newTouchpadNavigationRecognizer()
398+ cfg := RuntimeInputConfig{
399+ TouchpadNavigationEnabled: true,
400+ TouchpadNavigationMinDelta: 200,
401+ TouchpadNavigationMaxVerticalRatio: 0.5,
402+ }
403+
404+ _ = recognizer.Handle(touchpadNavigationInput{
405+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
406+ Config: cfg,
407+ CanGoBack: true,
408+ ViewWidth: 1000,
409+ })
410+ progress := recognizer.Handle(touchpadNavigationInput{
411+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -120, 0),
412+ Config: cfg,
413+ CanGoBack: true,
414+ })
415+ require.True(t, progress.HasIndicator)
416+ require.InDelta(t, 0.6, progress.Indicator.Progress, 0.001)
417+ require.False(t, progress.Indicator.ThresholdReached)
418+
419+ end := recognizer.Handle(touchpadNavigationInput{
420+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
421+ Config: cfg,
422+ CanGoBack: true,
423+ })
424+ require.False(t, end.HasAction)
425+ require.True(t, end.HasIndicator)
426+ require.False(t, end.Indicator.Active)
427+}
428+
429+func TestTouchpadNavigationRecognizer_ResetsBetweenConsecutiveGestures(t *testing.T) {
430+ recognizer := newTouchpadNavigationRecognizer()
431+ cfg := RuntimeInputConfig{
432+ TouchpadNavigationEnabled: true,
433+ TouchpadNavigationMinDelta: 200,
434+ TouchpadNavigationMaxVerticalRatio: 0.5,
435+ }
436+
437+ _ = recognizer.Handle(touchpadNavigationInput{
438+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
439+ Config: cfg,
440+ CanGoBack: true,
441+ ViewWidth: 1000,
442+ })
443+ firstUpdate := recognizer.Handle(touchpadNavigationInput{
444+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -240, 0),
445+ Config: cfg,
446+ CanGoBack: true,
447+ })
448+ require.True(t, firstUpdate.HasIndicator)
449+ firstEnd := recognizer.Handle(touchpadNavigationInput{
450+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
451+ Config: cfg,
452+ CanGoBack: true,
453+ })
454+ require.True(t, firstEnd.HasAction)
455+ require.Equal(t, cef2gtk.NavigationSwipeBack, firstEnd.Action)
456+
457+ _ = recognizer.Handle(touchpadNavigationInput{
458+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 976, 0, 0),
459+ Config: cfg,
460+ CanGoForward: true,
461+ ViewWidth: 1000,
462+ })
463+ secondUpdate := recognizer.Handle(touchpadNavigationInput{
464+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 976, 240, 0),
465+ Config: cfg,
466+ CanGoForward: true,
467+ })
468+ require.True(t, secondUpdate.HasIndicator)
469+ require.Equal(t, entity.TouchpadNavigationForward, secondUpdate.Indicator.Action)
470+ secondEnd := recognizer.Handle(touchpadNavigationInput{
471+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 976, 0, 0),
472+ Config: cfg,
473+ CanGoForward: true,
474+ })
475+ require.True(t, secondEnd.HasAction)
476+ require.Equal(t, cef2gtk.NavigationSwipeForward, secondEnd.Action)
477+}
478+
479+func TestTouchpadNavigationRecognizer_CancelsVerticalGestures(t *testing.T) {
480+ recognizer := newTouchpadNavigationRecognizer()
481+ cfg := RuntimeInputConfig{
482+ TouchpadNavigationEnabled: true,
483+ TouchpadNavigationMinDelta: 200,
484+ TouchpadNavigationMaxVerticalRatio: 0.5,
485+ }
486+
487+ _ = recognizer.Handle(touchpadNavigationInput{
488+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
489+ Config: cfg,
490+ CanGoBack: true,
491+ ViewWidth: 1000,
492+ })
493+ vertical := recognizer.Handle(touchpadNavigationInput{
494+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -60, 80),
495+ Config: cfg,
496+ CanGoBack: true,
497+ })
498+ require.False(t, vertical.HasIndicator)
499+ require.False(t, vertical.HasAction)
500+
501+ continued := recognizer.Handle(touchpadNavigationInput{
502+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -400, 0),
503+ Config: cfg,
504+ CanGoBack: true,
505+ })
506+ require.False(t, continued.HasIndicator)
507+ require.False(t, continued.HasAction)
508+
509+ end := recognizer.Handle(touchpadNavigationInput{
510+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
511+ Config: cfg,
512+ CanGoBack: true,
513+ })
514+ require.False(t, end.HasAction)
515+}
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+
577+func touchpadNavigationScrollEvent(phase cef2gtk.ScrollPhase, dx, dy float64) cef2gtk.ScrollEvent {
578+ return touchpadNavigationScrollEventAt(phase, 0, dx, dy)
579+}
580+
581+// ViewWidth is provided separately on touchpadNavigationInput; the recognizer
582+// caches it from the initial Begin event for subsequent Update and End phases.
583+func touchpadNavigationScrollEventAt(phase cef2gtk.ScrollPhase, x, dx, dy float64) cef2gtk.ScrollEvent {
584+ return cef2gtk.ScrollEvent{
585+ Phase: phase,
586+ X: x,
587+ DX: dx,
588+ DY: dy,
589+ Unit: gdk.ScrollUnitSurfaceValue,
590+ UnitKnown: true,
591+ }
592+}
@@ -0,0 +1,592 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ cef2gtk "github.com/bnema/purego-cef2gtk"
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestTouchpadNavigationRecognizer_ReportsProgressAndNavigatesOnReleaseAfterThreshold(t *testing.T) {
13+ recognizer := newTouchpadNavigationRecognizer()
14+ cfg := RuntimeInputConfig{
15+ TouchpadNavigationEnabled: true,
16+ TouchpadNavigationMinDelta: 200,
17+ TouchpadNavigationMaxVerticalRatio: 0.5,
18+ }
19+
20+ begin := recognizer.Handle(touchpadNavigationInput{
21+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
22+ Config: cfg,
23+ CanGoBack: true,
24+ CanGoForward: true,
25+ ViewWidth: 1000,
26+ })
27+ require.False(t, begin.HasIndicator)
28+ require.False(t, begin.HasAction)
29+
30+ ordinaryScroll := recognizer.Handle(touchpadNavigationInput{
31+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -80, 0),
32+ Config: cfg,
33+ CanGoBack: true,
34+ CanGoForward: true,
35+ })
36+ require.False(t, ordinaryScroll.HasIndicator, "ordinary horizontal scrolling below the activation distance should not show navigation UI")
37+ require.False(t, ordinaryScroll.HasAction)
38+
39+ progress := recognizer.Handle(touchpadNavigationInput{
40+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -40, 0),
41+ Config: cfg,
42+ CanGoBack: true,
43+ CanGoForward: true,
44+ })
45+ require.True(t, progress.HasIndicator)
46+ require.Equal(t, entity.TouchpadNavigationBack, progress.Indicator.Action)
47+ require.True(t, progress.Indicator.Active)
48+ require.InDelta(t, 0.6, progress.Indicator.Progress, 0.001)
49+ require.False(t, progress.Indicator.ThresholdReached)
50+ require.False(t, progress.HasAction)
51+
52+ armed := recognizer.Handle(touchpadNavigationInput{
53+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -100, 0),
54+ Config: cfg,
55+ CanGoBack: true,
56+ CanGoForward: true,
57+ })
58+ require.True(t, armed.HasIndicator)
59+ require.Equal(t, entity.TouchpadNavigationBack, armed.Indicator.Action)
60+ require.True(t, armed.Indicator.Active)
61+ require.InDelta(t, 1.0, armed.Indicator.Progress, 0.001)
62+ require.True(t, armed.Indicator.ThresholdReached)
63+ require.False(t, armed.HasAction, "navigation waits for gesture release so the indicator is visible first")
64+
65+ end := recognizer.Handle(touchpadNavigationInput{
66+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
67+ Config: cfg,
68+ CanGoBack: true,
69+ CanGoForward: true,
70+ })
71+ require.True(t, end.HasIndicator)
72+ require.False(t, end.Indicator.Active)
73+ require.True(t, end.HasAction)
74+ require.Equal(t, cef2gtk.NavigationSwipeBack, end.Action)
75+}
76+
77+func TestTouchpadNavigationRecognizer_ReportsForwardProgressAndNavigatesOnReleaseAfterThreshold(t *testing.T) {
78+ recognizer := newTouchpadNavigationRecognizer()
79+ cfg := RuntimeInputConfig{
80+ TouchpadNavigationEnabled: true,
81+ TouchpadNavigationMinDelta: 200,
82+ TouchpadNavigationMaxVerticalRatio: 0.5,
83+ }
84+
85+ begin := recognizer.Handle(touchpadNavigationInput{
86+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 976, 0, 0),
87+ Config: cfg,
88+ CanGoBack: true,
89+ CanGoForward: true,
90+ ViewWidth: 1000,
91+ })
92+ require.False(t, begin.HasIndicator)
93+ require.False(t, begin.HasAction)
94+
95+ armed := recognizer.Handle(touchpadNavigationInput{
96+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 976, 240, 0),
97+ Config: cfg,
98+ CanGoBack: true,
99+ CanGoForward: true,
100+ })
101+ require.True(t, armed.HasIndicator)
102+ require.Equal(t, entity.TouchpadNavigationForward, armed.Indicator.Action)
103+ require.True(t, armed.Indicator.Active)
104+ require.InDelta(t, 1.0, armed.Indicator.Progress, 0.001)
105+ require.True(t, armed.Indicator.ThresholdReached)
106+ require.False(t, armed.HasAction)
107+
108+ end := recognizer.Handle(touchpadNavigationInput{
109+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 976, 0, 0),
110+ Config: cfg,
111+ CanGoBack: true,
112+ CanGoForward: true,
113+ })
114+ require.True(t, end.HasIndicator)
115+ require.False(t, end.Indicator.Active)
116+ require.True(t, end.HasAction)
117+ require.Equal(t, cef2gtk.NavigationSwipeForward, end.Action)
118+}
119+
120+func TestTouchpadNavigationRecognizer_DoesNotNavigateAtExactThreshold(t *testing.T) {
121+ recognizer := newTouchpadNavigationRecognizer()
122+ cfg := RuntimeInputConfig{
123+ TouchpadNavigationEnabled: true,
124+ TouchpadNavigationMinDelta: 200,
125+ TouchpadNavigationMaxVerticalRatio: 0.5,
126+ }
127+
128+ _ = recognizer.Handle(touchpadNavigationInput{
129+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
130+ Config: cfg,
131+ CanGoBack: true,
132+ ViewWidth: 1000,
133+ })
134+ exact := recognizer.Handle(touchpadNavigationInput{
135+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -200, 0),
136+ Config: cfg,
137+ CanGoBack: true,
138+ })
139+ require.True(t, exact.HasIndicator)
140+ require.InDelta(t, 1.0, exact.Indicator.Progress, 0.001)
141+ require.False(t, exact.Indicator.ThresholdReached)
142+
143+ end := recognizer.Handle(touchpadNavigationInput{
144+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
145+ Config: cfg,
146+ CanGoBack: true,
147+ })
148+ require.True(t, end.HasIndicator)
149+ require.False(t, end.Indicator.Active)
150+ require.False(t, end.Indicator.ThresholdReached)
151+ require.False(t, end.HasAction)
152+}
153+
154+func TestTouchpadNavigationRecognizer_DoesNotShowIndicatorBelowActivationDistance(t *testing.T) {
155+ recognizer := newTouchpadNavigationRecognizer()
156+ cfg := RuntimeInputConfig{
157+ TouchpadNavigationEnabled: true,
158+ TouchpadNavigationMinDelta: 200,
159+ TouchpadNavigationMaxVerticalRatio: 0.5,
160+ }
161+
162+ _ = recognizer.Handle(touchpadNavigationInput{
163+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
164+ Config: cfg,
165+ CanGoBack: true,
166+ ViewWidth: 1000,
167+ })
168+ ordinaryScroll := recognizer.Handle(touchpadNavigationInput{
169+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -80, 0),
170+ Config: cfg,
171+ CanGoBack: true,
172+ })
173+ require.False(t, ordinaryScroll.HasIndicator)
174+ require.False(t, ordinaryScroll.HasAction)
175+
176+ end := recognizer.Handle(touchpadNavigationInput{
177+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
178+ Config: cfg,
179+ CanGoBack: true,
180+ })
181+ require.False(t, end.HasAction)
182+ require.False(t, end.HasIndicator)
183+}
184+
185+func TestTouchpadNavigationRecognizer_AllowsIntentionalEdgeSwipeWithViewWidth(t *testing.T) {
186+ recognizer := newTouchpadNavigationRecognizer()
187+ cfg := RuntimeInputConfig{
188+ TouchpadNavigationEnabled: true,
189+ TouchpadNavigationMinDelta: 200,
190+ TouchpadNavigationMaxVerticalRatio: 0.5,
191+ }
192+
193+ _ = recognizer.Handle(touchpadNavigationInput{
194+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 24, 0, 0),
195+ Config: cfg,
196+ CanGoBack: true,
197+ ViewWidth: 1000,
198+ })
199+ armed := recognizer.Handle(touchpadNavigationInput{
200+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 24, -240, 0),
201+ Config: cfg,
202+ CanGoBack: true,
203+ })
204+ require.True(t, armed.HasIndicator)
205+ require.True(t, armed.Indicator.ThresholdReached)
206+
207+ end := recognizer.Handle(touchpadNavigationInput{
208+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 24, 0, 0),
209+ Config: cfg,
210+ CanGoBack: true,
211+ })
212+ require.True(t, end.HasAction)
213+ require.Equal(t, cef2gtk.NavigationSwipeBack, end.Action)
214+}
215+
216+func TestTouchpadNavigationRecognizer_DoesNotTreatInContentHorizontalScrollAsNavigation(t *testing.T) {
217+ recognizer := newTouchpadNavigationRecognizer()
218+ cfg := RuntimeInputConfig{
219+ TouchpadNavigationEnabled: true,
220+ TouchpadNavigationMinDelta: 200,
221+ TouchpadNavigationMaxVerticalRatio: 0.5,
222+ }
223+
224+ _ = recognizer.Handle(touchpadNavigationInput{
225+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 500, 0, 0),
226+ Config: cfg,
227+ CanGoBack: true,
228+ ViewWidth: 1000,
229+ })
230+ ordinaryScroll := recognizer.Handle(touchpadNavigationInput{
231+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 500, -240, 0),
232+ Config: cfg,
233+ CanGoBack: true,
234+ ViewWidth: 1000,
235+ })
236+ require.False(t, ordinaryScroll.HasIndicator)
237+ require.False(t, ordinaryScroll.HasAction)
238+
239+ end := recognizer.Handle(touchpadNavigationInput{
240+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 500, 0, 0),
241+ Config: cfg,
242+ CanGoBack: true,
243+ ViewWidth: 1000,
244+ })
245+ require.False(t, end.HasIndicator)
246+ require.False(t, end.HasAction)
247+}
248+
249+func TestTouchpadNavigationRecognizer_IgnoresNonPreciseScrollEvents(t *testing.T) {
250+ tests := []struct {
251+ name string
252+ event cef2gtk.ScrollEvent
253+ }{
254+ {
255+ name: "unknown unit",
256+ event: cef2gtk.ScrollEvent{
257+ Phase: cef2gtk.ScrollPhaseUpdate,
258+ DX: -240,
259+ Unit: gdk.ScrollUnitSurfaceValue,
260+ UnitKnown: false,
261+ },
262+ },
263+ {
264+ name: "wheel unit",
265+ event: cef2gtk.ScrollEvent{
266+ Phase: cef2gtk.ScrollPhaseUpdate,
267+ DX: -240,
268+ Unit: gdk.ScrollUnitWheelValue,
269+ UnitKnown: true,
270+ },
271+ },
272+ }
273+
274+ for _, tt := range tests {
275+ t.Run(tt.name, func(t *testing.T) {
276+ recognizer := newTouchpadNavigationRecognizer()
277+ cfg := RuntimeInputConfig{
278+ TouchpadNavigationEnabled: true,
279+ TouchpadNavigationMinDelta: 200,
280+ TouchpadNavigationMaxVerticalRatio: 0.5,
281+ }
282+
283+ _ = recognizer.Handle(touchpadNavigationInput{
284+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
285+ Config: cfg,
286+ CanGoBack: true,
287+ ViewWidth: 1000,
288+ })
289+ result := recognizer.Handle(touchpadNavigationInput{
290+ Event: tt.event,
291+ Config: cfg,
292+ CanGoBack: true,
293+ })
294+ require.False(t, result.HasIndicator)
295+ require.False(t, result.HasAction)
296+ })
297+ }
298+}
299+
300+func TestTouchpadNavigationRecognizer_DisabledConfigSuppressesGestureRecognition(t *testing.T) {
301+ recognizer := newTouchpadNavigationRecognizer()
302+ cfg := RuntimeInputConfig{
303+ TouchpadNavigationEnabled: false,
304+ TouchpadNavigationMinDelta: 200,
305+ TouchpadNavigationMaxVerticalRatio: 0.5,
306+ }
307+
308+ begin := recognizer.Handle(touchpadNavigationInput{
309+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
310+ Config: cfg,
311+ CanGoBack: true,
312+ ViewWidth: 1000,
313+ })
314+ require.False(t, begin.HasIndicator)
315+ require.False(t, begin.HasAction)
316+
317+ update := recognizer.Handle(touchpadNavigationInput{
318+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -240, 0),
319+ Config: cfg,
320+ CanGoBack: true,
321+ })
322+ require.False(t, update.HasIndicator)
323+ require.False(t, update.HasAction)
324+
325+ end := recognizer.Handle(touchpadNavigationInput{
326+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
327+ Config: cfg,
328+ CanGoBack: true,
329+ })
330+ require.False(t, end.HasIndicator)
331+ require.False(t, end.HasAction)
332+}
333+
334+func TestTouchpadNavigationRecognizer_BackCapabilitySuppressesBackGesture(t *testing.T) {
335+ recognizer := newTouchpadNavigationRecognizer()
336+ cfg := RuntimeInputConfig{
337+ TouchpadNavigationEnabled: true,
338+ TouchpadNavigationMinDelta: 200,
339+ TouchpadNavigationMaxVerticalRatio: 0.5,
340+ }
341+
342+ _ = recognizer.Handle(touchpadNavigationInput{
343+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
344+ Config: cfg,
345+ CanGoBack: false,
346+ ViewWidth: 1000,
347+ })
348+ update := recognizer.Handle(touchpadNavigationInput{
349+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -240, 0),
350+ Config: cfg,
351+ CanGoBack: false,
352+ })
353+ require.False(t, update.HasIndicator)
354+ require.False(t, update.HasAction)
355+
356+ end := recognizer.Handle(touchpadNavigationInput{
357+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
358+ Config: cfg,
359+ CanGoBack: false,
360+ })
361+ require.False(t, end.HasIndicator)
362+ require.False(t, end.HasAction)
363+}
364+
365+func TestTouchpadNavigationRecognizer_ForwardCapabilitySuppressesForwardGesture(t *testing.T) {
366+ recognizer := newTouchpadNavigationRecognizer()
367+ cfg := RuntimeInputConfig{
368+ TouchpadNavigationEnabled: true,
369+ TouchpadNavigationMinDelta: 200,
370+ TouchpadNavigationMaxVerticalRatio: 0.5,
371+ }
372+
373+ _ = recognizer.Handle(touchpadNavigationInput{
374+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 976, 0, 0),
375+ Config: cfg,
376+ CanGoForward: false,
377+ ViewWidth: 1000,
378+ })
379+ update := recognizer.Handle(touchpadNavigationInput{
380+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 976, 240, 0),
381+ Config: cfg,
382+ CanGoForward: false,
383+ })
384+ require.False(t, update.HasIndicator)
385+ require.False(t, update.HasAction)
386+
387+ end := recognizer.Handle(touchpadNavigationInput{
388+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 976, 0, 0),
389+ Config: cfg,
390+ CanGoForward: false,
391+ })
392+ require.False(t, end.HasIndicator)
393+ require.False(t, end.HasAction)
394+}
395+
396+func TestTouchpadNavigationRecognizer_HidesIndicatorWithoutNavigatingWhenReleasedAfterActivationBelowThreshold(t *testing.T) {
397+ recognizer := newTouchpadNavigationRecognizer()
398+ cfg := RuntimeInputConfig{
399+ TouchpadNavigationEnabled: true,
400+ TouchpadNavigationMinDelta: 200,
401+ TouchpadNavigationMaxVerticalRatio: 0.5,
402+ }
403+
404+ _ = recognizer.Handle(touchpadNavigationInput{
405+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
406+ Config: cfg,
407+ CanGoBack: true,
408+ ViewWidth: 1000,
409+ })
410+ progress := recognizer.Handle(touchpadNavigationInput{
411+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -120, 0),
412+ Config: cfg,
413+ CanGoBack: true,
414+ })
415+ require.True(t, progress.HasIndicator)
416+ require.InDelta(t, 0.6, progress.Indicator.Progress, 0.001)
417+ require.False(t, progress.Indicator.ThresholdReached)
418+
419+ end := recognizer.Handle(touchpadNavigationInput{
420+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
421+ Config: cfg,
422+ CanGoBack: true,
423+ })
424+ require.False(t, end.HasAction)
425+ require.True(t, end.HasIndicator)
426+ require.False(t, end.Indicator.Active)
427+}
428+
429+func TestTouchpadNavigationRecognizer_ResetsBetweenConsecutiveGestures(t *testing.T) {
430+ recognizer := newTouchpadNavigationRecognizer()
431+ cfg := RuntimeInputConfig{
432+ TouchpadNavigationEnabled: true,
433+ TouchpadNavigationMinDelta: 200,
434+ TouchpadNavigationMaxVerticalRatio: 0.5,
435+ }
436+
437+ _ = recognizer.Handle(touchpadNavigationInput{
438+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
439+ Config: cfg,
440+ CanGoBack: true,
441+ ViewWidth: 1000,
442+ })
443+ firstUpdate := recognizer.Handle(touchpadNavigationInput{
444+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -240, 0),
445+ Config: cfg,
446+ CanGoBack: true,
447+ })
448+ require.True(t, firstUpdate.HasIndicator)
449+ firstEnd := recognizer.Handle(touchpadNavigationInput{
450+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
451+ Config: cfg,
452+ CanGoBack: true,
453+ })
454+ require.True(t, firstEnd.HasAction)
455+ require.Equal(t, cef2gtk.NavigationSwipeBack, firstEnd.Action)
456+
457+ _ = recognizer.Handle(touchpadNavigationInput{
458+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseBegin, 976, 0, 0),
459+ Config: cfg,
460+ CanGoForward: true,
461+ ViewWidth: 1000,
462+ })
463+ secondUpdate := recognizer.Handle(touchpadNavigationInput{
464+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseUpdate, 976, 240, 0),
465+ Config: cfg,
466+ CanGoForward: true,
467+ })
468+ require.True(t, secondUpdate.HasIndicator)
469+ require.Equal(t, entity.TouchpadNavigationForward, secondUpdate.Indicator.Action)
470+ secondEnd := recognizer.Handle(touchpadNavigationInput{
471+ Event: touchpadNavigationScrollEventAt(cef2gtk.ScrollPhaseEnd, 976, 0, 0),
472+ Config: cfg,
473+ CanGoForward: true,
474+ })
475+ require.True(t, secondEnd.HasAction)
476+ require.Equal(t, cef2gtk.NavigationSwipeForward, secondEnd.Action)
477+}
478+
479+func TestTouchpadNavigationRecognizer_CancelsVerticalGestures(t *testing.T) {
480+ recognizer := newTouchpadNavigationRecognizer()
481+ cfg := RuntimeInputConfig{
482+ TouchpadNavigationEnabled: true,
483+ TouchpadNavigationMinDelta: 200,
484+ TouchpadNavigationMaxVerticalRatio: 0.5,
485+ }
486+
487+ _ = recognizer.Handle(touchpadNavigationInput{
488+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
489+ Config: cfg,
490+ CanGoBack: true,
491+ ViewWidth: 1000,
492+ })
493+ vertical := recognizer.Handle(touchpadNavigationInput{
494+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -60, 80),
495+ Config: cfg,
496+ CanGoBack: true,
497+ })
498+ require.False(t, vertical.HasIndicator)
499+ require.False(t, vertical.HasAction)
500+
501+ continued := recognizer.Handle(touchpadNavigationInput{
502+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -400, 0),
503+ Config: cfg,
504+ CanGoBack: true,
505+ })
506+ require.False(t, continued.HasIndicator)
507+ require.False(t, continued.HasAction)
508+
509+ end := recognizer.Handle(touchpadNavigationInput{
510+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
511+ Config: cfg,
512+ CanGoBack: true,
513+ })
514+ require.False(t, end.HasAction)
515+}
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+
577+func touchpadNavigationScrollEvent(phase cef2gtk.ScrollPhase, dx, dy float64) cef2gtk.ScrollEvent {
578+ return touchpadNavigationScrollEventAt(phase, 0, dx, dy)
579+}
580+
581+// ViewWidth is provided separately on touchpadNavigationInput; the recognizer
582+// caches it from the initial Begin event for subsequent Update and End phases.
583+func touchpadNavigationScrollEventAt(phase cef2gtk.ScrollPhase, x, dx, dy float64) cef2gtk.ScrollEvent {
584+ return cef2gtk.ScrollEvent{
585+ Phase: phase,
586+ X: x,
587+ DX: dx,
588+ DY: dy,
589+ Unit: gdk.ScrollUnitSurfaceValue,
590+ UnitKnown: true,
591+ }
592+}
M internal/infrastructure/cef/webview.go +72 -8
@@ -20,6 +20,7 @@ import (
2020
2121 "github.com/bnema/dumber/internal/application/dto"
2222 "github.com/bnema/dumber/internal/application/port"
23+ "github.com/bnema/dumber/internal/domain/entity"
2324 "github.com/bnema/dumber/internal/logging"
2425 "github.com/bnema/dumber/internal/shared/syncdispatch"
2526 )
@@ -228,6 +229,7 @@ type WebView struct {
228229
229230 // Touchpad input diagnostics are debounced to avoid flooding logs while
230231 // debugging continuous scroll streams.
232+ touchpadNavigation *touchpadNavigationRecognizer
231233 inputDiagMu sync.Mutex
232234 inputDiagLastLog time.Time
233235 inputDiagEvents int
@@ -1136,6 +1138,7 @@ func (wv *WebView) Destroy() {
11361138 wv.openerMessageCallbacks = nil
11371139 wv.openerNavigationCallbacks = nil
11381140 wv.popupReadyToShow = nil
1141+ wv.touchpadNavigation = nil
11391142 wv.inputAttached = false
11401143 wv.pendingNativePopups = nil
11411144 wv.nativePopupParent = nil
@@ -1478,16 +1481,14 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
14781481 OnMiddleClick: func(_, _ float64) bool {
14791482 return wv.handleMiddleClickFromBridge()
14801483 },
1481- OnScroll: wv.handleScrollInputDiagnostic,
1484+ OnScroll: wv.handleScrollInput,
14821485 NavigationSwipe: cef2gtk.NavigationSwipeOptions{
1483- Enabled: wv.inputConfig.TouchpadNavigationEnabled,
1484- MinDelta: wv.inputConfig.TouchpadNavigationMinDelta,
1485- MaxVerticalRatio: wv.inputConfig.TouchpadNavigationMaxVerticalRatio,
1486+ // Dumber handles thresholding and progress UI locally from OnScroll so
1487+ // horizontal scrolling continues to reach CEF while history navigation
1488+ // waits for a deliberate release past the visual threshold.
1489+ Enabled: false,
14861490 },
1487- CanNavigateBack: wv.CanGoBack,
1488- CanNavigateForward: wv.CanGoForward,
1489- OnNavigateSwipe: wv.handleNavigationSwipeAction,
1490- SelectionText: wv.selectedTextSnapshot,
1491+ SelectionText: wv.selectedTextSnapshot,
14911492 OnClipboardShortcut: func(action, text string) {
14921493 if wv.engine != nil {
14931494 wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
@@ -1496,6 +1497,69 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
14961497 }
14971498 }
14981499
1500+// handleScrollInput is invoked by cef2gtk from GTK scroll event callbacks on
1501+// the GTK main thread, which keeps lazy touchpad recognizer ownership
1502+// single-threaded while every scroll event is still forwarded to CEF.
1503+func (wv *WebView) handleScrollInput(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {
1504+ wv.handleTouchpadNavigationScroll(event)
1505+ return wv.handleScrollInputDiagnostic(event)
1506+}
1507+
1508+func (wv *WebView) handleTouchpadNavigationScroll(event cef2gtk.ScrollEvent) {
1509+ if wv == nil || wv.destroyed.Load() {
1510+ return
1511+ }
1512+ if !wv.inputConfig.TouchpadNavigationEnabled {
1513+ return
1514+ }
1515+ wv.mu.Lock()
1516+ if wv.destroyed.Load() {
1517+ wv.mu.Unlock()
1518+ return
1519+ }
1520+ if wv.touchpadNavigation == nil {
1521+ wv.touchpadNavigation = newTouchpadNavigationRecognizer()
1522+ }
1523+ result := wv.touchpadNavigation.Handle(touchpadNavigationInput{
1524+ Event: event,
1525+ Config: wv.inputConfig,
1526+ CanGoBack: wv.canGoBack,
1527+ CanGoForward: wv.canGoFwd,
1528+ ViewWidth: wv.touchpadNavigationViewWidth(),
1529+ })
1530+ wv.mu.Unlock()
1531+ if result.HasIndicator {
1532+ wv.emitTouchpadNavigationGesture(result.Indicator)
1533+ }
1534+ if result.HasAction {
1535+ wv.handleNavigationSwipeAction(result.Action)
1536+ }
1537+}
1538+
1539+func (wv *WebView) touchpadNavigationViewWidth() float64 {
1540+ if wv == nil || wv.nativeWidget == nil {
1541+ return 0
1542+ }
1543+ if width := wv.nativeWidget.GetAllocatedWidth(); width > 0 {
1544+ return float64(width)
1545+ }
1546+ return float64(wv.nativeWidget.GetWidth())
1547+}
1548+
1549+func (wv *WebView) emitTouchpadNavigationGesture(gesture entity.TouchpadNavigationGesture) {
1550+ if wv == nil {
1551+ return
1552+ }
1553+ wv.mu.RLock()
1554+ cb := wv.callbacks
1555+ wv.mu.RUnlock()
1556+ if cb != nil && cb.OnTouchpadNavigationGesture != nil {
1557+ // Scroll input is delivered from GTK callbacks, so invoking directly keeps
1558+ // progress and final indicators ordered before any navigation action.
1559+ cb.OnTouchpadNavigationGesture(gesture)
1560+ }
1561+}
1562+
14991563 func (wv *WebView) handleScrollInputDiagnostic(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {
15001564 if event.Phase != cef2gtk.ScrollPhaseUpdate || (math.Abs(event.DX) == 0 && math.Abs(event.DY) == 0) {
15011565 return cef2gtk.ScrollForwardToCEF
@@ -20,6 +20,7 @@ import (
20 20
21 "github.com/bnema/dumber/internal/application/dto"21 "github.com/bnema/dumber/internal/application/dto"
22 "github.com/bnema/dumber/internal/application/port"22 "github.com/bnema/dumber/internal/application/port"
23+ "github.com/bnema/dumber/internal/domain/entity"
23 "github.com/bnema/dumber/internal/logging"24 "github.com/bnema/dumber/internal/logging"
24 "github.com/bnema/dumber/internal/shared/syncdispatch"25 "github.com/bnema/dumber/internal/shared/syncdispatch"
25 )26 )
@@ -228,6 +229,7 @@ type WebView struct {
228 229
229 // Touchpad input diagnostics are debounced to avoid flooding logs while230 // Touchpad input diagnostics are debounced to avoid flooding logs while
230 // debugging continuous scroll streams.231 // debugging continuous scroll streams.
232+ touchpadNavigation *touchpadNavigationRecognizer
231 inputDiagMu sync.Mutex233 inputDiagMu sync.Mutex
232 inputDiagLastLog time.Time234 inputDiagLastLog time.Time
233 inputDiagEvents int235 inputDiagEvents int
@@ -1136,6 +1138,7 @@ func (wv *WebView) Destroy() {
1136 wv.openerMessageCallbacks = nil1138 wv.openerMessageCallbacks = nil
1137 wv.openerNavigationCallbacks = nil1139 wv.openerNavigationCallbacks = nil
1138 wv.popupReadyToShow = nil1140 wv.popupReadyToShow = nil
1141+ wv.touchpadNavigation = nil
1139 wv.inputAttached = false1142 wv.inputAttached = false
1140 wv.pendingNativePopups = nil1143 wv.pendingNativePopups = nil
1141 wv.nativePopupParent = nil1144 wv.nativePopupParent = nil
@@ -1478,16 +1481,14 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
1478 OnMiddleClick: func(_, _ float64) bool {1481 OnMiddleClick: func(_, _ float64) bool {
1479 return wv.handleMiddleClickFromBridge()1482 return wv.handleMiddleClickFromBridge()
1480 },1483 },
1481- OnScroll: wv.handleScrollInputDiagnostic,1484+ OnScroll: wv.handleScrollInput,
1482 NavigationSwipe: cef2gtk.NavigationSwipeOptions{1485 NavigationSwipe: cef2gtk.NavigationSwipeOptions{
1483- Enabled: wv.inputConfig.TouchpadNavigationEnabled,1486+ // Dumber handles thresholding and progress UI locally from OnScroll so
1484- MinDelta: wv.inputConfig.TouchpadNavigationMinDelta,1487+ // horizontal scrolling continues to reach CEF while history navigation
1485- MaxVerticalRatio: wv.inputConfig.TouchpadNavigationMaxVerticalRatio,1488+ // waits for a deliberate release past the visual threshold.
1489+ Enabled: false,
1486 },1490 },
1487- CanNavigateBack: wv.CanGoBack,1491+ SelectionText: wv.selectedTextSnapshot,
1488- CanNavigateForward: wv.CanGoForward,
1489- OnNavigateSwipe: wv.handleNavigationSwipeAction,
1490- SelectionText: wv.selectedTextSnapshot,
1491 OnClipboardShortcut: func(action, text string) {1492 OnClipboardShortcut: func(action, text string) {
1492 if wv.engine != nil {1493 if wv.engine != nil {
1493 wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)1494 wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
@@ -1496,6 +1497,69 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
1496 }1497 }
1497 }1498 }
1498 1499
1500+// handleScrollInput is invoked by cef2gtk from GTK scroll event callbacks on
1501+// the GTK main thread, which keeps lazy touchpad recognizer ownership
1502+// single-threaded while every scroll event is still forwarded to CEF.
1503+func (wv *WebView) handleScrollInput(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {
1504+ wv.handleTouchpadNavigationScroll(event)
1505+ return wv.handleScrollInputDiagnostic(event)
1506+}
1507+
1508+func (wv *WebView) handleTouchpadNavigationScroll(event cef2gtk.ScrollEvent) {
1509+ if wv == nil || wv.destroyed.Load() {
1510+ return
1511+ }
1512+ if !wv.inputConfig.TouchpadNavigationEnabled {
1513+ return
1514+ }
1515+ wv.mu.Lock()
1516+ if wv.destroyed.Load() {
1517+ wv.mu.Unlock()
1518+ return
1519+ }
1520+ if wv.touchpadNavigation == nil {
1521+ wv.touchpadNavigation = newTouchpadNavigationRecognizer()
1522+ }
1523+ result := wv.touchpadNavigation.Handle(touchpadNavigationInput{
1524+ Event: event,
1525+ Config: wv.inputConfig,
1526+ CanGoBack: wv.canGoBack,
1527+ CanGoForward: wv.canGoFwd,
1528+ ViewWidth: wv.touchpadNavigationViewWidth(),
1529+ })
1530+ wv.mu.Unlock()
1531+ if result.HasIndicator {
1532+ wv.emitTouchpadNavigationGesture(result.Indicator)
1533+ }
1534+ if result.HasAction {
1535+ wv.handleNavigationSwipeAction(result.Action)
1536+ }
1537+}
1538+
1539+func (wv *WebView) touchpadNavigationViewWidth() float64 {
1540+ if wv == nil || wv.nativeWidget == nil {
1541+ return 0
1542+ }
1543+ if width := wv.nativeWidget.GetAllocatedWidth(); width > 0 {
1544+ return float64(width)
1545+ }
1546+ return float64(wv.nativeWidget.GetWidth())
1547+}
1548+
1549+func (wv *WebView) emitTouchpadNavigationGesture(gesture entity.TouchpadNavigationGesture) {
1550+ if wv == nil {
1551+ return
1552+ }
1553+ wv.mu.RLock()
1554+ cb := wv.callbacks
1555+ wv.mu.RUnlock()
1556+ if cb != nil && cb.OnTouchpadNavigationGesture != nil {
1557+ // Scroll input is delivered from GTK callbacks, so invoking directly keeps
1558+ // progress and final indicators ordered before any navigation action.
1559+ cb.OnTouchpadNavigationGesture(gesture)
1560+ }
1561+}
1562+
1499 func (wv *WebView) handleScrollInputDiagnostic(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {1563 func (wv *WebView) handleScrollInputDiagnostic(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {
1500 if event.Phase != cef2gtk.ScrollPhaseUpdate || (math.Abs(event.DX) == 0 && math.Abs(event.DY) == 0) {1564 if event.Phase != cef2gtk.ScrollPhaseUpdate || (math.Abs(event.DX) == 0 && math.Abs(event.DY) == 0) {
1501 return cef2gtk.ScrollForwardToCEF1565 return cef2gtk.ScrollForwardToCEF
M internal/infrastructure/cef/webview_input_test.go +96 -6
@@ -1,8 +1,14 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
56
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
10+ "github.com/bnema/purego-cef2gtk"
11+ "github.com/bnema/puregotk/v4/gdk"
612 "github.com/bnema/puregotk/v4/gtk"
713 "github.com/stretchr/testify/require"
814 )
@@ -33,13 +39,97 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T)
3339 require.Equal(t, int32(120), opts.Scroll.MaxDelta)
3440 require.NotNil(t, opts.OnMiddleClick)
3541 require.NotNil(t, opts.OnScroll)
36- require.True(t, opts.NavigationSwipe.Enabled)
37- require.InDelta(t, 80.0, opts.NavigationSwipe.MinDelta, 0.001)
38- require.InDelta(t, 0.5, opts.NavigationSwipe.MaxVerticalRatio, 0.001)
39- require.NotNil(t, opts.CanNavigateBack)
40- require.NotNil(t, opts.CanNavigateForward)
41- require.NotNil(t, opts.OnNavigateSwipe)
42+ require.False(t, opts.NavigationSwipe.Enabled)
43+ require.Zero(t, opts.NavigationSwipe.MinDelta)
44+ require.Zero(t, opts.NavigationSwipe.MaxVerticalRatio)
45+ require.Nil(t, opts.CanNavigateBack)
46+ require.Nil(t, opts.CanNavigateForward)
47+ require.Nil(t, opts.OnNavigateSwipe)
4248 require.NotNil(t, opts.SelectionText)
4349 require.NotNil(t, opts.OnClipboardShortcut)
4450 require.Same(t, nativeWidget, wv.nativeWidget)
4551 }
52+
53+func TestWebViewHandleScrollInput_ForwardsOrdinaryHorizontalScroll(t *testing.T) {
54+ wv := &WebView{
55+ ctx: context.Background(),
56+ inputConfig: RuntimeInputConfig{
57+ TouchpadNavigationEnabled: true,
58+ TouchpadNavigationMinDelta: 200,
59+ TouchpadNavigationMaxVerticalRatio: 0.5,
60+ },
61+ canGoBack: true,
62+ }
63+
64+ // This verifies the bridge-level forwarding contract for ordinary scroll
65+ // updates. The nil native widget leaves view width unknown, so local history
66+ // gesture recognition fails closed and the event continues to CEF.
67+ decision := wv.handleScrollInput(cef2gtk.ScrollEvent{
68+ Phase: cef2gtk.ScrollPhaseUpdate,
69+ X: 500,
70+ DX: -240,
71+ Unit: gdk.ScrollUnitSurfaceValue,
72+ UnitKnown: true,
73+ })
74+
75+ require.Equal(t, cef2gtk.ScrollForwardToCEF, decision)
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 entity.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+}
@@ -1,8 +1,14 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
10+ "github.com/bnema/purego-cef2gtk"
11+ "github.com/bnema/puregotk/v4/gdk"
6 "github.com/bnema/puregotk/v4/gtk"12 "github.com/bnema/puregotk/v4/gtk"
7 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
8 )14 )
@@ -33,13 +39,97 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T)
33 require.Equal(t, int32(120), opts.Scroll.MaxDelta)39 require.Equal(t, int32(120), opts.Scroll.MaxDelta)
34 require.NotNil(t, opts.OnMiddleClick)40 require.NotNil(t, opts.OnMiddleClick)
35 require.NotNil(t, opts.OnScroll)41 require.NotNil(t, opts.OnScroll)
36- require.True(t, opts.NavigationSwipe.Enabled)42+ require.False(t, opts.NavigationSwipe.Enabled)
37- require.InDelta(t, 80.0, opts.NavigationSwipe.MinDelta, 0.001)43+ require.Zero(t, opts.NavigationSwipe.MinDelta)
38- require.InDelta(t, 0.5, opts.NavigationSwipe.MaxVerticalRatio, 0.001)44+ require.Zero(t, opts.NavigationSwipe.MaxVerticalRatio)
39- require.NotNil(t, opts.CanNavigateBack)45+ require.Nil(t, opts.CanNavigateBack)
40- require.NotNil(t, opts.CanNavigateForward)46+ require.Nil(t, opts.CanNavigateForward)
41- require.NotNil(t, opts.OnNavigateSwipe)47+ require.Nil(t, opts.OnNavigateSwipe)
42 require.NotNil(t, opts.SelectionText)48 require.NotNil(t, opts.SelectionText)
43 require.NotNil(t, opts.OnClipboardShortcut)49 require.NotNil(t, opts.OnClipboardShortcut)
44 require.Same(t, nativeWidget, wv.nativeWidget)50 require.Same(t, nativeWidget, wv.nativeWidget)
45 }51 }
52+
53+func TestWebViewHandleScrollInput_ForwardsOrdinaryHorizontalScroll(t *testing.T) {
54+ wv := &WebView{
55+ ctx: context.Background(),
56+ inputConfig: RuntimeInputConfig{
57+ TouchpadNavigationEnabled: true,
58+ TouchpadNavigationMinDelta: 200,
59+ TouchpadNavigationMaxVerticalRatio: 0.5,
60+ },
61+ canGoBack: true,
62+ }
63+
64+ // This verifies the bridge-level forwarding contract for ordinary scroll
65+ // updates. The nil native widget leaves view width unknown, so local history
66+ // gesture recognition fails closed and the event continues to CEF.
67+ decision := wv.handleScrollInput(cef2gtk.ScrollEvent{
68+ Phase: cef2gtk.ScrollPhaseUpdate,
69+ X: 500,
70+ DX: -240,
71+ Unit: gdk.ScrollUnitSurfaceValue,
72+ UnitKnown: true,
73+ })
74+
75+ require.Equal(t, cef2gtk.ScrollForwardToCEF, decision)
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 entity.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+}
M internal/infrastructure/config/defaults.go +3 -1
@@ -88,7 +88,9 @@ const (
8888 defaultCEFScrollPreciseMultiplier = 2.5
8989 defaultCEFScrollMaxDelta = 0
9090 defaultCEFTouchpadNavigation = true
91- defaultCEFTouchpadNavigationDelta = 200.0
91+ // Require a deliberate swipe before history navigation so ordinary
92+ // horizontal scrolling is unlikely to cross the commit threshold.
93+ defaultCEFTouchpadNavigationDelta = 320.0
9294 defaultCEFTouchpadNavigationRatio = 0.5
9395
9496 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
@@ -88,7 +88,9 @@ const (
88 defaultCEFScrollPreciseMultiplier = 2.588 defaultCEFScrollPreciseMultiplier = 2.5
89 defaultCEFScrollMaxDelta = 089 defaultCEFScrollMaxDelta = 0
90 defaultCEFTouchpadNavigation = true90 defaultCEFTouchpadNavigation = true
91- defaultCEFTouchpadNavigationDelta = 200.091+ // Require a deliberate swipe before history navigation so ordinary
92+ // horizontal scrolling is unlikely to cross the commit threshold.
93+ defaultCEFTouchpadNavigationDelta = 320.0
92 defaultCEFTouchpadNavigationRatio = 0.594 defaultCEFTouchpadNavigationRatio = 0.5
93 95
94 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)96 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
M internal/infrastructure/config/schema_provider.go +2 -2
@@ -1044,14 +1044,14 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
10441044 Key: "engine.cef.input.touchpad_navigation_enabled",
10451045 Type: "bool",
10461046 Default: fmt.Sprintf("%t", defaults.Engine.CEF.Input.TouchpadNavigationEnabled),
1047- Description: "Enable two-finger touchpad swipe back/forward navigation for CEF views",
1047+ Description: "Enable deliberate two-finger touchpad swipe back/forward navigation for CEF views",
10481048 Section: SectionPerformance,
10491049 },
10501050 {
10511051 Key: "engine.cef.input.touchpad_navigation_min_delta",
10521052 Type: "float64",
10531053 Default: fmt.Sprintf("%.2f", defaults.Engine.CEF.Input.TouchpadNavigationMinDelta),
1054- Description: "Minimum accumulated horizontal touchpad swipe delta required for CEF back/forward navigation",
1054+ Description: "Minimum accumulated horizontal touchpad swipe delta required before release triggers CEF back/forward navigation",
10551055 Range: ">0",
10561056 Section: SectionPerformance,
10571057 },
@@ -1044,14 +1044,14 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
1044 Key: "engine.cef.input.touchpad_navigation_enabled",1044 Key: "engine.cef.input.touchpad_navigation_enabled",
1045 Type: "bool",1045 Type: "bool",
1046 Default: fmt.Sprintf("%t", defaults.Engine.CEF.Input.TouchpadNavigationEnabled),1046 Default: fmt.Sprintf("%t", defaults.Engine.CEF.Input.TouchpadNavigationEnabled),
1047- Description: "Enable two-finger touchpad swipe back/forward navigation for CEF views",1047+ Description: "Enable deliberate two-finger touchpad swipe back/forward navigation for CEF views",
1048 Section: SectionPerformance,1048 Section: SectionPerformance,
1049 },1049 },
1050 {1050 {
1051 Key: "engine.cef.input.touchpad_navigation_min_delta",1051 Key: "engine.cef.input.touchpad_navigation_min_delta",
1052 Type: "float64",1052 Type: "float64",
1053 Default: fmt.Sprintf("%.2f", defaults.Engine.CEF.Input.TouchpadNavigationMinDelta),1053 Default: fmt.Sprintf("%.2f", defaults.Engine.CEF.Input.TouchpadNavigationMinDelta),
1054- Description: "Minimum accumulated horizontal touchpad swipe delta required for CEF back/forward navigation",1054+ Description: "Minimum accumulated horizontal touchpad swipe delta required before release triggers CEF back/forward navigation",
1055 Range: ">0",1055 Range: ">0",
1056 Section: SectionPerformance,1056 Section: SectionPerformance,
1057 },1057 },
M internal/ui/app.go +22 -0
@@ -593,6 +593,24 @@ func (a *App) browserWindowForAnyPane(paneID entity.PaneID) *browserWindow {
593593 return nil
594594 }
595595
596+func (a *App) handleTouchpadNavigationGesture(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) {
597+ if a == nil {
598+ return
599+ }
600+ run := func() {
601+ bw := a.browserWindowForAnyPane(paneID)
602+ if bw == nil || bw.touchpadNavIndicator == nil {
603+ return
604+ }
605+ bw.touchpadNavIndicator.ShowGesture(gesture)
606+ }
607+ if a.dispatchOnMainThread != nil {
608+ a.dispatchOnMainThread("touchpad-navigation-gesture", run)
609+ return
610+ }
611+ run()
612+}
613+
596614 func (a *App) lastFocusedBrowserWindow() *browserWindow {
597615 if a.lastFocusedWindowID == "" || a.browserWindows == nil {
598616 return nil
@@ -3176,6 +3194,10 @@ func (a *App) initContentCoordinator(
31763194 a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)
31773195 }
31783196
3197+ a.contentCoord.SetOnTouchpadNavigationGesture(func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) {
3198+ a.handleTouchpadNavigationGesture(paneID, gesture)
3199+ })
3200+
31793201 // Wire deferred init trigger - runs after first navigation starts
31803202 a.contentCoord.SetOnFirstLoadStarted(func() {
31813203 a.triggerDeferredInit(ctx)
@@ -593,6 +593,24 @@ func (a *App) browserWindowForAnyPane(paneID entity.PaneID) *browserWindow {
593 return nil593 return nil
594 }594 }
595 595
596+func (a *App) handleTouchpadNavigationGesture(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) {
597+ if a == nil {
598+ return
599+ }
600+ run := func() {
601+ bw := a.browserWindowForAnyPane(paneID)
602+ if bw == nil || bw.touchpadNavIndicator == nil {
603+ return
604+ }
605+ bw.touchpadNavIndicator.ShowGesture(gesture)
606+ }
607+ if a.dispatchOnMainThread != nil {
608+ a.dispatchOnMainThread("touchpad-navigation-gesture", run)
609+ return
610+ }
611+ run()
612+}
613+
596 func (a *App) lastFocusedBrowserWindow() *browserWindow {614 func (a *App) lastFocusedBrowserWindow() *browserWindow {
597 if a.lastFocusedWindowID == "" || a.browserWindows == nil {615 if a.lastFocusedWindowID == "" || a.browserWindows == nil {
598 return nil616 return nil
@@ -3176,6 +3194,10 @@ func (a *App) initContentCoordinator(
3176 a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)3194 a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)
3177 }3195 }
3178 3196
3197+ a.contentCoord.SetOnTouchpadNavigationGesture(func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) {
3198+ a.handleTouchpadNavigationGesture(paneID, gesture)
3199+ })
3200+
3179 // Wire deferred init trigger - runs after first navigation starts3201 // Wire deferred init trigger - runs after first navigation starts
3180 a.contentCoord.SetOnFirstLoadStarted(func() {3202 a.contentCoord.SetOnFirstLoadStarted(func() {
3181 a.triggerDeferredInit(ctx)3203 a.triggerDeferredInit(ctx)
M internal/ui/browser_window.go +19 -0
@@ -26,6 +26,7 @@ type browserWindow struct {
2626 mainWindow *window.MainWindow
2727 appToaster *component.Toaster
2828 modeToaster *component.Toaster
29+ touchpadNavIndicator *component.TouchpadNavigationIndicator
2930 borderMgr *focus.BorderManager
3031 sessionManager *component.SessionManager
3132 tabPicker *component.TabPicker
@@ -71,8 +72,12 @@ func (bw *browserWindow) clearShellState() {
7172 if bw.historySidebar != nil {
7273 bw.historySidebar.Destroy()
7374 }
75+ if bw.touchpadNavIndicator != nil {
76+ bw.touchpadNavIndicator.Destroy()
77+ }
7478 bw.appToaster = nil
7579 bw.modeToaster = nil
80+ bw.touchpadNavIndicator = nil
7681 bw.borderMgr = nil
7782 bw.sessionManager = nil
7883 bw.tabPicker = nil
@@ -93,6 +98,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
9398 }
9499
95100 bw.initToasterOverlay(a)
101+ bw.initTouchpadNavigationIndicator()
96102 bw.initBorderOverlay(a)
97103 bw.initAccentPicker(ctx, a)
98104 bw.initSessionManager(ctx, a)
@@ -124,6 +130,19 @@ func (bw *browserWindow) initToasterOverlay(a *App) {
124130 }
125131 }
126132
133+func (bw *browserWindow) initTouchpadNavigationIndicator() {
134+ if bw == nil || bw.mainWindow == nil {
135+ return
136+ }
137+ bw.touchpadNavIndicator = component.NewTouchpadNavigationIndicator()
138+ if bw.touchpadNavIndicator == nil {
139+ return
140+ }
141+ if widget := bw.touchpadNavIndicator.Widget(); widget != nil {
142+ bw.mainWindow.AddNonMeasuringOverlay(widget)
143+ }
144+}
145+
127146 func (bw *browserWindow) initBorderOverlay(a *App) {
128147 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
129148 return
@@ -26,6 +26,7 @@ type browserWindow struct {
26 mainWindow *window.MainWindow26 mainWindow *window.MainWindow
27 appToaster *component.Toaster27 appToaster *component.Toaster
28 modeToaster *component.Toaster28 modeToaster *component.Toaster
29+ touchpadNavIndicator *component.TouchpadNavigationIndicator
29 borderMgr *focus.BorderManager30 borderMgr *focus.BorderManager
30 sessionManager *component.SessionManager31 sessionManager *component.SessionManager
31 tabPicker *component.TabPicker32 tabPicker *component.TabPicker
@@ -71,8 +72,12 @@ func (bw *browserWindow) clearShellState() {
71 if bw.historySidebar != nil {72 if bw.historySidebar != nil {
72 bw.historySidebar.Destroy()73 bw.historySidebar.Destroy()
73 }74 }
75+ if bw.touchpadNavIndicator != nil {
76+ bw.touchpadNavIndicator.Destroy()
77+ }
74 bw.appToaster = nil78 bw.appToaster = nil
75 bw.modeToaster = nil79 bw.modeToaster = nil
80+ bw.touchpadNavIndicator = nil
76 bw.borderMgr = nil81 bw.borderMgr = nil
77 bw.sessionManager = nil82 bw.sessionManager = nil
78 bw.tabPicker = nil83 bw.tabPicker = nil
@@ -93,6 +98,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
93 }98 }
94 99
95 bw.initToasterOverlay(a)100 bw.initToasterOverlay(a)
101+ bw.initTouchpadNavigationIndicator()
96 bw.initBorderOverlay(a)102 bw.initBorderOverlay(a)
97 bw.initAccentPicker(ctx, a)103 bw.initAccentPicker(ctx, a)
98 bw.initSessionManager(ctx, a)104 bw.initSessionManager(ctx, a)
@@ -124,6 +130,19 @@ func (bw *browserWindow) initToasterOverlay(a *App) {
124 }130 }
125 }131 }
126 132
133+func (bw *browserWindow) initTouchpadNavigationIndicator() {
134+ if bw == nil || bw.mainWindow == nil {
135+ return
136+ }
137+ bw.touchpadNavIndicator = component.NewTouchpadNavigationIndicator()
138+ if bw.touchpadNavIndicator == nil {
139+ return
140+ }
141+ if widget := bw.touchpadNavIndicator.Widget(); widget != nil {
142+ bw.mainWindow.AddNonMeasuringOverlay(widget)
143+ }
144+}
145+
127 func (bw *browserWindow) initBorderOverlay(a *App) {146 func (bw *browserWindow) initBorderOverlay(a *App) {
128 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {147 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
129 return148 return
A internal/ui/component/touchpad_navigation_indicator.go +235 -0
@@ -0,0 +1,235 @@
1+package component
2+
3+import (
4+ "github.com/bnema/dumber/internal/domain/entity"
5+ "github.com/bnema/puregotk/v4/glib"
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+const (
10+ touchpadNavigationIndicatorHideMs = 220
11+ touchpadNavigationIndicatorSpacing = 8
12+ touchpadNavigationIndicatorHeaderGap = 10
13+ touchpadNavigationIndicatorMargin = 32
14+ touchpadNavigationIndicatorWidth = 156
15+ touchpadNavigationIndicatorBarHeight = 5
16+ touchpadNavigationIndicatorIconMinChars = 2
17+ touchpadNavigationIndicatorIconXAlign = 0.5
18+)
19+
20+// TouchpadNavigationIndicator is a lightweight overlay that makes CEF
21+// two-finger history navigation deliberate: it appears during the swipe,
22+// fills toward the commit threshold, and briefly shows the triggered state.
23+type TouchpadNavigationIndicator struct {
24+ container *gtk.Box
25+ icon *gtk.Label
26+ label *gtk.Label
27+ bar *gtk.ProgressBar
28+
29+ hideTimer uint
30+}
31+
32+func NewTouchpadNavigationIndicator() *TouchpadNavigationIndicator {
33+ container := gtk.NewBox(gtk.OrientationVerticalValue, touchpadNavigationIndicatorSpacing)
34+ if container == nil {
35+ return nil
36+ }
37+ container.AddCssClass("touchpad-navigation-indicator")
38+ container.SetValign(gtk.AlignCenterValue)
39+ container.SetHalign(gtk.AlignStartValue)
40+ container.SetHexpand(false)
41+ container.SetVexpand(false)
42+ container.SetMarginStart(touchpadNavigationIndicatorMargin)
43+ container.SetMarginEnd(touchpadNavigationIndicatorMargin)
44+ container.SetCanTarget(false)
45+ container.SetCanFocus(false)
46+ container.SetVisible(false)
47+
48+ icon, label, ok := appendTouchpadNavigationHeader(container)
49+ if !ok {
50+ container.Unref()
51+ return nil
52+ }
53+
54+ bar := gtk.NewProgressBar()
55+ if bar == nil {
56+ container.Unref()
57+ return nil
58+ }
59+ bar.AddCssClass("touchpad-navigation-progress")
60+ bar.SetSizeRequest(touchpadNavigationIndicatorWidth, touchpadNavigationIndicatorBarHeight)
61+ bar.SetFraction(0)
62+ bar.SetCanTarget(false)
63+ bar.SetCanFocus(false)
64+ container.Append(&bar.Widget)
65+
66+ return &TouchpadNavigationIndicator{
67+ container: container,
68+ icon: icon,
69+ label: label,
70+ bar: bar,
71+ }
72+}
73+
74+func appendTouchpadNavigationHeader(container *gtk.Box) (*gtk.Label, *gtk.Label, bool) {
75+ header := gtk.NewBox(gtk.OrientationHorizontalValue, touchpadNavigationIndicatorHeaderGap)
76+ if header == nil {
77+ return nil, nil, false
78+ }
79+ header.AddCssClass("touchpad-navigation-header")
80+ header.SetCanTarget(false)
81+ header.SetCanFocus(false)
82+ container.Append(&header.Widget)
83+
84+ iconText := "←"
85+ icon := gtk.NewLabel(&iconText)
86+ if icon == nil {
87+ return nil, nil, false
88+ }
89+ icon.AddCssClass("touchpad-navigation-icon")
90+ icon.SetWidthChars(touchpadNavigationIndicatorIconMinChars)
91+ icon.SetXalign(touchpadNavigationIndicatorIconXAlign)
92+ icon.SetCanTarget(false)
93+ icon.SetCanFocus(false)
94+ header.Append(&icon.Widget)
95+
96+ text := "Slide Back"
97+ label := gtk.NewLabel(&text)
98+ if label == nil {
99+ return nil, nil, false
100+ }
101+ label.AddCssClass("touchpad-navigation-label")
102+ label.SetXalign(0)
103+ label.SetCanTarget(false)
104+ label.SetCanFocus(false)
105+ header.Append(&label.Widget)
106+
107+ return icon, label, true
108+}
109+
110+func (i *TouchpadNavigationIndicator) Widget() *gtk.Widget {
111+ if i == nil || i.container == nil {
112+ return nil
113+ }
114+ return &i.container.Widget
115+}
116+
117+func (i *TouchpadNavigationIndicator) ShowGesture(gesture entity.TouchpadNavigationGesture) {
118+ if i == nil || i.container == nil || i.icon == nil || i.label == nil || i.bar == nil {
119+ return
120+ }
121+ i.cancelHideTimer()
122+
123+ i.container.RemoveCssClass("hiding")
124+ i.container.RemoveCssClass("back")
125+ i.container.RemoveCssClass("forward")
126+ if gesture.Action == entity.TouchpadNavigationForward {
127+ i.container.AddCssClass("forward")
128+ i.container.SetHalign(gtk.AlignEndValue)
129+ i.bar.SetInverted(true)
130+ } else {
131+ i.container.AddCssClass("back")
132+ i.container.SetHalign(gtk.AlignStartValue)
133+ i.bar.SetInverted(false)
134+ }
135+
136+ if gesture.ThresholdReached {
137+ i.container.AddCssClass("threshold-reached")
138+ } else {
139+ i.container.RemoveCssClass("threshold-reached")
140+ }
141+
142+ i.icon.SetLabel(touchpadNavigationIndicatorIcon(gesture))
143+ i.label.SetLabel(touchpadNavigationIndicatorLabel(gesture))
144+ i.bar.SetFraction(clampIndicatorProgress(gesture.Progress))
145+ i.container.SetVisible(true)
146+
147+ if !gesture.Active {
148+ i.scheduleHide(gesture.ThresholdReached)
149+ }
150+}
151+
152+func (i *TouchpadNavigationIndicator) Destroy() {
153+ if i == nil {
154+ return
155+ }
156+ i.cancelHideTimer()
157+ if i.container != nil {
158+ i.container.SetVisible(false)
159+ i.container.Unparent()
160+ i.container.Unref()
161+ }
162+ i.container = nil
163+ i.icon = nil
164+ i.label = nil
165+ i.bar = nil
166+}
167+
168+func (i *TouchpadNavigationIndicator) scheduleHide(triggered bool) {
169+ delay := uint(0)
170+ if triggered {
171+ delay = touchpadNavigationIndicatorHideMs
172+ }
173+ if delay == 0 {
174+ i.hide()
175+ return
176+ }
177+ if i.container != nil {
178+ i.container.AddCssClass("hiding")
179+ }
180+ cb := glib.SourceFunc(func(_ uintptr) bool {
181+ i.hideTimer = 0
182+ i.hide()
183+ return false
184+ })
185+ i.hideTimer = glib.TimeoutAdd(delay, &cb, 0)
186+}
187+
188+func (i *TouchpadNavigationIndicator) hide() {
189+ i.cancelHideTimer()
190+ if i.container != nil {
191+ i.container.RemoveCssClass("hiding")
192+ i.container.RemoveCssClass("threshold-reached")
193+ i.container.RemoveCssClass("back")
194+ i.container.RemoveCssClass("forward")
195+ i.container.SetVisible(false)
196+ }
197+ if i.bar != nil {
198+ i.bar.SetFraction(0)
199+ }
200+}
201+
202+func (i *TouchpadNavigationIndicator) cancelHideTimer() {
203+ if i.hideTimer != 0 {
204+ glib.SourceRemove(i.hideTimer)
205+ i.hideTimer = 0
206+ }
207+}
208+
209+func touchpadNavigationIndicatorIcon(gesture entity.TouchpadNavigationGesture) string {
210+ if gesture.Action == entity.TouchpadNavigationForward {
211+ return "→"
212+ }
213+ return "←"
214+}
215+
216+func touchpadNavigationIndicatorLabel(gesture entity.TouchpadNavigationGesture) string {
217+ suffix := "Back"
218+ if gesture.Action == entity.TouchpadNavigationForward {
219+ suffix = "Forward"
220+ }
221+ if gesture.ThresholdReached {
222+ return "Release for " + suffix
223+ }
224+ return "Slide " + suffix
225+}
226+
227+func clampIndicatorProgress(progress float64) float64 {
228+ if progress < 0 {
229+ return 0
230+ }
231+ if progress > 1 {
232+ return 1
233+ }
234+ return progress
235+}
@@ -0,0 +1,235 @@
1+package component
2+
3+import (
4+ "github.com/bnema/dumber/internal/domain/entity"
5+ "github.com/bnema/puregotk/v4/glib"
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+const (
10+ touchpadNavigationIndicatorHideMs = 220
11+ touchpadNavigationIndicatorSpacing = 8
12+ touchpadNavigationIndicatorHeaderGap = 10
13+ touchpadNavigationIndicatorMargin = 32
14+ touchpadNavigationIndicatorWidth = 156
15+ touchpadNavigationIndicatorBarHeight = 5
16+ touchpadNavigationIndicatorIconMinChars = 2
17+ touchpadNavigationIndicatorIconXAlign = 0.5
18+)
19+
20+// TouchpadNavigationIndicator is a lightweight overlay that makes CEF
21+// two-finger history navigation deliberate: it appears during the swipe,
22+// fills toward the commit threshold, and briefly shows the triggered state.
23+type TouchpadNavigationIndicator struct {
24+ container *gtk.Box
25+ icon *gtk.Label
26+ label *gtk.Label
27+ bar *gtk.ProgressBar
28+
29+ hideTimer uint
30+}
31+
32+func NewTouchpadNavigationIndicator() *TouchpadNavigationIndicator {
33+ container := gtk.NewBox(gtk.OrientationVerticalValue, touchpadNavigationIndicatorSpacing)
34+ if container == nil {
35+ return nil
36+ }
37+ container.AddCssClass("touchpad-navigation-indicator")
38+ container.SetValign(gtk.AlignCenterValue)
39+ container.SetHalign(gtk.AlignStartValue)
40+ container.SetHexpand(false)
41+ container.SetVexpand(false)
42+ container.SetMarginStart(touchpadNavigationIndicatorMargin)
43+ container.SetMarginEnd(touchpadNavigationIndicatorMargin)
44+ container.SetCanTarget(false)
45+ container.SetCanFocus(false)
46+ container.SetVisible(false)
47+
48+ icon, label, ok := appendTouchpadNavigationHeader(container)
49+ if !ok {
50+ container.Unref()
51+ return nil
52+ }
53+
54+ bar := gtk.NewProgressBar()
55+ if bar == nil {
56+ container.Unref()
57+ return nil
58+ }
59+ bar.AddCssClass("touchpad-navigation-progress")
60+ bar.SetSizeRequest(touchpadNavigationIndicatorWidth, touchpadNavigationIndicatorBarHeight)
61+ bar.SetFraction(0)
62+ bar.SetCanTarget(false)
63+ bar.SetCanFocus(false)
64+ container.Append(&bar.Widget)
65+
66+ return &TouchpadNavigationIndicator{
67+ container: container,
68+ icon: icon,
69+ label: label,
70+ bar: bar,
71+ }
72+}
73+
74+func appendTouchpadNavigationHeader(container *gtk.Box) (*gtk.Label, *gtk.Label, bool) {
75+ header := gtk.NewBox(gtk.OrientationHorizontalValue, touchpadNavigationIndicatorHeaderGap)
76+ if header == nil {
77+ return nil, nil, false
78+ }
79+ header.AddCssClass("touchpad-navigation-header")
80+ header.SetCanTarget(false)
81+ header.SetCanFocus(false)
82+ container.Append(&header.Widget)
83+
84+ iconText := "←"
85+ icon := gtk.NewLabel(&iconText)
86+ if icon == nil {
87+ return nil, nil, false
88+ }
89+ icon.AddCssClass("touchpad-navigation-icon")
90+ icon.SetWidthChars(touchpadNavigationIndicatorIconMinChars)
91+ icon.SetXalign(touchpadNavigationIndicatorIconXAlign)
92+ icon.SetCanTarget(false)
93+ icon.SetCanFocus(false)
94+ header.Append(&icon.Widget)
95+
96+ text := "Slide Back"
97+ label := gtk.NewLabel(&text)
98+ if label == nil {
99+ return nil, nil, false
100+ }
101+ label.AddCssClass("touchpad-navigation-label")
102+ label.SetXalign(0)
103+ label.SetCanTarget(false)
104+ label.SetCanFocus(false)
105+ header.Append(&label.Widget)
106+
107+ return icon, label, true
108+}
109+
110+func (i *TouchpadNavigationIndicator) Widget() *gtk.Widget {
111+ if i == nil || i.container == nil {
112+ return nil
113+ }
114+ return &i.container.Widget
115+}
116+
117+func (i *TouchpadNavigationIndicator) ShowGesture(gesture entity.TouchpadNavigationGesture) {
118+ if i == nil || i.container == nil || i.icon == nil || i.label == nil || i.bar == nil {
119+ return
120+ }
121+ i.cancelHideTimer()
122+
123+ i.container.RemoveCssClass("hiding")
124+ i.container.RemoveCssClass("back")
125+ i.container.RemoveCssClass("forward")
126+ if gesture.Action == entity.TouchpadNavigationForward {
127+ i.container.AddCssClass("forward")
128+ i.container.SetHalign(gtk.AlignEndValue)
129+ i.bar.SetInverted(true)
130+ } else {
131+ i.container.AddCssClass("back")
132+ i.container.SetHalign(gtk.AlignStartValue)
133+ i.bar.SetInverted(false)
134+ }
135+
136+ if gesture.ThresholdReached {
137+ i.container.AddCssClass("threshold-reached")
138+ } else {
139+ i.container.RemoveCssClass("threshold-reached")
140+ }
141+
142+ i.icon.SetLabel(touchpadNavigationIndicatorIcon(gesture))
143+ i.label.SetLabel(touchpadNavigationIndicatorLabel(gesture))
144+ i.bar.SetFraction(clampIndicatorProgress(gesture.Progress))
145+ i.container.SetVisible(true)
146+
147+ if !gesture.Active {
148+ i.scheduleHide(gesture.ThresholdReached)
149+ }
150+}
151+
152+func (i *TouchpadNavigationIndicator) Destroy() {
153+ if i == nil {
154+ return
155+ }
156+ i.cancelHideTimer()
157+ if i.container != nil {
158+ i.container.SetVisible(false)
159+ i.container.Unparent()
160+ i.container.Unref()
161+ }
162+ i.container = nil
163+ i.icon = nil
164+ i.label = nil
165+ i.bar = nil
166+}
167+
168+func (i *TouchpadNavigationIndicator) scheduleHide(triggered bool) {
169+ delay := uint(0)
170+ if triggered {
171+ delay = touchpadNavigationIndicatorHideMs
172+ }
173+ if delay == 0 {
174+ i.hide()
175+ return
176+ }
177+ if i.container != nil {
178+ i.container.AddCssClass("hiding")
179+ }
180+ cb := glib.SourceFunc(func(_ uintptr) bool {
181+ i.hideTimer = 0
182+ i.hide()
183+ return false
184+ })
185+ i.hideTimer = glib.TimeoutAdd(delay, &cb, 0)
186+}
187+
188+func (i *TouchpadNavigationIndicator) hide() {
189+ i.cancelHideTimer()
190+ if i.container != nil {
191+ i.container.RemoveCssClass("hiding")
192+ i.container.RemoveCssClass("threshold-reached")
193+ i.container.RemoveCssClass("back")
194+ i.container.RemoveCssClass("forward")
195+ i.container.SetVisible(false)
196+ }
197+ if i.bar != nil {
198+ i.bar.SetFraction(0)
199+ }
200+}
201+
202+func (i *TouchpadNavigationIndicator) cancelHideTimer() {
203+ if i.hideTimer != 0 {
204+ glib.SourceRemove(i.hideTimer)
205+ i.hideTimer = 0
206+ }
207+}
208+
209+func touchpadNavigationIndicatorIcon(gesture entity.TouchpadNavigationGesture) string {
210+ if gesture.Action == entity.TouchpadNavigationForward {
211+ return "→"
212+ }
213+ return "←"
214+}
215+
216+func touchpadNavigationIndicatorLabel(gesture entity.TouchpadNavigationGesture) string {
217+ suffix := "Back"
218+ if gesture.Action == entity.TouchpadNavigationForward {
219+ suffix = "Forward"
220+ }
221+ if gesture.ThresholdReached {
222+ return "Release for " + suffix
223+ }
224+ return "Slide " + suffix
225+}
226+
227+func clampIndicatorProgress(progress float64) float64 {
228+ if progress < 0 {
229+ return 0
230+ }
231+ if progress > 1 {
232+ return 1
233+ }
234+ return progress
235+}
M internal/ui/coordinator/content/callbacks.go +5 -0
@@ -87,6 +87,11 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
8787 OnLinkHover: func(uri string) {
8888 c.onLinkHover(paneID, uri)
8989 },
90+ OnTouchpadNavigationGesture: func(gesture entity.TouchpadNavigationGesture) {
91+ if c.onTouchpadNavigationGesture != nil {
92+ c.onTouchpadNavigationGesture(paneID, gesture)
93+ }
94+ },
9095 OnWebProcessTerminated: func(reason port.WebProcessTerminationReason, reasonLabel string, uri string) {
9196 originalURI := extractOriginalURIFromCrashPage(uri)
9297 if !shouldRenderCrashPage(reason) {
@@ -87,6 +87,11 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
87 OnLinkHover: func(uri string) {87 OnLinkHover: func(uri string) {
88 c.onLinkHover(paneID, uri)88 c.onLinkHover(paneID, uri)
89 },89 },
90+ OnTouchpadNavigationGesture: func(gesture entity.TouchpadNavigationGesture) {
91+ if c.onTouchpadNavigationGesture != nil {
92+ c.onTouchpadNavigationGesture(paneID, gesture)
93+ }
94+ },
90 OnWebProcessTerminated: func(reason port.WebProcessTerminationReason, reasonLabel string, uri string) {95 OnWebProcessTerminated: func(reason port.WebProcessTerminationReason, reasonLabel string, uri string) {
91 originalURI := extractOriginalURIFromCrashPage(uri)96 originalURI := extractOriginalURIFromCrashPage(uri)
92 if !shouldRenderCrashPage(reason) {97 if !shouldRenderCrashPage(reason) {
M internal/ui/coordinator/content/coordinator.go +9 -0
@@ -64,6 +64,9 @@ type Coordinator struct {
6464 // Callback when the active pane commits a navigation (new page loading).
6565 onActiveNavigationCommitted func(paneID entity.PaneID, uri string)
6666
67+ // Callback when a CEF touchpad history navigation gesture progresses.
68+ onTouchpadNavigationGesture func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)
69+
6770 // Callback when the WebView becomes visible (first real commit)
6871 onWebViewShown func(paneID entity.PaneID)
6972
@@ -192,6 +195,12 @@ func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url stri
192195 c.onPaneURIUpdated = fn
193196 }
194197
198+// SetOnTouchpadNavigationGesture sets the callback for CEF two-finger history
199+// navigation gesture progress.
200+func (c *Coordinator) SetOnTouchpadNavigationGesture(fn func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)) {
201+ c.onTouchpadNavigationGesture = fn
202+}
203+
195204 // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
196205 func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) {
197206 c.onWindowTitleChanged = fn
@@ -64,6 +64,9 @@ type Coordinator struct {
64 // Callback when the active pane commits a navigation (new page loading).64 // Callback when the active pane commits a navigation (new page loading).
65 onActiveNavigationCommitted func(paneID entity.PaneID, uri string)65 onActiveNavigationCommitted func(paneID entity.PaneID, uri string)
66 66
67+ // Callback when a CEF touchpad history navigation gesture progresses.
68+ onTouchpadNavigationGesture func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)
69+
67 // Callback when the WebView becomes visible (first real commit)70 // Callback when the WebView becomes visible (first real commit)
68 onWebViewShown func(paneID entity.PaneID)71 onWebViewShown func(paneID entity.PaneID)
69 72
@@ -192,6 +195,12 @@ func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url stri
192 c.onPaneURIUpdated = fn195 c.onPaneURIUpdated = fn
193 }196 }
194 197
198+// SetOnTouchpadNavigationGesture sets the callback for CEF two-finger history
199+// navigation gesture progress.
200+func (c *Coordinator) SetOnTouchpadNavigationGesture(fn func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)) {
201+ c.onTouchpadNavigationGesture = fn
202+}
203+
195 // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).204 // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
196 func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) {205 func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) {
197 c.onWindowTitleChanged = fn206 c.onWindowTitleChanged = fn
M internal/ui/theme/css.go +95 -0
@@ -106,6 +106,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
106106 sb.WriteString(generateProgressBarCSS(p))
107107 sb.WriteString("\n")
108108
109+ // Touchpad history navigation indicator styling
110+ sb.WriteString(generateTouchpadNavigationIndicatorCSS(p))
111+ sb.WriteString("\n")
112+
109113 // Toaster styling
110114 sb.WriteString(generateToasterCSS(p))
111115 sb.WriteString("\n")
@@ -761,6 +765,97 @@ progressbar.osd progress {
761765 `
762766 }
763767
768+func generateTouchpadNavigationIndicatorCSS(p Palette) string {
769+ _ = p
770+ return `/* ===== Touchpad Navigation Indicator Styling ===== */
771+
772+.touchpad-navigation-indicator {
773+ background-color: alpha(var(--surface), 0.90);
774+ color: var(--text);
775+ border: 0.0625em solid alpha(var(--border), 0.72);
776+ border-radius: 1.125em;
777+ padding: 0.75em 0.875em 0.8125em;
778+ opacity: 1;
779+ filter: blur(0px);
780+ backdrop-filter: blur(0.75em);
781+ box-shadow: 0 1em 2.25em alpha(black, 0.30),
782+ 0 0.0625em 0 alpha(white, 0.06) inset;
783+ transition: opacity 220ms ease-out,
784+ filter 220ms ease-out,
785+ backdrop-filter 220ms ease-out,
786+ background-color 140ms ease-out,
787+ border-color 140ms ease-out,
788+ box-shadow 220ms ease-out;
789+}
790+
791+.touchpad-navigation-indicator.back {
792+ border-left: 0.1875em solid alpha(var(--accent), 0.82);
793+}
794+
795+.touchpad-navigation-indicator.forward {
796+ border-right: 0.1875em solid alpha(var(--accent), 0.82);
797+}
798+
799+.touchpad-navigation-indicator.threshold-reached {
800+ border-color: alpha(var(--accent), 0.95);
801+ background-color: alpha(var(--accent), 0.18);
802+ box-shadow: 0 1.125em 2.5em alpha(black, 0.34),
803+ 0 0 0 0.0625em alpha(var(--accent), 0.24) inset;
804+}
805+
806+.touchpad-navigation-indicator.hiding {
807+ opacity: 0;
808+ filter: blur(0.0625em);
809+ box-shadow: 0 0.375em 0.875em alpha(black, 0.10),
810+ 0 0 0 0.0625em alpha(var(--accent), 0.04) inset;
811+}
812+
813+.touchpad-navigation-header {
814+ min-width: 9.75em;
815+}
816+
817+.touchpad-navigation-icon {
818+ background-color: alpha(var(--accent), 0.16);
819+ color: var(--accent);
820+ border-radius: 999px;
821+ font-size: 0.95em;
822+ font-weight: 700;
823+ padding: 0.125em 0.375em;
824+}
825+
826+.touchpad-navigation-indicator.threshold-reached .touchpad-navigation-icon {
827+ background-color: var(--accent);
828+ color: var(--bg);
829+}
830+
831+.touchpad-navigation-label {
832+ color: var(--text);
833+ font-weight: 700;
834+ font-size: 0.92em;
835+ letter-spacing: 0.01em;
836+}
837+
838+progressbar.touchpad-navigation-progress {
839+ min-height: 0.3125em;
840+}
841+
842+progressbar.touchpad-navigation-progress trough {
843+ min-height: 0.3125em;
844+ min-width: 9.75em;
845+ border-radius: 999px;
846+ background-color: alpha(var(--muted), 0.24);
847+ box-shadow: 0 0.0625em 0 alpha(white, 0.06) inset;
848+}
849+
850+progressbar.touchpad-navigation-progress progress {
851+ min-height: 0.3125em;
852+ border-radius: 999px;
853+ background-color: var(--accent);
854+ box-shadow: 0 0 0.35em alpha(var(--accent), 0.35);
855+}
856+`
857+}
858+
764859 // generateSessionManagerCSS creates session manager modal styles.
765860 // Uses em units for scalable UI, matches omnibox styling patterns.
766861 func generateSessionManagerCSS(p Palette) string {
@@ -106,6 +106,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
106 sb.WriteString(generateProgressBarCSS(p))106 sb.WriteString(generateProgressBarCSS(p))
107 sb.WriteString("\n")107 sb.WriteString("\n")
108 108
109+ // Touchpad history navigation indicator styling
110+ sb.WriteString(generateTouchpadNavigationIndicatorCSS(p))
111+ sb.WriteString("\n")
112+
109 // Toaster styling113 // Toaster styling
110 sb.WriteString(generateToasterCSS(p))114 sb.WriteString(generateToasterCSS(p))
111 sb.WriteString("\n")115 sb.WriteString("\n")
@@ -761,6 +765,97 @@ progressbar.osd progress {
761 `765 `
762 }766 }
763 767
768+func generateTouchpadNavigationIndicatorCSS(p Palette) string {
769+ _ = p
770+ return `/* ===== Touchpad Navigation Indicator Styling ===== */
771+
772+.touchpad-navigation-indicator {
773+ background-color: alpha(var(--surface), 0.90);
774+ color: var(--text);
775+ border: 0.0625em solid alpha(var(--border), 0.72);
776+ border-radius: 1.125em;
777+ padding: 0.75em 0.875em 0.8125em;
778+ opacity: 1;
779+ filter: blur(0px);
780+ backdrop-filter: blur(0.75em);
781+ box-shadow: 0 1em 2.25em alpha(black, 0.30),
782+ 0 0.0625em 0 alpha(white, 0.06) inset;
783+ transition: opacity 220ms ease-out,
784+ filter 220ms ease-out,
785+ backdrop-filter 220ms ease-out,
786+ background-color 140ms ease-out,
787+ border-color 140ms ease-out,
788+ box-shadow 220ms ease-out;
789+}
790+
791+.touchpad-navigation-indicator.back {
792+ border-left: 0.1875em solid alpha(var(--accent), 0.82);
793+}
794+
795+.touchpad-navigation-indicator.forward {
796+ border-right: 0.1875em solid alpha(var(--accent), 0.82);
797+}
798+
799+.touchpad-navigation-indicator.threshold-reached {
800+ border-color: alpha(var(--accent), 0.95);
801+ background-color: alpha(var(--accent), 0.18);
802+ box-shadow: 0 1.125em 2.5em alpha(black, 0.34),
803+ 0 0 0 0.0625em alpha(var(--accent), 0.24) inset;
804+}
805+
806+.touchpad-navigation-indicator.hiding {
807+ opacity: 0;
808+ filter: blur(0.0625em);
809+ box-shadow: 0 0.375em 0.875em alpha(black, 0.10),
810+ 0 0 0 0.0625em alpha(var(--accent), 0.04) inset;
811+}
812+
813+.touchpad-navigation-header {
814+ min-width: 9.75em;
815+}
816+
817+.touchpad-navigation-icon {
818+ background-color: alpha(var(--accent), 0.16);
819+ color: var(--accent);
820+ border-radius: 999px;
821+ font-size: 0.95em;
822+ font-weight: 700;
823+ padding: 0.125em 0.375em;
824+}
825+
826+.touchpad-navigation-indicator.threshold-reached .touchpad-navigation-icon {
827+ background-color: var(--accent);
828+ color: var(--bg);
829+}
830+
831+.touchpad-navigation-label {
832+ color: var(--text);
833+ font-weight: 700;
834+ font-size: 0.92em;
835+ letter-spacing: 0.01em;
836+}
837+
838+progressbar.touchpad-navigation-progress {
839+ min-height: 0.3125em;
840+}
841+
842+progressbar.touchpad-navigation-progress trough {
843+ min-height: 0.3125em;
844+ min-width: 9.75em;
845+ border-radius: 999px;
846+ background-color: alpha(var(--muted), 0.24);
847+ box-shadow: 0 0.0625em 0 alpha(white, 0.06) inset;
848+}
849+
850+progressbar.touchpad-navigation-progress progress {
851+ min-height: 0.3125em;
852+ border-radius: 999px;
853+ background-color: var(--accent);
854+ box-shadow: 0 0 0.35em alpha(var(--accent), 0.35);
855+}
856+`
857+}
858+
764 // generateSessionManagerCSS creates session manager modal styles.859 // generateSessionManagerCSS creates session manager modal styles.
765 // Uses em units for scalable UI, matches omnibox styling patterns.860 // Uses em units for scalable UI, matches omnibox styling patterns.
766 func generateSessionManagerCSS(p Palette) string {861 func generateSessionManagerCSS(p Palette) string {
M internal/ui/theme/css_test.go +18 -0
@@ -235,6 +235,24 @@ func cssRuleBlock(t *testing.T, css, selector string) string {
235235 return matches[1]
236236 }
237237
238+func TestGenerateCSS_TouchpadNavigationIndicatorFadesWholeOverlay(t *testing.T) {
239+ css := GenerateCSS(DefaultDarkPalette())
240+
241+ baseBlock := cssRuleBlock(t, css, ".touchpad-navigation-indicator")
242+ assert.Contains(t, baseBlock, "opacity: 1;")
243+ assert.Contains(t, baseBlock, "filter: blur(0px);")
244+ assert.Contains(t, baseBlock, "backdrop-filter: blur(0.75em);")
245+ assert.Contains(t, baseBlock, "transition: opacity 220ms ease-out,")
246+ assert.Contains(t, baseBlock, "filter 220ms ease-out,")
247+ assert.Contains(t, baseBlock, "backdrop-filter 220ms ease-out,")
248+ assert.Contains(t, baseBlock, "box-shadow 220ms ease-out;")
249+
250+ hidingBlock := cssRuleBlock(t, css, ".touchpad-navigation-indicator.hiding")
251+ assert.Contains(t, hidingBlock, "opacity: 0;")
252+ assert.Contains(t, hidingBlock, "filter: blur(0.0625em);")
253+ assert.Contains(t, hidingBlock, "box-shadow:")
254+}
255+
238256 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {
239257 css := GenerateCSS(DefaultDarkPalette())
240258
@@ -235,6 +235,24 @@ func cssRuleBlock(t *testing.T, css, selector string) string {
235 return matches[1]235 return matches[1]
236 }236 }
237 237
238+func TestGenerateCSS_TouchpadNavigationIndicatorFadesWholeOverlay(t *testing.T) {
239+ css := GenerateCSS(DefaultDarkPalette())
240+
241+ baseBlock := cssRuleBlock(t, css, ".touchpad-navigation-indicator")
242+ assert.Contains(t, baseBlock, "opacity: 1;")
243+ assert.Contains(t, baseBlock, "filter: blur(0px);")
244+ assert.Contains(t, baseBlock, "backdrop-filter: blur(0.75em);")
245+ assert.Contains(t, baseBlock, "transition: opacity 220ms ease-out,")
246+ assert.Contains(t, baseBlock, "filter 220ms ease-out,")
247+ assert.Contains(t, baseBlock, "backdrop-filter 220ms ease-out,")
248+ assert.Contains(t, baseBlock, "box-shadow 220ms ease-out;")
249+
250+ hidingBlock := cssRuleBlock(t, css, ".touchpad-navigation-indicator.hiding")
251+ assert.Contains(t, hidingBlock, "opacity: 0;")
252+ assert.Contains(t, hidingBlock, "filter: blur(0.0625em);")
253+ assert.Contains(t, hidingBlock, "box-shadow:")
254+}
255+
238 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {256 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {
239 css := GenerateCSS(DefaultDarkPalette())257 css := GenerateCSS(DefaultDarkPalette())
240 258
M internal/ui/window/main_window.go +12 -0
@@ -311,6 +311,18 @@ func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
311311 }
312312 }
313313
314+// AddNonMeasuringOverlay adds a widget as a visual overlay that does not affect
315+// content measurement or get clipped to the main child allocation. Use this for
316+// transient indicators that should float above the WebView.
317+func (mw *MainWindow) AddNonMeasuringOverlay(widget *gtk.Widget) {
318+ if mw.contentOverlay == nil || widget == nil {
319+ return
320+ }
321+ mw.contentOverlay.AddOverlay(widget)
322+ mw.contentOverlay.SetClipOverlay(widget, false)
323+ mw.contentOverlay.SetMeasureOverlay(widget, false)
324+}
325+
314326 // Destroy releases all GTK resources held by the window.
315327 func (mw *MainWindow) Destroy() {
316328 if mw.tabBar != nil {
@@ -311,6 +311,18 @@ func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
311 }311 }
312 }312 }
313 313
314+// AddNonMeasuringOverlay adds a widget as a visual overlay that does not affect
315+// content measurement or get clipped to the main child allocation. Use this for
316+// transient indicators that should float above the WebView.
317+func (mw *MainWindow) AddNonMeasuringOverlay(widget *gtk.Widget) {
318+ if mw.contentOverlay == nil || widget == nil {
319+ return
320+ }
321+ mw.contentOverlay.AddOverlay(widget)
322+ mw.contentOverlay.SetClipOverlay(widget, false)
323+ mw.contentOverlay.SetMeasureOverlay(widget, false)
324+}
325+
314 // Destroy releases all GTK resources held by the window.326 // Destroy releases all GTK resources held by the window.
315 func (mw *MainWindow) Destroy() {327 func (mw *MainWindow) Destroy() {
316 if mw.tabBar != nil {328 if mw.tabBar != nil {

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