Merge pull request #309 from bnema/feat/thresholded-touchpad-navigation
feat(cef): improve touchpad history navigation
16 files · +1472 -17
M internal/application/port/webview.go +4 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "context" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // WebViewID uniquely identifies a WebView instance. | |
| @@ -137,6 +138,9 @@ type WebViewCallbacks struct { | |||
| 137 | 138 | ||
| 138 | 139 | // OnAudioStateChanged is called when audio playback starts or stops. | |
| 139 | 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 | 146 | // 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 ( | |||
| 20 | 20 | ||
| 21 | 21 | "github.com/bnema/dumber/internal/application/dto" | |
| 22 | 22 | "github.com/bnema/dumber/internal/application/port" | |
| 23 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 23 | 24 | "github.com/bnema/dumber/internal/logging" | |
| 24 | 25 | "github.com/bnema/dumber/internal/shared/syncdispatch" | |
| 25 | 26 | ) | |
| @@ -228,6 +229,7 @@ type WebView struct { | |||
| 228 | 229 | ||
| 229 | 230 | // Touchpad input diagnostics are debounced to avoid flooding logs while | |
| 230 | 231 | // debugging continuous scroll streams. | |
| 232 | + | touchpadNavigation *touchpadNavigationRecognizer | |
| 231 | 233 | inputDiagMu sync.Mutex | |
| 232 | 234 | inputDiagLastLog time.Time | |
| 233 | 235 | inputDiagEvents int | |
| @@ -1136,6 +1138,7 @@ func (wv *WebView) Destroy() { | |||
| 1136 | 1138 | wv.openerMessageCallbacks = nil | |
| 1137 | 1139 | wv.openerNavigationCallbacks = nil | |
| 1138 | 1140 | wv.popupReadyToShow = nil | |
| 1141 | + | wv.touchpadNavigation = nil | |
| 1139 | 1142 | wv.inputAttached = false | |
| 1140 | 1143 | wv.pendingNativePopups = nil | |
| 1141 | 1144 | wv.nativePopupParent = nil | |
| @@ -1478,16 +1481,14 @@ func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions { | |||
| 1478 | 1481 | OnMiddleClick: func(_, _ float64) bool { | |
| 1479 | 1482 | return wv.handleMiddleClickFromBridge() | |
| 1480 | 1483 | }, | |
| 1481 | - | OnScroll: wv.handleScrollInputDiagnostic, | |
| 1484 | + | OnScroll: wv.handleScrollInput, | |
| 1482 | 1485 | 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, | |
| 1486 | 1490 | }, | |
| 1487 | - | CanNavigateBack: wv.CanGoBack, | |
| 1488 | - | CanNavigateForward: wv.CanGoForward, | |
| 1489 | - | OnNavigateSwipe: wv.handleNavigationSwipeAction, | |
| 1490 | - | SelectionText: wv.selectedTextSnapshot, | |
| 1491 | + | SelectionText: wv.selectedTextSnapshot, | |
| 1491 | 1492 | OnClipboardShortcut: func(action, text string) { | |
| 1492 | 1493 | if wv.engine != nil { | |
| 1493 | 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 | 1563 | func (wv *WebView) handleScrollInputDiagnostic(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision { | |
| 1500 | 1564 | if event.Phase != cef2gtk.ScrollPhaseUpdate || (math.Abs(event.DX) == 0 && math.Abs(event.DY) == 0) { | |
| 1501 | 1565 | 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 while | 230 | // 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.Mutex | 233 | inputDiagMu sync.Mutex | ||
| 232 | inputDiagLastLog time.Time | 234 | inputDiagLastLog time.Time | ||
| 233 | inputDiagEvents int | 235 | inputDiagEvents int | ||
| @@ -1136,6 +1138,7 @@ func (wv *WebView) Destroy() { | |||||
| 1136 | wv.openerMessageCallbacks = nil | 1138 | wv.openerMessageCallbacks = nil | ||
| 1137 | wv.openerNavigationCallbacks = nil | 1139 | wv.openerNavigationCallbacks = nil | ||
| 1138 | wv.popupReadyToShow = nil | 1140 | wv.popupReadyToShow = nil | ||
| 1141 | + | wv.touchpadNavigation = nil | |||
| 1139 | wv.inputAttached = false | 1142 | wv.inputAttached = false | ||
| 1140 | wv.pendingNativePopups = nil | 1143 | wv.pendingNativePopups = nil | ||
| 1141 | wv.nativePopupParent = nil | 1144 | 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.ScrollForwardToCEF | 1565 | return cef2gtk.ScrollForwardToCEF | ||
M internal/infrastructure/cef/webview_input_test.go +96 -6
| @@ -1,8 +1,14 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 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 | 12 | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | 13 | "github.com/stretchr/testify/require" | |
| 8 | 14 | ) | |
| @@ -33,13 +39,97 @@ func TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T) | |||
| 33 | 39 | require.Equal(t, int32(120), opts.Scroll.MaxDelta) | |
| 34 | 40 | require.NotNil(t, opts.OnMiddleClick) | |
| 35 | 41 | 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) | |
| 42 | 48 | require.NotNil(t, opts.SelectionText) | |
| 43 | 49 | require.NotNil(t, opts.OnClipboardShortcut) | |
| 44 | 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 | + | } | |
| @@ -1,8 +1,14 @@ | |||||
| 1 | package cef | 1 | 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 ( | |||
| 88 | 88 | defaultCEFScrollPreciseMultiplier = 2.5 | |
| 89 | 89 | defaultCEFScrollMaxDelta = 0 | |
| 90 | 90 | 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 | |
| 92 | 94 | defaultCEFTouchpadNavigationRatio = 0.5 | |
| 93 | 95 | ||
| 94 | 96 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| @@ -88,7 +88,9 @@ const ( | |||||
| 88 | defaultCEFScrollPreciseMultiplier = 2.5 | 88 | defaultCEFScrollPreciseMultiplier = 2.5 | ||
| 89 | defaultCEFScrollMaxDelta = 0 | 89 | defaultCEFScrollMaxDelta = 0 | ||
| 90 | defaultCEFTouchpadNavigation = true | 90 | 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 | |||
| 92 | defaultCEFTouchpadNavigationRatio = 0.5 | 94 | 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 | |||
| 1044 | 1044 | Key: "engine.cef.input.touchpad_navigation_enabled", | |
| 1045 | 1045 | Type: "bool", | |
| 1046 | 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 | 1048 | Section: SectionPerformance, | |
| 1049 | 1049 | }, | |
| 1050 | 1050 | { | |
| 1051 | 1051 | Key: "engine.cef.input.touchpad_navigation_min_delta", | |
| 1052 | 1052 | Type: "float64", | |
| 1053 | 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 | 1055 | Range: ">0", | |
| 1056 | 1056 | Section: SectionPerformance, | |
| 1057 | 1057 | }, | |
| @@ -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 { | |||
| 593 | 593 | 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 | 614 | func (a *App) lastFocusedBrowserWindow() *browserWindow { | |
| 597 | 615 | if a.lastFocusedWindowID == "" || a.browserWindows == nil { | |
| 598 | 616 | return nil | |
| @@ -3176,6 +3194,10 @@ func (a *App) initContentCoordinator( | |||
| 3176 | 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 | 3201 | // Wire deferred init trigger - runs after first navigation starts | |
| 3180 | 3202 | a.contentCoord.SetOnFirstLoadStarted(func() { | |
| 3181 | 3203 | a.triggerDeferredInit(ctx) | |
| @@ -593,6 +593,24 @@ func (a *App) browserWindowForAnyPane(paneID entity.PaneID) *browserWindow { | |||||
| 593 | return nil | 593 | 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 nil | 616 | 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 starts | 3201 | // 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 { | |||
| 26 | 26 | mainWindow *window.MainWindow | |
| 27 | 27 | appToaster *component.Toaster | |
| 28 | 28 | modeToaster *component.Toaster | |
| 29 | + | touchpadNavIndicator *component.TouchpadNavigationIndicator | |
| 29 | 30 | borderMgr *focus.BorderManager | |
| 30 | 31 | sessionManager *component.SessionManager | |
| 31 | 32 | tabPicker *component.TabPicker | |
| @@ -71,8 +72,12 @@ func (bw *browserWindow) clearShellState() { | |||
| 71 | 72 | if bw.historySidebar != nil { | |
| 72 | 73 | bw.historySidebar.Destroy() | |
| 73 | 74 | } | |
| 75 | + | if bw.touchpadNavIndicator != nil { | |
| 76 | + | bw.touchpadNavIndicator.Destroy() | |
| 77 | + | } | |
| 74 | 78 | bw.appToaster = nil | |
| 75 | 79 | bw.modeToaster = nil | |
| 80 | + | bw.touchpadNavIndicator = nil | |
| 76 | 81 | bw.borderMgr = nil | |
| 77 | 82 | bw.sessionManager = nil | |
| 78 | 83 | bw.tabPicker = nil | |
| @@ -93,6 +98,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |||
| 93 | 98 | } | |
| 94 | 99 | ||
| 95 | 100 | bw.initToasterOverlay(a) | |
| 101 | + | bw.initTouchpadNavigationIndicator() | |
| 96 | 102 | bw.initBorderOverlay(a) | |
| 97 | 103 | bw.initAccentPicker(ctx, a) | |
| 98 | 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 | 146 | func (bw *browserWindow) initBorderOverlay(a *App) { | |
| 128 | 147 | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | |
| 129 | 148 | return | |
| @@ -26,6 +26,7 @@ type browserWindow struct { | |||||
| 26 | mainWindow *window.MainWindow | 26 | mainWindow *window.MainWindow | ||
| 27 | appToaster *component.Toaster | 27 | appToaster *component.Toaster | ||
| 28 | modeToaster *component.Toaster | 28 | modeToaster *component.Toaster | ||
| 29 | + | touchpadNavIndicator *component.TouchpadNavigationIndicator | |||
| 29 | borderMgr *focus.BorderManager | 30 | borderMgr *focus.BorderManager | ||
| 30 | sessionManager *component.SessionManager | 31 | sessionManager *component.SessionManager | ||
| 31 | tabPicker *component.TabPicker | 32 | 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 = nil | 78 | bw.appToaster = nil | ||
| 75 | bw.modeToaster = nil | 79 | bw.modeToaster = nil | ||
| 80 | + | bw.touchpadNavIndicator = nil | |||
| 76 | bw.borderMgr = nil | 81 | bw.borderMgr = nil | ||
| 77 | bw.sessionManager = nil | 82 | bw.sessionManager = nil | ||
| 78 | bw.tabPicker = nil | 83 | 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 | return | 148 | 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 | |||
| 87 | 87 | OnLinkHover: func(uri string) { | |
| 88 | 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 | 95 | OnWebProcessTerminated: func(reason port.WebProcessTerminationReason, reasonLabel string, uri string) { | |
| 91 | 96 | originalURI := extractOriginalURIFromCrashPage(uri) | |
| 92 | 97 | 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 { | |||
| 64 | 64 | // Callback when the active pane commits a navigation (new page loading). | |
| 65 | 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 | 70 | // Callback when the WebView becomes visible (first real commit) | |
| 68 | 71 | onWebViewShown func(paneID entity.PaneID) | |
| 69 | 72 | ||
| @@ -192,6 +195,12 @@ func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url stri | |||
| 192 | 195 | 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 | 204 | // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates). | |
| 196 | 205 | func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) { | |
| 197 | 206 | 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 = fn | 195 | 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 = fn | 206 | c.onWindowTitleChanged = fn | ||
M internal/ui/theme/css.go +95 -0
| @@ -106,6 +106,10 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo | |||
| 106 | 106 | sb.WriteString(generateProgressBarCSS(p)) | |
| 107 | 107 | sb.WriteString("\n") | |
| 108 | 108 | ||
| 109 | + | // Touchpad history navigation indicator styling | |
| 110 | + | sb.WriteString(generateTouchpadNavigationIndicatorCSS(p)) | |
| 111 | + | sb.WriteString("\n") | |
| 112 | + | ||
| 109 | 113 | // Toaster styling | |
| 110 | 114 | sb.WriteString(generateToasterCSS(p)) | |
| 111 | 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 | 859 | // generateSessionManagerCSS creates session manager modal styles. | |
| 765 | 860 | // Uses em units for scalable UI, matches omnibox styling patterns. | |
| 766 | 861 | 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 styling | 113 | // 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 { | |||
| 235 | 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 | 256 | func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) { | |
| 239 | 257 | css := GenerateCSS(DefaultDarkPalette()) | |
| 240 | 258 | ||
| @@ -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) { | |||
| 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 | 326 | // Destroy releases all GTK resources held by the window. | |
| 315 | 327 | func (mw *MainWindow) Destroy() { | |
| 316 | 328 | 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 { | ||