1package cef
2
3import (
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
11const (
12 defaultTouchpadNavigationCommitDistance = 320.0
13 defaultTouchpadNavigationVerticalRatio = 0.5
14 touchpadNavigationIndicatorActivationFraction = 0.5
15 touchpadNavigationEdgeFraction = 0.15
16 touchpadNavigationMaxEdgeDistance = 96.0
17)
18
19type 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
31type touchpadNavigationInput struct {
32 Event cef2gtk.ScrollEvent
33 Config RuntimeInputConfig
34 CanGoBack bool
35 CanGoForward bool
36 ViewWidth float64
37}
38
39type touchpadNavigationResult struct {
40 HasIndicator bool
41 Indicator entity.TouchpadNavigationGesture
42 HasAction bool
43 Action cef2gtk.NavigationSwipeAction
44}
45
46func newTouchpadNavigationRecognizer() *touchpadNavigationRecognizer {
47 return &touchpadNavigationRecognizer{}
48}
49
50func (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
72func (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
115func (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
130func (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
150func (r *touchpadNavigationRecognizer) indicatorAction(input touchpadNavigationInput) (entity.TouchpadNavigationAction, bool) {
151 return r.resolvedDirection(input)
152}
153
154func (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
174func (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
185func (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
195func (r *touchpadNavigationRecognizer) setGestureStart(input touchpadNavigationInput) {
196 r.gestureStartX = input.Event.X
197 r.gestureViewWidth = input.ViewWidth
198 r.hasGestureStart = true
199}
200
201func (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
212func (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
217func (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
229func isTouchpadNavigationPreciseEvent(event cef2gtk.ScrollEvent) bool {
230 return event.UnitKnown && event.Unit == gdk.ScrollUnitSurfaceValue
231}
232
233func normalizedTouchpadNavigationMinDelta(value float64) float64 {
234 if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
235 return defaultTouchpadNavigationCommitDistance
236 }
237 return value
238}
239
240func normalizedTouchpadNavigationRatio(value float64) float64 {
241 if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
242 return defaultTouchpadNavigationVerticalRatio
243 }
244 return value
245}
246
247func 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}