detached 4cf5b9e

feat(cef): require deliberate touchpad navigation swipes

commit
4cf5b9eb65f728451a7396775c95031e1c5b42fc
author
brice · · 2026-06-18 06:56 UTC
parent
a450a83
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +737 -10
M internal/application/port/webview.go +19 -0
@@ -89,6 +89,22 @@ type Texture interface {
8989
9090 // WebViewCallbacks defines callback handlers for WebView events.
9191 // Implementations should invoke these on the main thread/goroutine.
92+type TouchpadNavigationAction int
93+
94+const (
95+ TouchpadNavigationBack TouchpadNavigationAction = iota
96+ TouchpadNavigationForward
97+)
98+
99+// TouchpadNavigationGesture describes visual progress for a deliberate
100+// two-finger history navigation gesture.
101+type TouchpadNavigationGesture struct {
102+ Action TouchpadNavigationAction
103+ Progress float64
104+ ThresholdReached bool
105+ Active bool
106+}
107+
92108 type WebViewCallbacks struct {
93109 // OnLoadChanged is called when load state changes.
94110 OnLoadChanged func(event LoadEvent)
@@ -137,6 +153,9 @@ type WebViewCallbacks struct {
137153
138154 // OnAudioStateChanged is called when audio playback starts or stops.
139155 OnAudioStateChanged func(playing bool)
156+ // OnTouchpadNavigationGesture is called while a CEF two-finger history
157+ // navigation gesture is progressing or finishing.
158+ OnTouchpadNavigationGesture func(gesture TouchpadNavigationGesture)
140159 }
141160
142161 // FindOptions configures search behavior.
@@ -89,6 +89,22 @@ type Texture interface {
89 89
90 // WebViewCallbacks defines callback handlers for WebView events.90 // WebViewCallbacks defines callback handlers for WebView events.
91 // Implementations should invoke these on the main thread/goroutine.91 // Implementations should invoke these on the main thread/goroutine.
92+type TouchpadNavigationAction int
93+
94+const (
95+ TouchpadNavigationBack TouchpadNavigationAction = iota
96+ TouchpadNavigationForward
97+)
98+
99+// TouchpadNavigationGesture describes visual progress for a deliberate
100+// two-finger history navigation gesture.
101+type TouchpadNavigationGesture struct {
102+ Action TouchpadNavigationAction
103+ Progress float64
104+ ThresholdReached bool
105+ Active bool
106+}
107+
92 type WebViewCallbacks struct {108 type WebViewCallbacks struct {
93 // OnLoadChanged is called when load state changes.109 // OnLoadChanged is called when load state changes.
94 OnLoadChanged func(event LoadEvent)110 OnLoadChanged func(event LoadEvent)
@@ -137,6 +153,9 @@ type WebViewCallbacks struct {
137 153
138 // OnAudioStateChanged is called when audio playback starts or stops.154 // OnAudioStateChanged is called when audio playback starts or stops.
139 OnAudioStateChanged func(playing bool)155 OnAudioStateChanged func(playing bool)
156+ // OnTouchpadNavigationGesture is called while a CEF two-finger history
157+ // navigation gesture is progressing or finishing.
158+ OnTouchpadNavigationGesture func(gesture TouchpadNavigationGesture)
140 }159 }
141 160
142 // FindOptions configures search behavior.161 // FindOptions configures search behavior.
A internal/infrastructure/cef/touchpad_navigation.go +214 -0
@@ -0,0 +1,214 @@
1+package cef
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/dumber/internal/application/port"
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+)
15+
16+type touchpadNavigationRecognizer struct {
17+ cumulativeDX float64
18+ cumulativeDY float64
19+ thresholdReached bool
20+ verticalCanceled bool
21+ indicatorShown bool
22+ lastIndicatorAction port.TouchpadNavigationAction
23+}
24+
25+type touchpadNavigationInput struct {
26+ Event cef2gtk.ScrollEvent
27+ Config RuntimeInputConfig
28+ CanGoBack bool
29+ CanGoForward bool
30+}
31+
32+type touchpadNavigationResult struct {
33+ HasIndicator bool
34+ Indicator port.TouchpadNavigationGesture
35+ HasAction bool
36+ Action cef2gtk.NavigationSwipeAction
37+}
38+
39+func newTouchpadNavigationRecognizer() *touchpadNavigationRecognizer {
40+ return &touchpadNavigationRecognizer{}
41+}
42+
43+func (r *touchpadNavigationRecognizer) Handle(input touchpadNavigationInput) touchpadNavigationResult {
44+ if r == nil || !input.Config.TouchpadNavigationEnabled {
45+ return touchpadNavigationResult{}
46+ }
47+
48+ switch input.Event.Phase {
49+ case cef2gtk.ScrollPhaseBegin:
50+ r.reset()
51+ return touchpadNavigationResult{}
52+ case cef2gtk.ScrollPhaseUpdate:
53+ if !isTouchpadNavigationPreciseEvent(input.Event) {
54+ return touchpadNavigationResult{}
55+ }
56+ return r.handleUpdate(input)
57+ case cef2gtk.ScrollPhaseEnd:
58+ return r.handleEnd(input)
59+ default:
60+ return touchpadNavigationResult{}
61+ }
62+}
63+
64+func (r *touchpadNavigationRecognizer) handleUpdate(input touchpadNavigationInput) touchpadNavigationResult {
65+ if r.verticalCanceled {
66+ return touchpadNavigationResult{}
67+ }
68+
69+ // GTK scroll deltas are inverted compared to browser-history direction:
70+ // negative horizontal surface deltas indicate a back swipe, positive indicate
71+ // forward. Match the prior bridge behavior while keeping recognition local so
72+ // normal scroll forwarding remains untouched.
73+ r.cumulativeDX += -input.Event.DX
74+ r.cumulativeDY += input.Event.DY
75+ if r.isTooVertical(input.Config) {
76+ return r.cancelAsTooVertical()
77+ }
78+
79+ action, ok := r.indicatorAction(input)
80+ if !ok {
81+ return touchpadNavigationResult{}
82+ }
83+
84+ threshold := normalizedTouchpadNavigationMinDelta(input.Config.TouchpadNavigationMinDelta)
85+ progress := clampFloat(math.Abs(r.cumulativeDX)/threshold, 0, 1)
86+ r.thresholdReached = progress >= 1
87+ r.indicatorShown = true
88+ r.lastIndicatorAction = action
89+ return touchpadNavigationResult{
90+ HasIndicator: true,
91+ Indicator: port.TouchpadNavigationGesture{
92+ Action: action,
93+ Progress: progress,
94+ ThresholdReached: r.thresholdReached,
95+ Active: true,
96+ },
97+ }
98+}
99+
100+func (r *touchpadNavigationRecognizer) handleEnd(input touchpadNavigationInput) touchpadNavigationResult {
101+ defer r.reset()
102+ if r.verticalCanceled || !r.thresholdReached || r.isTooVertical(input.Config) {
103+ return r.finishIndicator(input, false)
104+ }
105+ action, ok := r.navigationAction(input)
106+ if !ok {
107+ return r.finishIndicator(input, false)
108+ }
109+ result := r.finishIndicator(input, true)
110+ result.HasAction = true
111+ result.Action = action
112+ return result
113+}
114+
115+func (r *touchpadNavigationRecognizer) finishIndicator(input touchpadNavigationInput, triggered bool) touchpadNavigationResult {
116+ action, ok := r.indicatorAction(input)
117+ if !ok && r.indicatorShown {
118+ action, ok = r.lastIndicatorAction, true
119+ }
120+ if !ok {
121+ return touchpadNavigationResult{}
122+ }
123+ threshold := normalizedTouchpadNavigationMinDelta(input.Config.TouchpadNavigationMinDelta)
124+ return touchpadNavigationResult{
125+ HasIndicator: true,
126+ Indicator: port.TouchpadNavigationGesture{
127+ Action: action,
128+ Progress: clampFloat(math.Abs(r.cumulativeDX)/threshold, 0, 1),
129+ ThresholdReached: triggered,
130+ Active: false,
131+ },
132+ }
133+}
134+
135+func (r *touchpadNavigationRecognizer) indicatorAction(input touchpadNavigationInput) (port.TouchpadNavigationAction, bool) {
136+ if r.cumulativeDX > 0 && input.CanGoBack {
137+ return port.TouchpadNavigationBack, true
138+ }
139+ if r.cumulativeDX < 0 && input.CanGoForward {
140+ return port.TouchpadNavigationForward, true
141+ }
142+ return port.TouchpadNavigationBack, false
143+}
144+
145+func (r *touchpadNavigationRecognizer) cancelAsTooVertical() touchpadNavigationResult {
146+ action, ok := r.indicatorAction(touchpadNavigationInput{})
147+ if !ok && r.indicatorShown {
148+ action, ok = r.lastIndicatorAction, true
149+ }
150+ r.reset()
151+ r.verticalCanceled = true
152+ if !ok {
153+ return touchpadNavigationResult{}
154+ }
155+ return touchpadNavigationResult{
156+ HasIndicator: true,
157+ Indicator: port.TouchpadNavigationGesture{
158+ Action: action,
159+ Active: false,
160+ },
161+ }
162+}
163+
164+func (r *touchpadNavigationRecognizer) navigationAction(input touchpadNavigationInput) (cef2gtk.NavigationSwipeAction, bool) {
165+ if r.cumulativeDX > 0 && input.CanGoBack {
166+ return cef2gtk.NavigationSwipeBack, true
167+ }
168+ if r.cumulativeDX < 0 && input.CanGoForward {
169+ return cef2gtk.NavigationSwipeForward, true
170+ }
171+ return cef2gtk.NavigationSwipeBack, false
172+}
173+
174+func (r *touchpadNavigationRecognizer) isTooVertical(cfg RuntimeInputConfig) bool {
175+ absDX, absDY := math.Abs(r.cumulativeDX), math.Abs(r.cumulativeDY)
176+ return absDX == 0 || absDY >= absDX*normalizedTouchpadNavigationRatio(cfg.TouchpadNavigationMaxVerticalRatio)
177+}
178+
179+func (r *touchpadNavigationRecognizer) reset() {
180+ r.cumulativeDX = 0
181+ r.cumulativeDY = 0
182+ r.thresholdReached = false
183+ r.verticalCanceled = false
184+ r.indicatorShown = false
185+ r.lastIndicatorAction = port.TouchpadNavigationBack
186+}
187+
188+func isTouchpadNavigationPreciseEvent(event cef2gtk.ScrollEvent) bool {
189+ return event.UnitKnown && event.Unit == gdk.ScrollUnitSurfaceValue
190+}
191+
192+func normalizedTouchpadNavigationMinDelta(value float64) float64 {
193+ if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
194+ return defaultTouchpadNavigationCommitDistance
195+ }
196+ return value
197+}
198+
199+func normalizedTouchpadNavigationRatio(value float64) float64 {
200+ if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
201+ return defaultTouchpadNavigationVerticalRatio
202+ }
203+ return value
204+}
205+
206+func clampFloat(value, minValue, maxValue float64) float64 {
207+ if value < minValue {
208+ return minValue
209+ }
210+ if value > maxValue {
211+ return maxValue
212+ }
213+ return value
214+}
@@ -0,0 +1,214 @@
1+package cef
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/dumber/internal/application/port"
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+)
15+
16+type touchpadNavigationRecognizer struct {
17+ cumulativeDX float64
18+ cumulativeDY float64
19+ thresholdReached bool
20+ verticalCanceled bool
21+ indicatorShown bool
22+ lastIndicatorAction port.TouchpadNavigationAction
23+}
24+
25+type touchpadNavigationInput struct {
26+ Event cef2gtk.ScrollEvent
27+ Config RuntimeInputConfig
28+ CanGoBack bool
29+ CanGoForward bool
30+}
31+
32+type touchpadNavigationResult struct {
33+ HasIndicator bool
34+ Indicator port.TouchpadNavigationGesture
35+ HasAction bool
36+ Action cef2gtk.NavigationSwipeAction
37+}
38+
39+func newTouchpadNavigationRecognizer() *touchpadNavigationRecognizer {
40+ return &touchpadNavigationRecognizer{}
41+}
42+
43+func (r *touchpadNavigationRecognizer) Handle(input touchpadNavigationInput) touchpadNavigationResult {
44+ if r == nil || !input.Config.TouchpadNavigationEnabled {
45+ return touchpadNavigationResult{}
46+ }
47+
48+ switch input.Event.Phase {
49+ case cef2gtk.ScrollPhaseBegin:
50+ r.reset()
51+ return touchpadNavigationResult{}
52+ case cef2gtk.ScrollPhaseUpdate:
53+ if !isTouchpadNavigationPreciseEvent(input.Event) {
54+ return touchpadNavigationResult{}
55+ }
56+ return r.handleUpdate(input)
57+ case cef2gtk.ScrollPhaseEnd:
58+ return r.handleEnd(input)
59+ default:
60+ return touchpadNavigationResult{}
61+ }
62+}
63+
64+func (r *touchpadNavigationRecognizer) handleUpdate(input touchpadNavigationInput) touchpadNavigationResult {
65+ if r.verticalCanceled {
66+ return touchpadNavigationResult{}
67+ }
68+
69+ // GTK scroll deltas are inverted compared to browser-history direction:
70+ // negative horizontal surface deltas indicate a back swipe, positive indicate
71+ // forward. Match the prior bridge behavior while keeping recognition local so
72+ // normal scroll forwarding remains untouched.
73+ r.cumulativeDX += -input.Event.DX
74+ r.cumulativeDY += input.Event.DY
75+ if r.isTooVertical(input.Config) {
76+ return r.cancelAsTooVertical()
77+ }
78+
79+ action, ok := r.indicatorAction(input)
80+ if !ok {
81+ return touchpadNavigationResult{}
82+ }
83+
84+ threshold := normalizedTouchpadNavigationMinDelta(input.Config.TouchpadNavigationMinDelta)
85+ progress := clampFloat(math.Abs(r.cumulativeDX)/threshold, 0, 1)
86+ r.thresholdReached = progress >= 1
87+ r.indicatorShown = true
88+ r.lastIndicatorAction = action
89+ return touchpadNavigationResult{
90+ HasIndicator: true,
91+ Indicator: port.TouchpadNavigationGesture{
92+ Action: action,
93+ Progress: progress,
94+ ThresholdReached: r.thresholdReached,
95+ Active: true,
96+ },
97+ }
98+}
99+
100+func (r *touchpadNavigationRecognizer) handleEnd(input touchpadNavigationInput) touchpadNavigationResult {
101+ defer r.reset()
102+ if r.verticalCanceled || !r.thresholdReached || r.isTooVertical(input.Config) {
103+ return r.finishIndicator(input, false)
104+ }
105+ action, ok := r.navigationAction(input)
106+ if !ok {
107+ return r.finishIndicator(input, false)
108+ }
109+ result := r.finishIndicator(input, true)
110+ result.HasAction = true
111+ result.Action = action
112+ return result
113+}
114+
115+func (r *touchpadNavigationRecognizer) finishIndicator(input touchpadNavigationInput, triggered bool) touchpadNavigationResult {
116+ action, ok := r.indicatorAction(input)
117+ if !ok && r.indicatorShown {
118+ action, ok = r.lastIndicatorAction, true
119+ }
120+ if !ok {
121+ return touchpadNavigationResult{}
122+ }
123+ threshold := normalizedTouchpadNavigationMinDelta(input.Config.TouchpadNavigationMinDelta)
124+ return touchpadNavigationResult{
125+ HasIndicator: true,
126+ Indicator: port.TouchpadNavigationGesture{
127+ Action: action,
128+ Progress: clampFloat(math.Abs(r.cumulativeDX)/threshold, 0, 1),
129+ ThresholdReached: triggered,
130+ Active: false,
131+ },
132+ }
133+}
134+
135+func (r *touchpadNavigationRecognizer) indicatorAction(input touchpadNavigationInput) (port.TouchpadNavigationAction, bool) {
136+ if r.cumulativeDX > 0 && input.CanGoBack {
137+ return port.TouchpadNavigationBack, true
138+ }
139+ if r.cumulativeDX < 0 && input.CanGoForward {
140+ return port.TouchpadNavigationForward, true
141+ }
142+ return port.TouchpadNavigationBack, false
143+}
144+
145+func (r *touchpadNavigationRecognizer) cancelAsTooVertical() touchpadNavigationResult {
146+ action, ok := r.indicatorAction(touchpadNavigationInput{})
147+ if !ok && r.indicatorShown {
148+ action, ok = r.lastIndicatorAction, true
149+ }
150+ r.reset()
151+ r.verticalCanceled = true
152+ if !ok {
153+ return touchpadNavigationResult{}
154+ }
155+ return touchpadNavigationResult{
156+ HasIndicator: true,
157+ Indicator: port.TouchpadNavigationGesture{
158+ Action: action,
159+ Active: false,
160+ },
161+ }
162+}
163+
164+func (r *touchpadNavigationRecognizer) navigationAction(input touchpadNavigationInput) (cef2gtk.NavigationSwipeAction, bool) {
165+ if r.cumulativeDX > 0 && input.CanGoBack {
166+ return cef2gtk.NavigationSwipeBack, true
167+ }
168+ if r.cumulativeDX < 0 && input.CanGoForward {
169+ return cef2gtk.NavigationSwipeForward, true
170+ }
171+ return cef2gtk.NavigationSwipeBack, false
172+}
173+
174+func (r *touchpadNavigationRecognizer) isTooVertical(cfg RuntimeInputConfig) bool {
175+ absDX, absDY := math.Abs(r.cumulativeDX), math.Abs(r.cumulativeDY)
176+ return absDX == 0 || absDY >= absDX*normalizedTouchpadNavigationRatio(cfg.TouchpadNavigationMaxVerticalRatio)
177+}
178+
179+func (r *touchpadNavigationRecognizer) reset() {
180+ r.cumulativeDX = 0
181+ r.cumulativeDY = 0
182+ r.thresholdReached = false
183+ r.verticalCanceled = false
184+ r.indicatorShown = false
185+ r.lastIndicatorAction = port.TouchpadNavigationBack
186+}
187+
188+func isTouchpadNavigationPreciseEvent(event cef2gtk.ScrollEvent) bool {
189+ return event.UnitKnown && event.Unit == gdk.ScrollUnitSurfaceValue
190+}
191+
192+func normalizedTouchpadNavigationMinDelta(value float64) float64 {
193+ if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
194+ return defaultTouchpadNavigationCommitDistance
195+ }
196+ return value
197+}
198+
199+func normalizedTouchpadNavigationRatio(value float64) float64 {
200+ if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
201+ return defaultTouchpadNavigationVerticalRatio
202+ }
203+ return value
204+}
205+
206+func clampFloat(value, minValue, maxValue float64) float64 {
207+ if value < minValue {
208+ return minValue
209+ }
210+ if value > maxValue {
211+ return maxValue
212+ }
213+ return value
214+}
A internal/infrastructure/cef/touchpad_navigation_test.go +144 -0
@@ -0,0 +1,144 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
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+ })
26+ require.False(t, begin.HasIndicator)
27+ require.False(t, begin.HasAction)
28+
29+ progress := recognizer.Handle(touchpadNavigationInput{
30+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -80, 0),
31+ Config: cfg,
32+ CanGoBack: true,
33+ CanGoForward: true,
34+ })
35+ require.True(t, progress.HasIndicator)
36+ require.Equal(t, port.TouchpadNavigationBack, progress.Indicator.Action)
37+ require.True(t, progress.Indicator.Active)
38+ require.InDelta(t, 0.4, progress.Indicator.Progress, 0.001)
39+ require.False(t, progress.Indicator.ThresholdReached)
40+ require.False(t, progress.HasAction)
41+
42+ armed := recognizer.Handle(touchpadNavigationInput{
43+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -140, 0),
44+ Config: cfg,
45+ CanGoBack: true,
46+ CanGoForward: true,
47+ })
48+ require.True(t, armed.HasIndicator)
49+ require.Equal(t, port.TouchpadNavigationBack, armed.Indicator.Action)
50+ require.True(t, armed.Indicator.Active)
51+ require.InDelta(t, 1.0, armed.Indicator.Progress, 0.001)
52+ require.True(t, armed.Indicator.ThresholdReached)
53+ require.False(t, armed.HasAction, "navigation waits for gesture release so the indicator is visible first")
54+
55+ end := recognizer.Handle(touchpadNavigationInput{
56+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
57+ Config: cfg,
58+ CanGoBack: true,
59+ CanGoForward: true,
60+ })
61+ require.True(t, end.HasIndicator)
62+ require.False(t, end.Indicator.Active)
63+ require.True(t, end.HasAction)
64+ require.Equal(t, cef2gtk.NavigationSwipeBack, end.Action)
65+}
66+
67+func TestTouchpadNavigationRecognizer_DoesNotNavigateBelowThreshold(t *testing.T) {
68+ recognizer := newTouchpadNavigationRecognizer()
69+ cfg := RuntimeInputConfig{
70+ TouchpadNavigationEnabled: true,
71+ TouchpadNavigationMinDelta: 200,
72+ TouchpadNavigationMaxVerticalRatio: 0.5,
73+ }
74+
75+ _ = recognizer.Handle(touchpadNavigationInput{
76+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
77+ Config: cfg,
78+ CanGoBack: true,
79+ })
80+ progress := recognizer.Handle(touchpadNavigationInput{
81+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -120, 0),
82+ Config: cfg,
83+ CanGoBack: true,
84+ })
85+ require.True(t, progress.HasIndicator)
86+ require.InDelta(t, 0.6, progress.Indicator.Progress, 0.001)
87+ require.False(t, progress.Indicator.ThresholdReached)
88+
89+ end := recognizer.Handle(touchpadNavigationInput{
90+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
91+ Config: cfg,
92+ CanGoBack: true,
93+ })
94+ require.False(t, end.HasAction)
95+ require.True(t, end.HasIndicator)
96+ require.False(t, end.Indicator.Active)
97+}
98+
99+func TestTouchpadNavigationRecognizer_CancelsVerticalGestures(t *testing.T) {
100+ recognizer := newTouchpadNavigationRecognizer()
101+ cfg := RuntimeInputConfig{
102+ TouchpadNavigationEnabled: true,
103+ TouchpadNavigationMinDelta: 200,
104+ TouchpadNavigationMaxVerticalRatio: 0.5,
105+ }
106+
107+ _ = recognizer.Handle(touchpadNavigationInput{
108+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
109+ Config: cfg,
110+ CanGoBack: true,
111+ })
112+ vertical := recognizer.Handle(touchpadNavigationInput{
113+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -60, 80),
114+ Config: cfg,
115+ CanGoBack: true,
116+ })
117+ require.False(t, vertical.HasIndicator)
118+ require.False(t, vertical.HasAction)
119+
120+ continued := recognizer.Handle(touchpadNavigationInput{
121+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -400, 0),
122+ Config: cfg,
123+ CanGoBack: true,
124+ })
125+ require.False(t, continued.HasIndicator)
126+ require.False(t, continued.HasAction)
127+
128+ end := recognizer.Handle(touchpadNavigationInput{
129+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
130+ Config: cfg,
131+ CanGoBack: true,
132+ })
133+ require.False(t, end.HasAction)
134+}
135+
136+func touchpadNavigationScrollEvent(phase cef2gtk.ScrollPhase, dx, dy float64) cef2gtk.ScrollEvent {
137+ return cef2gtk.ScrollEvent{
138+ Phase: phase,
139+ DX: dx,
140+ DY: dy,
141+ Unit: gdk.ScrollUnitSurfaceValue,
142+ UnitKnown: true,
143+ }
144+}
@@ -0,0 +1,144 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
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+ })
26+ require.False(t, begin.HasIndicator)
27+ require.False(t, begin.HasAction)
28+
29+ progress := recognizer.Handle(touchpadNavigationInput{
30+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -80, 0),
31+ Config: cfg,
32+ CanGoBack: true,
33+ CanGoForward: true,
34+ })
35+ require.True(t, progress.HasIndicator)
36+ require.Equal(t, port.TouchpadNavigationBack, progress.Indicator.Action)
37+ require.True(t, progress.Indicator.Active)
38+ require.InDelta(t, 0.4, progress.Indicator.Progress, 0.001)
39+ require.False(t, progress.Indicator.ThresholdReached)
40+ require.False(t, progress.HasAction)
41+
42+ armed := recognizer.Handle(touchpadNavigationInput{
43+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -140, 0),
44+ Config: cfg,
45+ CanGoBack: true,
46+ CanGoForward: true,
47+ })
48+ require.True(t, armed.HasIndicator)
49+ require.Equal(t, port.TouchpadNavigationBack, armed.Indicator.Action)
50+ require.True(t, armed.Indicator.Active)
51+ require.InDelta(t, 1.0, armed.Indicator.Progress, 0.001)
52+ require.True(t, armed.Indicator.ThresholdReached)
53+ require.False(t, armed.HasAction, "navigation waits for gesture release so the indicator is visible first")
54+
55+ end := recognizer.Handle(touchpadNavigationInput{
56+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
57+ Config: cfg,
58+ CanGoBack: true,
59+ CanGoForward: true,
60+ })
61+ require.True(t, end.HasIndicator)
62+ require.False(t, end.Indicator.Active)
63+ require.True(t, end.HasAction)
64+ require.Equal(t, cef2gtk.NavigationSwipeBack, end.Action)
65+}
66+
67+func TestTouchpadNavigationRecognizer_DoesNotNavigateBelowThreshold(t *testing.T) {
68+ recognizer := newTouchpadNavigationRecognizer()
69+ cfg := RuntimeInputConfig{
70+ TouchpadNavigationEnabled: true,
71+ TouchpadNavigationMinDelta: 200,
72+ TouchpadNavigationMaxVerticalRatio: 0.5,
73+ }
74+
75+ _ = recognizer.Handle(touchpadNavigationInput{
76+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
77+ Config: cfg,
78+ CanGoBack: true,
79+ })
80+ progress := recognizer.Handle(touchpadNavigationInput{
81+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -120, 0),
82+ Config: cfg,
83+ CanGoBack: true,
84+ })
85+ require.True(t, progress.HasIndicator)
86+ require.InDelta(t, 0.6, progress.Indicator.Progress, 0.001)
87+ require.False(t, progress.Indicator.ThresholdReached)
88+
89+ end := recognizer.Handle(touchpadNavigationInput{
90+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
91+ Config: cfg,
92+ CanGoBack: true,
93+ })
94+ require.False(t, end.HasAction)
95+ require.True(t, end.HasIndicator)
96+ require.False(t, end.Indicator.Active)
97+}
98+
99+func TestTouchpadNavigationRecognizer_CancelsVerticalGestures(t *testing.T) {
100+ recognizer := newTouchpadNavigationRecognizer()
101+ cfg := RuntimeInputConfig{
102+ TouchpadNavigationEnabled: true,
103+ TouchpadNavigationMinDelta: 200,
104+ TouchpadNavigationMaxVerticalRatio: 0.5,
105+ }
106+
107+ _ = recognizer.Handle(touchpadNavigationInput{
108+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseBegin, 0, 0),
109+ Config: cfg,
110+ CanGoBack: true,
111+ })
112+ vertical := recognizer.Handle(touchpadNavigationInput{
113+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -60, 80),
114+ Config: cfg,
115+ CanGoBack: true,
116+ })
117+ require.False(t, vertical.HasIndicator)
118+ require.False(t, vertical.HasAction)
119+
120+ continued := recognizer.Handle(touchpadNavigationInput{
121+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseUpdate, -400, 0),
122+ Config: cfg,
123+ CanGoBack: true,
124+ })
125+ require.False(t, continued.HasIndicator)
126+ require.False(t, continued.HasAction)
127+
128+ end := recognizer.Handle(touchpadNavigationInput{
129+ Event: touchpadNavigationScrollEvent(cef2gtk.ScrollPhaseEnd, 0, 0),
130+ Config: cfg,
131+ CanGoBack: true,
132+ })
133+ require.False(t, end.HasAction)
134+}
135+
136+func touchpadNavigationScrollEvent(phase cef2gtk.ScrollPhase, dx, dy float64) cef2gtk.ScrollEvent {
137+ return cef2gtk.ScrollEvent{
138+ Phase: phase,
139+ DX: dx,
140+ DY: dy,
141+ Unit: gdk.ScrollUnitSurfaceValue,
142+ UnitKnown: true,
143+ }
144+}
M internal/infrastructure/cef/webview.go +44 -4
@@ -228,6 +228,7 @@ type WebView struct {
228228
229229 // Touchpad input diagnostics are debounced to avoid flooding logs while
230230 // debugging continuous scroll streams.
231+ touchpadNavigation *touchpadNavigationRecognizer
231232 inputDiagMu sync.Mutex
232233 inputDiagLastLog time.Time
233234 inputDiagEvents int
@@ -1478,11 +1479,12 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
14781479 OnMiddleClick: func(_, _ float64) bool {
14791480 return wv.handleMiddleClickFromBridge()
14801481 },
1481- OnScroll: wv.handleScrollInputDiagnostic,
1482+ OnScroll: wv.handleScrollInput,
14821483 NavigationSwipe: cef2gtk.NavigationSwipeOptions{
1483- Enabled: wv.inputConfig.TouchpadNavigationEnabled,
1484- MinDelta: wv.inputConfig.TouchpadNavigationMinDelta,
1485- MaxVerticalRatio: wv.inputConfig.TouchpadNavigationMaxVerticalRatio,
1484+ // Dumber handles thresholding and progress UI locally from OnScroll so
1485+ // horizontal scrolling continues to reach CEF while history navigation
1486+ // waits for a deliberate release past the visual threshold.
1487+ Enabled: false,
14861488 },
14871489 CanNavigateBack: wv.CanGoBack,
14881490 CanNavigateForward: wv.CanGoForward,
@@ -1496,6 +1498,44 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
14961498 }
14971499 }
14981500
1501+func (wv *WebView) handleScrollInput(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {
1502+ wv.handleTouchpadNavigationScroll(event)
1503+ return wv.handleScrollInputDiagnostic(event)
1504+}
1505+
1506+func (wv *WebView) handleTouchpadNavigationScroll(event cef2gtk.ScrollEvent) {
1507+ if wv == nil {
1508+ return
1509+ }
1510+ if wv.touchpadNavigation == nil {
1511+ wv.touchpadNavigation = newTouchpadNavigationRecognizer()
1512+ }
1513+ result := wv.touchpadNavigation.Handle(touchpadNavigationInput{
1514+ Event: event,
1515+ Config: wv.inputConfig,
1516+ CanGoBack: wv.CanGoBack(),
1517+ CanGoForward: wv.CanGoForward(),
1518+ })
1519+ if result.HasIndicator {
1520+ wv.emitTouchpadNavigationGesture(result.Indicator)
1521+ }
1522+ if result.HasAction {
1523+ wv.handleNavigationSwipeAction(result.Action)
1524+ }
1525+}
1526+
1527+func (wv *WebView) emitTouchpadNavigationGesture(gesture port.TouchpadNavigationGesture) {
1528+ if wv == nil {
1529+ return
1530+ }
1531+ wv.mu.RLock()
1532+ cb := wv.callbacks
1533+ wv.mu.RUnlock()
1534+ if cb != nil && cb.OnTouchpadNavigationGesture != nil {
1535+ cb.OnTouchpadNavigationGesture(gesture)
1536+ }
1537+}
1538+
14991539 func (wv *WebView) handleScrollInputDiagnostic(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {
15001540 if event.Phase != cef2gtk.ScrollPhaseUpdate || (math.Abs(event.DX) == 0 && math.Abs(event.DY) == 0) {
15011541 return cef2gtk.ScrollForwardToCEF
@@ -228,6 +228,7 @@ type WebView struct {
228 228
229 // Touchpad input diagnostics are debounced to avoid flooding logs while229 // Touchpad input diagnostics are debounced to avoid flooding logs while
230 // debugging continuous scroll streams.230 // debugging continuous scroll streams.
231+ touchpadNavigation *touchpadNavigationRecognizer
231 inputDiagMu sync.Mutex232 inputDiagMu sync.Mutex
232 inputDiagLastLog time.Time233 inputDiagLastLog time.Time
233 inputDiagEvents int234 inputDiagEvents int
@@ -1478,11 +1479,12 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
1478 OnMiddleClick: func(_, _ float64) bool {1479 OnMiddleClick: func(_, _ float64) bool {
1479 return wv.handleMiddleClickFromBridge()1480 return wv.handleMiddleClickFromBridge()
1480 },1481 },
1481- OnScroll: wv.handleScrollInputDiagnostic,1482+ OnScroll: wv.handleScrollInput,
1482 NavigationSwipe: cef2gtk.NavigationSwipeOptions{1483 NavigationSwipe: cef2gtk.NavigationSwipeOptions{
1483- Enabled: wv.inputConfig.TouchpadNavigationEnabled,1484+ // Dumber handles thresholding and progress UI locally from OnScroll so
1484- MinDelta: wv.inputConfig.TouchpadNavigationMinDelta,1485+ // horizontal scrolling continues to reach CEF while history navigation
1485- MaxVerticalRatio: wv.inputConfig.TouchpadNavigationMaxVerticalRatio,1486+ // waits for a deliberate release past the visual threshold.
1487+ Enabled: false,
1486 },1488 },
1487 CanNavigateBack: wv.CanGoBack,1489 CanNavigateBack: wv.CanGoBack,
1488 CanNavigateForward: wv.CanGoForward,1490 CanNavigateForward: wv.CanGoForward,
@@ -1496,6 +1498,44 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
1496 }1498 }
1497 }1499 }
1498 1500
1501+func (wv *WebView) handleScrollInput(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {
1502+ wv.handleTouchpadNavigationScroll(event)
1503+ return wv.handleScrollInputDiagnostic(event)
1504+}
1505+
1506+func (wv *WebView) handleTouchpadNavigationScroll(event cef2gtk.ScrollEvent) {
1507+ if wv == nil {
1508+ return
1509+ }
1510+ if wv.touchpadNavigation == nil {
1511+ wv.touchpadNavigation = newTouchpadNavigationRecognizer()
1512+ }
1513+ result := wv.touchpadNavigation.Handle(touchpadNavigationInput{
1514+ Event: event,
1515+ Config: wv.inputConfig,
1516+ CanGoBack: wv.CanGoBack(),
1517+ CanGoForward: wv.CanGoForward(),
1518+ })
1519+ if result.HasIndicator {
1520+ wv.emitTouchpadNavigationGesture(result.Indicator)
1521+ }
1522+ if result.HasAction {
1523+ wv.handleNavigationSwipeAction(result.Action)
1524+ }
1525+}
1526+
1527+func (wv *WebView) emitTouchpadNavigationGesture(gesture port.TouchpadNavigationGesture) {
1528+ if wv == nil {
1529+ return
1530+ }
1531+ wv.mu.RLock()
1532+ cb := wv.callbacks
1533+ wv.mu.RUnlock()
1534+ if cb != nil && cb.OnTouchpadNavigationGesture != nil {
1535+ cb.OnTouchpadNavigationGesture(gesture)
1536+ }
1537+}
1538+
1499 func (wv *WebView) handleScrollInputDiagnostic(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {1539 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) {1540 if event.Phase != cef2gtk.ScrollPhaseUpdate || (math.Abs(event.DX) == 0 && math.Abs(event.DY) == 0) {
1501 return cef2gtk.ScrollForwardToCEF1541 return cef2gtk.ScrollForwardToCEF
M internal/infrastructure/cef/webview_input_test.go +3 -3
@@ -33,9 +33,9 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T)
3333 require.Equal(t, int32(120), opts.Scroll.MaxDelta)
3434 require.NotNil(t, opts.OnMiddleClick)
3535 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)
36+ require.False(t, opts.NavigationSwipe.Enabled)
37+ require.Zero(t, opts.NavigationSwipe.MinDelta)
38+ require.Zero(t, opts.NavigationSwipe.MaxVerticalRatio)
3939 require.NotNil(t, opts.CanNavigateBack)
4040 require.NotNil(t, opts.CanNavigateForward)
4141 require.NotNil(t, opts.OnNavigateSwipe)
@@ -33,9 +33,9 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T)
33 require.Equal(t, int32(120), opts.Scroll.MaxDelta)33 require.Equal(t, int32(120), opts.Scroll.MaxDelta)
34 require.NotNil(t, opts.OnMiddleClick)34 require.NotNil(t, opts.OnMiddleClick)
35 require.NotNil(t, opts.OnScroll)35 require.NotNil(t, opts.OnScroll)
36- require.True(t, opts.NavigationSwipe.Enabled)36+ require.False(t, opts.NavigationSwipe.Enabled)
37- require.InDelta(t, 80.0, opts.NavigationSwipe.MinDelta, 0.001)37+ require.Zero(t, opts.NavigationSwipe.MinDelta)
38- require.InDelta(t, 0.5, opts.NavigationSwipe.MaxVerticalRatio, 0.001)38+ require.Zero(t, opts.NavigationSwipe.MaxVerticalRatio)
39 require.NotNil(t, opts.CanNavigateBack)39 require.NotNil(t, opts.CanNavigateBack)
40 require.NotNil(t, opts.CanNavigateForward)40 require.NotNil(t, opts.CanNavigateForward)
41 require.NotNil(t, opts.OnNavigateSwipe)41 require.NotNil(t, opts.OnNavigateSwipe)
M internal/infrastructure/config/defaults.go +1 -1
@@ -88,7 +88,7 @@ const (
8888 defaultCEFScrollPreciseMultiplier = 2.5
8989 defaultCEFScrollMaxDelta = 0
9090 defaultCEFTouchpadNavigation = true
91- defaultCEFTouchpadNavigationDelta = 200.0
91+ defaultCEFTouchpadNavigationDelta = 320.0
9292 defaultCEFTouchpadNavigationRatio = 0.5
9393
9494 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
@@ -88,7 +88,7 @@ const (
88 defaultCEFScrollPreciseMultiplier = 2.588 defaultCEFScrollPreciseMultiplier = 2.5
89 defaultCEFScrollMaxDelta = 089 defaultCEFScrollMaxDelta = 0
90 defaultCEFTouchpadNavigation = true90 defaultCEFTouchpadNavigation = true
91- defaultCEFTouchpadNavigationDelta = 200.091+ defaultCEFTouchpadNavigationDelta = 320.0
92 defaultCEFTouchpadNavigationRatio = 0.592 defaultCEFTouchpadNavigationRatio = 0.5
93 93
94 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)94 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
M internal/infrastructure/config/defaults_test.go +1 -0
@@ -32,6 +32,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
3232 assert.Equal(t, int32(defaultCEFScrollMaxDelta), cfg.Engine.CEF.Input.ScrollMaxDelta)
3333 assert.Equal(t, defaultCEFTouchpadNavigation, cfg.Engine.CEF.Input.TouchpadNavigationEnabled)
3434 assert.InDelta(t, defaultCEFTouchpadNavigationDelta, cfg.Engine.CEF.Input.TouchpadNavigationMinDelta, 0.001)
35+ assert.InDelta(t, 320.0, cfg.Engine.CEF.Input.TouchpadNavigationMinDelta, 0.001, "touchpad navigation should require a deliberate swipe by default")
3536 assert.InDelta(t, defaultCEFTouchpadNavigationRatio, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
3637 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
3738
@@ -32,6 +32,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
32 assert.Equal(t, int32(defaultCEFScrollMaxDelta), cfg.Engine.CEF.Input.ScrollMaxDelta)32 assert.Equal(t, int32(defaultCEFScrollMaxDelta), cfg.Engine.CEF.Input.ScrollMaxDelta)
33 assert.Equal(t, defaultCEFTouchpadNavigation, cfg.Engine.CEF.Input.TouchpadNavigationEnabled)33 assert.Equal(t, defaultCEFTouchpadNavigation, cfg.Engine.CEF.Input.TouchpadNavigationEnabled)
34 assert.InDelta(t, defaultCEFTouchpadNavigationDelta, cfg.Engine.CEF.Input.TouchpadNavigationMinDelta, 0.001)34 assert.InDelta(t, defaultCEFTouchpadNavigationDelta, cfg.Engine.CEF.Input.TouchpadNavigationMinDelta, 0.001)
35+ assert.InDelta(t, 320.0, cfg.Engine.CEF.Input.TouchpadNavigationMinDelta, 0.001, "touchpad navigation should require a deliberate swipe by default")
35 assert.InDelta(t, defaultCEFTouchpadNavigationRatio, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)36 assert.InDelta(t, defaultCEFTouchpadNavigationRatio, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
36 assert.True(t, cfg.Engine.WebKit.ITPEnabled)37 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
37 38
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 port.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 port.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 port.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 port.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 +196 -0
@@ -0,0 +1,196 @@
1+package component
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/puregotk/v4/glib"
8+ "github.com/bnema/puregotk/v4/gtk"
9+)
10+
11+const (
12+ touchpadNavigationIndicatorHideMs = 220
13+ touchpadNavigationIndicatorSpacing = 6
14+ touchpadNavigationIndicatorMargin = 28
15+ touchpadNavigationIndicatorWidth = 96
16+ touchpadNavigationIndicatorBarHeight = 4
17+)
18+
19+// TouchpadNavigationIndicator is a lightweight overlay that makes CEF
20+// two-finger history navigation deliberate: it appears during the swipe,
21+// fills toward the commit threshold, and briefly shows the triggered state.
22+type TouchpadNavigationIndicator struct {
23+ container *gtk.Box
24+ label *gtk.Label
25+ bar *gtk.ProgressBar
26+
27+ hideTimer uint
28+ mu sync.Mutex
29+}
30+
31+func NewTouchpadNavigationIndicator() *TouchpadNavigationIndicator {
32+ container := gtk.NewBox(gtk.OrientationVerticalValue, touchpadNavigationIndicatorSpacing)
33+ if container == nil {
34+ return nil
35+ }
36+ container.AddCssClass("touchpad-navigation-indicator")
37+ container.SetValign(gtk.AlignCenterValue)
38+ container.SetHalign(gtk.AlignStartValue)
39+ container.SetHexpand(false)
40+ container.SetVexpand(false)
41+ container.SetMarginStart(touchpadNavigationIndicatorMargin)
42+ container.SetMarginEnd(touchpadNavigationIndicatorMargin)
43+ container.SetCanTarget(false)
44+ container.SetCanFocus(false)
45+ container.SetVisible(false)
46+
47+ text := ""
48+ label := gtk.NewLabel(&text)
49+ if label == nil {
50+ container.Unref()
51+ return nil
52+ }
53+ label.AddCssClass("touchpad-navigation-label")
54+ label.SetCanTarget(false)
55+ label.SetCanFocus(false)
56+ container.Append(&label.Widget)
57+
58+ bar := gtk.NewProgressBar()
59+ if bar == nil {
60+ container.Unref()
61+ return nil
62+ }
63+ bar.AddCssClass("touchpad-navigation-progress")
64+ bar.SetSizeRequest(touchpadNavigationIndicatorWidth, touchpadNavigationIndicatorBarHeight)
65+ bar.SetFraction(0)
66+ bar.SetCanTarget(false)
67+ bar.SetCanFocus(false)
68+ container.Append(&bar.Widget)
69+
70+ return &TouchpadNavigationIndicator{
71+ container: container,
72+ label: label,
73+ bar: bar,
74+ }
75+}
76+
77+func (i *TouchpadNavigationIndicator) Widget() *gtk.Widget {
78+ if i == nil || i.container == nil {
79+ return nil
80+ }
81+ return &i.container.Widget
82+}
83+
84+func (i *TouchpadNavigationIndicator) ShowGesture(gesture port.TouchpadNavigationGesture) {
85+ if i == nil || i.container == nil || i.label == nil || i.bar == nil {
86+ return
87+ }
88+ i.mu.Lock()
89+ defer i.mu.Unlock()
90+ i.cancelHideTimerLocked()
91+
92+ if gesture.Action == port.TouchpadNavigationForward {
93+ i.container.SetHalign(gtk.AlignEndValue)
94+ i.bar.SetInverted(true)
95+ } else {
96+ i.container.SetHalign(gtk.AlignStartValue)
97+ i.bar.SetInverted(false)
98+ }
99+
100+ if gesture.ThresholdReached {
101+ i.container.AddCssClass("threshold-reached")
102+ } else {
103+ i.container.RemoveCssClass("threshold-reached")
104+ }
105+
106+ i.label.SetLabel(touchpadNavigationIndicatorLabel(gesture))
107+ i.bar.SetFraction(clampIndicatorProgress(gesture.Progress))
108+ i.container.SetVisible(true)
109+
110+ if !gesture.Active {
111+ i.scheduleHideLocked(gesture.ThresholdReached)
112+ }
113+}
114+
115+func (i *TouchpadNavigationIndicator) Hide() {
116+ if i == nil || i.container == nil {
117+ return
118+ }
119+ i.mu.Lock()
120+ defer i.mu.Unlock()
121+ i.hideLocked()
122+}
123+
124+func (i *TouchpadNavigationIndicator) Destroy() {
125+ if i == nil {
126+ return
127+ }
128+ i.mu.Lock()
129+ defer i.mu.Unlock()
130+ i.cancelHideTimerLocked()
131+ if i.container != nil {
132+ i.container.SetVisible(false)
133+ i.container.Unparent()
134+ }
135+ i.container = nil
136+ i.label = nil
137+ i.bar = nil
138+}
139+
140+func (i *TouchpadNavigationIndicator) scheduleHideLocked(triggered bool) {
141+ delay := uint(0)
142+ if triggered {
143+ delay = touchpadNavigationIndicatorHideMs
144+ }
145+ cb := glib.SourceFunc(func(_ uintptr) bool {
146+ i.mu.Lock()
147+ defer i.mu.Unlock()
148+ i.hideTimer = 0
149+ i.hideLocked()
150+ return false
151+ })
152+ if delay == 0 {
153+ i.hideLocked()
154+ return
155+ }
156+ i.hideTimer = glib.TimeoutAdd(delay, &cb, 0)
157+}
158+
159+func (i *TouchpadNavigationIndicator) hideLocked() {
160+ i.cancelHideTimerLocked()
161+ if i.container != nil {
162+ i.container.RemoveCssClass("threshold-reached")
163+ i.container.SetVisible(false)
164+ }
165+ if i.bar != nil {
166+ i.bar.SetFraction(0)
167+ }
168+}
169+
170+func (i *TouchpadNavigationIndicator) cancelHideTimerLocked() {
171+ if i.hideTimer != 0 {
172+ glib.SourceRemove(i.hideTimer)
173+ i.hideTimer = 0
174+ }
175+}
176+
177+func touchpadNavigationIndicatorLabel(gesture port.TouchpadNavigationGesture) string {
178+ suffix := "back"
179+ if gesture.Action == port.TouchpadNavigationForward {
180+ suffix = "forward"
181+ }
182+ if gesture.ThresholdReached {
183+ return "Release to go " + suffix
184+ }
185+ return "Slide to go " + suffix
186+}
187+
188+func clampIndicatorProgress(progress float64) float64 {
189+ if progress < 0 {
190+ return 0
191+ }
192+ if progress > 1 {
193+ return 1
194+ }
195+ return progress
196+}
@@ -0,0 +1,196 @@
1+package component
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/puregotk/v4/glib"
8+ "github.com/bnema/puregotk/v4/gtk"
9+)
10+
11+const (
12+ touchpadNavigationIndicatorHideMs = 220
13+ touchpadNavigationIndicatorSpacing = 6
14+ touchpadNavigationIndicatorMargin = 28
15+ touchpadNavigationIndicatorWidth = 96
16+ touchpadNavigationIndicatorBarHeight = 4
17+)
18+
19+// TouchpadNavigationIndicator is a lightweight overlay that makes CEF
20+// two-finger history navigation deliberate: it appears during the swipe,
21+// fills toward the commit threshold, and briefly shows the triggered state.
22+type TouchpadNavigationIndicator struct {
23+ container *gtk.Box
24+ label *gtk.Label
25+ bar *gtk.ProgressBar
26+
27+ hideTimer uint
28+ mu sync.Mutex
29+}
30+
31+func NewTouchpadNavigationIndicator() *TouchpadNavigationIndicator {
32+ container := gtk.NewBox(gtk.OrientationVerticalValue, touchpadNavigationIndicatorSpacing)
33+ if container == nil {
34+ return nil
35+ }
36+ container.AddCssClass("touchpad-navigation-indicator")
37+ container.SetValign(gtk.AlignCenterValue)
38+ container.SetHalign(gtk.AlignStartValue)
39+ container.SetHexpand(false)
40+ container.SetVexpand(false)
41+ container.SetMarginStart(touchpadNavigationIndicatorMargin)
42+ container.SetMarginEnd(touchpadNavigationIndicatorMargin)
43+ container.SetCanTarget(false)
44+ container.SetCanFocus(false)
45+ container.SetVisible(false)
46+
47+ text := ""
48+ label := gtk.NewLabel(&text)
49+ if label == nil {
50+ container.Unref()
51+ return nil
52+ }
53+ label.AddCssClass("touchpad-navigation-label")
54+ label.SetCanTarget(false)
55+ label.SetCanFocus(false)
56+ container.Append(&label.Widget)
57+
58+ bar := gtk.NewProgressBar()
59+ if bar == nil {
60+ container.Unref()
61+ return nil
62+ }
63+ bar.AddCssClass("touchpad-navigation-progress")
64+ bar.SetSizeRequest(touchpadNavigationIndicatorWidth, touchpadNavigationIndicatorBarHeight)
65+ bar.SetFraction(0)
66+ bar.SetCanTarget(false)
67+ bar.SetCanFocus(false)
68+ container.Append(&bar.Widget)
69+
70+ return &TouchpadNavigationIndicator{
71+ container: container,
72+ label: label,
73+ bar: bar,
74+ }
75+}
76+
77+func (i *TouchpadNavigationIndicator) Widget() *gtk.Widget {
78+ if i == nil || i.container == nil {
79+ return nil
80+ }
81+ return &i.container.Widget
82+}
83+
84+func (i *TouchpadNavigationIndicator) ShowGesture(gesture port.TouchpadNavigationGesture) {
85+ if i == nil || i.container == nil || i.label == nil || i.bar == nil {
86+ return
87+ }
88+ i.mu.Lock()
89+ defer i.mu.Unlock()
90+ i.cancelHideTimerLocked()
91+
92+ if gesture.Action == port.TouchpadNavigationForward {
93+ i.container.SetHalign(gtk.AlignEndValue)
94+ i.bar.SetInverted(true)
95+ } else {
96+ i.container.SetHalign(gtk.AlignStartValue)
97+ i.bar.SetInverted(false)
98+ }
99+
100+ if gesture.ThresholdReached {
101+ i.container.AddCssClass("threshold-reached")
102+ } else {
103+ i.container.RemoveCssClass("threshold-reached")
104+ }
105+
106+ i.label.SetLabel(touchpadNavigationIndicatorLabel(gesture))
107+ i.bar.SetFraction(clampIndicatorProgress(gesture.Progress))
108+ i.container.SetVisible(true)
109+
110+ if !gesture.Active {
111+ i.scheduleHideLocked(gesture.ThresholdReached)
112+ }
113+}
114+
115+func (i *TouchpadNavigationIndicator) Hide() {
116+ if i == nil || i.container == nil {
117+ return
118+ }
119+ i.mu.Lock()
120+ defer i.mu.Unlock()
121+ i.hideLocked()
122+}
123+
124+func (i *TouchpadNavigationIndicator) Destroy() {
125+ if i == nil {
126+ return
127+ }
128+ i.mu.Lock()
129+ defer i.mu.Unlock()
130+ i.cancelHideTimerLocked()
131+ if i.container != nil {
132+ i.container.SetVisible(false)
133+ i.container.Unparent()
134+ }
135+ i.container = nil
136+ i.label = nil
137+ i.bar = nil
138+}
139+
140+func (i *TouchpadNavigationIndicator) scheduleHideLocked(triggered bool) {
141+ delay := uint(0)
142+ if triggered {
143+ delay = touchpadNavigationIndicatorHideMs
144+ }
145+ cb := glib.SourceFunc(func(_ uintptr) bool {
146+ i.mu.Lock()
147+ defer i.mu.Unlock()
148+ i.hideTimer = 0
149+ i.hideLocked()
150+ return false
151+ })
152+ if delay == 0 {
153+ i.hideLocked()
154+ return
155+ }
156+ i.hideTimer = glib.TimeoutAdd(delay, &cb, 0)
157+}
158+
159+func (i *TouchpadNavigationIndicator) hideLocked() {
160+ i.cancelHideTimerLocked()
161+ if i.container != nil {
162+ i.container.RemoveCssClass("threshold-reached")
163+ i.container.SetVisible(false)
164+ }
165+ if i.bar != nil {
166+ i.bar.SetFraction(0)
167+ }
168+}
169+
170+func (i *TouchpadNavigationIndicator) cancelHideTimerLocked() {
171+ if i.hideTimer != 0 {
172+ glib.SourceRemove(i.hideTimer)
173+ i.hideTimer = 0
174+ }
175+}
176+
177+func touchpadNavigationIndicatorLabel(gesture port.TouchpadNavigationGesture) string {
178+ suffix := "back"
179+ if gesture.Action == port.TouchpadNavigationForward {
180+ suffix = "forward"
181+ }
182+ if gesture.ThresholdReached {
183+ return "Release to go " + suffix
184+ }
185+ return "Slide to go " + suffix
186+}
187+
188+func clampIndicatorProgress(progress float64) float64 {
189+ if progress < 0 {
190+ return 0
191+ }
192+ if progress > 1 {
193+ return 1
194+ }
195+ return progress
196+}
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 port.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 port.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 port.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 port.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 port.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 port.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 +46 -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,48 @@ 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-variant), 0.92);
774+ color: var(--text);
775+ border: 1px solid alpha(var(--accent), 0.45);
776+ border-radius: 999px;
777+ padding: 10px 12px;
778+ box-shadow: 0 10px 24px alpha(#000000, 0.28);
779+}
780+
781+.touchpad-navigation-indicator.threshold-reached {
782+ border-color: var(--accent);
783+ background-color: alpha(var(--accent), 0.20);
784+}
785+
786+.touchpad-navigation-label {
787+ font-weight: 600;
788+ font-size: 0.9em;
789+}
790+
791+progressbar.touchpad-navigation-progress {
792+ min-height: 4px;
793+}
794+
795+progressbar.touchpad-navigation-progress trough {
796+ min-height: 4px;
797+ min-width: 96px;
798+ border-radius: 999px;
799+ background-color: alpha(var(--text), 0.16);
800+}
801+
802+progressbar.touchpad-navigation-progress progress {
803+ min-height: 4px;
804+ border-radius: 999px;
805+ background-color: var(--accent);
806+}
807+`
808+}
809+
764810 // generateSessionManagerCSS creates session manager modal styles.
765811 // Uses em units for scalable UI, matches omnibox styling patterns.
766812 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,48 @@ 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-variant), 0.92);
774+ color: var(--text);
775+ border: 1px solid alpha(var(--accent), 0.45);
776+ border-radius: 999px;
777+ padding: 10px 12px;
778+ box-shadow: 0 10px 24px alpha(#000000, 0.28);
779+}
780+
781+.touchpad-navigation-indicator.threshold-reached {
782+ border-color: var(--accent);
783+ background-color: alpha(var(--accent), 0.20);
784+}
785+
786+.touchpad-navigation-label {
787+ font-weight: 600;
788+ font-size: 0.9em;
789+}
790+
791+progressbar.touchpad-navigation-progress {
792+ min-height: 4px;
793+}
794+
795+progressbar.touchpad-navigation-progress trough {
796+ min-height: 4px;
797+ min-width: 96px;
798+ border-radius: 999px;
799+ background-color: alpha(var(--text), 0.16);
800+}
801+
802+progressbar.touchpad-navigation-progress progress {
803+ min-height: 4px;
804+ border-radius: 999px;
805+ background-color: var(--accent);
806+}
807+`
808+}
809+
764 // generateSessionManagerCSS creates session manager modal styles.810 // generateSessionManagerCSS creates session manager modal styles.
765 // Uses em units for scalable UI, matches omnibox styling patterns.811 // Uses em units for scalable UI, matches omnibox styling patterns.
766 func generateSessionManagerCSS(p Palette) string {812 func generateSessionManagerCSS(p Palette) string {
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