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