fix: simplify back/forward navigation to match Epiphany's approach
Potential fix for SPA back navigation issues. Changes: - Add back-forward-list::changed signal to track canGoBack/canGoFwd state changes, including SPA history.pushState() entries - Simplify GoBack/GoForward to direct WebKit calls without JS workarounds - Add GoBackDirect/GoForwardDirect for gesture handlers to preserve user gesture context (critical for SPA popstate handling) - Update gesture handler to call navigation directly on WebView - Claim gesture sequence to stop event propagation (matches Epiphany) This matches Epiphany's implementation which calls webkit_web_view_go_back() directly in the gesture callback without any intermediate layers.
- commit
- fe663f6a1c9462ebb6d6c6195a25f56cbfeb20a0
- author
- brice · · 2026-01-28 16:55 UTC
- parent
- 61df531
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +131 -57
M internal/infrastructure/webkit/webview.go +72 -26
| @@ -384,6 +384,7 @@ func (wv *WebView) connectSignals() { | |||
| 384 | 384 | wv.connectLeaveFullscreenSignal() | |
| 385 | 385 | wv.connectAudioStateSignal() | |
| 386 | 386 | wv.connectMouseTargetChangedSignal() | |
| 387 | + | wv.connectBackForwardListChangedSignal() | |
| 387 | 388 | } | |
| 388 | 389 | ||
| 389 | 390 | func (wv *WebView) connectLoadChangedSignal() { | |
| @@ -394,8 +395,8 @@ func (wv *WebView) connectLoadChangedSignal() { | |||
| 394 | 395 | wv.mu.Lock() | |
| 395 | 396 | wv.uri = uri | |
| 396 | 397 | wv.title = title | |
| 397 | - | wv.canGoBack = inner.CanGoBack() | |
| 398 | - | wv.canGoFwd = inner.CanGoForward() | |
| 398 | + | // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal | |
| 399 | + | // which fires for both traditional navigation and SPA history.pushState() | |
| 399 | 400 | wv.progress = inner.GetEstimatedLoadProgress() | |
| 400 | 401 | ||
| 401 | 402 | switch event { | |
| @@ -820,6 +821,33 @@ func (wv *WebView) connectMouseTargetChangedSignal() { | |||
| 820 | 821 | wv.signalIDs = append(wv.signalIDs, sigID) | |
| 821 | 822 | } | |
| 822 | 823 | ||
| 824 | + | func (wv *WebView) connectBackForwardListChangedSignal() { | |
| 825 | + | backForwardList := wv.inner.GetBackForwardList() | |
| 826 | + | if backForwardList == nil { | |
| 827 | + | wv.logger.Warn().Msg("failed to get back-forward-list") | |
| 828 | + | return | |
| 829 | + | } | |
| 830 | + | ||
| 831 | + | // The "changed" signal is emitted when the back-forward list changes, | |
| 832 | + | // including for SPA navigation via history.pushState/replaceState. | |
| 833 | + | // Parameters: (item_added, items_removed) - we ignore them and just refresh state. | |
| 834 | + | changedCb := func(_ webkit.BackForwardList, _ uintptr, _ uintptr) { | |
| 835 | + | canBack := wv.inner.CanGoBack() | |
| 836 | + | canFwd := wv.inner.CanGoForward() | |
| 837 | + | wv.logger.Debug(). | |
| 838 | + | Bool("can_go_back", canBack). | |
| 839 | + | Bool("can_go_forward", canFwd). | |
| 840 | + | Uint("list_length", backForwardList.GetLength()). | |
| 841 | + | Msg("back-forward-list changed") | |
| 842 | + | wv.mu.Lock() | |
| 843 | + | wv.canGoBack = canBack | |
| 844 | + | wv.canGoFwd = canFwd | |
| 845 | + | wv.mu.Unlock() | |
| 846 | + | } | |
| 847 | + | sigID := backForwardList.ConnectChanged(&changedCb) | |
| 848 | + | wv.signalIDs = append(wv.signalIDs, sigID) | |
| 849 | + | } | |
| 850 | + | ||
| 823 | 851 | // ID returns the unique identifier for this WebView. | |
| 824 | 852 | func (wv *WebView) ID() WebViewID { | |
| 825 | 853 | return wv.id | |
| @@ -917,49 +945,67 @@ func (wv *WebView) Stop(ctx context.Context) error { | |||
| 917 | 945 | } | |
| 918 | 946 | ||
| 919 | 947 | // GoBack navigates back in history. | |
| 920 | - | // Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility. | |
| 948 | + | // After calling WebKit's GoBack, dispatches a popstate event via JS to ensure | |
| 949 | + | // SPAs that use history.pushState receive the navigation notification. | |
| 950 | + | // WebKit's programmatic go_back() doesn't fire popstate for pushState entries. | |
| 921 | 951 | func (wv *WebView) GoBack(ctx context.Context) error { | |
| 922 | 952 | if wv.destroyed.Load() { | |
| 923 | 953 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 924 | 954 | } | |
| 925 | - | log := logging.FromContext(ctx) | |
| 926 | 955 | ||
| 927 | - | // Try WebKit native navigation first if available (for non-SPA pages) | |
| 928 | - | if wv.inner.CanGoBack() { | |
| 929 | - | wv.inner.GoBack() | |
| 930 | - | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)") | |
| 931 | - | return nil | |
| 932 | - | } | |
| 956 | + | logging.FromContext(ctx).Debug(). | |
| 957 | + | Int("webview_id", int(wv.id)). | |
| 958 | + | Bool("can_go_back", wv.inner.CanGoBack()). | |
| 959 | + | Str("current_uri", wv.inner.GetUri()). | |
| 960 | + | Msg("webview go back") | |
| 961 | + | ||
| 962 | + | wv.inner.GoBack() | |
| 963 | + | ||
| 964 | + | // Note: We don't dispatch popstate manually anymore. | |
| 965 | + | // WebKit's go_back() should handle SPA navigation correctly. | |
| 966 | + | // If issues persist, the problem is likely elsewhere. | |
| 933 | 967 | ||
| 934 | - | // Fall back to JavaScript history.back() for SPA navigation | |
| 935 | - | // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track | |
| 936 | - | wv.RunJavaScript(ctx, "history.back()", "") | |
| 937 | - | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js fallback)") | |
| 938 | 968 | return nil | |
| 939 | 969 | } | |
| 940 | 970 | ||
| 941 | 971 | // GoForward navigates forward in history. | |
| 942 | - | // Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility. | |
| 943 | 972 | func (wv *WebView) GoForward(ctx context.Context) error { | |
| 944 | 973 | if wv.destroyed.Load() { | |
| 945 | 974 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 946 | 975 | } | |
| 947 | - | log := logging.FromContext(ctx) | |
| 948 | 976 | ||
| 949 | - | // Try WebKit native navigation first if available (for non-SPA pages) | |
| 950 | - | if wv.inner.CanGoForward() { | |
| 951 | - | wv.inner.GoForward() | |
| 952 | - | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)") | |
| 953 | - | return nil | |
| 954 | - | } | |
| 977 | + | logging.FromContext(ctx).Debug(). | |
| 978 | + | Int("webview_id", int(wv.id)). | |
| 979 | + | Bool("can_go_forward", wv.inner.CanGoForward()). | |
| 980 | + | Str("current_uri", wv.inner.GetUri()). | |
| 981 | + | Msg("webview go forward") | |
| 982 | + | ||
| 983 | + | wv.inner.GoForward() | |
| 955 | 984 | ||
| 956 | - | // Fall back to JavaScript history.forward() for SPA navigation | |
| 957 | - | // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track | |
| 958 | - | wv.RunJavaScript(ctx, "history.forward()", "") | |
| 959 | - | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js fallback)") | |
| 960 | 985 | return nil | |
| 961 | 986 | } | |
| 962 | 987 | ||
| 988 | + | // GoBackDirect calls WebKit's go_back directly without any wrappers. | |
| 989 | + | // This is intended for use from gesture handlers where preserving user | |
| 990 | + | // gesture context is critical for SPA popstate handling. | |
| 991 | + | // Matches Epiphany: webkit_web_view_go_back(web_view) directly in gesture callback. | |
| 992 | + | func (wv *WebView) GoBackDirect() { | |
| 993 | + | if wv.destroyed.Load() { | |
| 994 | + | return | |
| 995 | + | } | |
| 996 | + | wv.inner.GoBack() | |
| 997 | + | } | |
| 998 | + | ||
| 999 | + | // GoForwardDirect calls WebKit's go_forward directly without any wrappers. | |
| 1000 | + | // This is intended for use from gesture handlers where preserving user | |
| 1001 | + | // gesture context is critical for SPA popstate handling. | |
| 1002 | + | func (wv *WebView) GoForwardDirect() { | |
| 1003 | + | if wv.destroyed.Load() { | |
| 1004 | + | return | |
| 1005 | + | } | |
| 1006 | + | wv.inner.GoForward() | |
| 1007 | + | } | |
| 1008 | + | ||
| 963 | 1009 | // CanGoBack returns true if back navigation is possible. | |
| 964 | 1010 | func (wv *WebView) CanGoBack() bool { | |
| 965 | 1011 | wv.mu.RLock() | |
| @@ -384,6 +384,7 @@ func (wv *WebView) connectSignals() { | |||||
| 384 | wv.connectLeaveFullscreenSignal() | 384 | wv.connectLeaveFullscreenSignal() | ||
| 385 | wv.connectAudioStateSignal() | 385 | wv.connectAudioStateSignal() | ||
| 386 | wv.connectMouseTargetChangedSignal() | 386 | wv.connectMouseTargetChangedSignal() | ||
| 387 | + | wv.connectBackForwardListChangedSignal() | |||
| 387 | } | 388 | } | ||
| 388 | 389 | ||||
| 389 | func (wv *WebView) connectLoadChangedSignal() { | 390 | func (wv *WebView) connectLoadChangedSignal() { | ||
| @@ -394,8 +395,8 @@ func (wv *WebView) connectLoadChangedSignal() { | |||||
| 394 | wv.mu.Lock() | 395 | wv.mu.Lock() | ||
| 395 | wv.uri = uri | 396 | wv.uri = uri | ||
| 396 | wv.title = title | 397 | wv.title = title | ||
| 397 | - | wv.canGoBack = inner.CanGoBack() | 398 | + | // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal |
| 398 | - | wv.canGoFwd = inner.CanGoForward() | 399 | + | // which fires for both traditional navigation and SPA history.pushState() |
| 399 | wv.progress = inner.GetEstimatedLoadProgress() | 400 | wv.progress = inner.GetEstimatedLoadProgress() | ||
| 400 | 401 | ||||
| 401 | switch event { | 402 | switch event { | ||
| @@ -820,6 +821,33 @@ func (wv *WebView) connectMouseTargetChangedSignal() { | |||||
| 820 | wv.signalIDs = append(wv.signalIDs, sigID) | 821 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 821 | } | 822 | } | ||
| 822 | 823 | ||||
| 824 | + | func (wv *WebView) connectBackForwardListChangedSignal() { | |||
| 825 | + | backForwardList := wv.inner.GetBackForwardList() | |||
| 826 | + | if backForwardList == nil { | |||
| 827 | + | wv.logger.Warn().Msg("failed to get back-forward-list") | |||
| 828 | + | return | |||
| 829 | + | } | |||
| 830 | + | ||||
| 831 | + | // The "changed" signal is emitted when the back-forward list changes, | |||
| 832 | + | // including for SPA navigation via history.pushState/replaceState. | |||
| 833 | + | // Parameters: (item_added, items_removed) - we ignore them and just refresh state. | |||
| 834 | + | changedCb := func(_ webkit.BackForwardList, _ uintptr, _ uintptr) { | |||
| 835 | + | canBack := wv.inner.CanGoBack() | |||
| 836 | + | canFwd := wv.inner.CanGoForward() | |||
| 837 | + | wv.logger.Debug(). | |||
| 838 | + | Bool("can_go_back", canBack). | |||
| 839 | + | Bool("can_go_forward", canFwd). | |||
| 840 | + | Uint("list_length", backForwardList.GetLength()). | |||
| 841 | + | Msg("back-forward-list changed") | |||
| 842 | + | wv.mu.Lock() | |||
| 843 | + | wv.canGoBack = canBack | |||
| 844 | + | wv.canGoFwd = canFwd | |||
| 845 | + | wv.mu.Unlock() | |||
| 846 | + | } | |||
| 847 | + | sigID := backForwardList.ConnectChanged(&changedCb) | |||
| 848 | + | wv.signalIDs = append(wv.signalIDs, sigID) | |||
| 849 | + | } | |||
| 850 | + | ||||
| 823 | // ID returns the unique identifier for this WebView. | 851 | // ID returns the unique identifier for this WebView. | ||
| 824 | func (wv *WebView) ID() WebViewID { | 852 | func (wv *WebView) ID() WebViewID { | ||
| 825 | return wv.id | 853 | return wv.id | ||
| @@ -917,49 +945,67 @@ func (wv *WebView) Stop(ctx context.Context) error { | |||||
| 917 | } | 945 | } | ||
| 918 | 946 | ||||
| 919 | // GoBack navigates back in history. | 947 | // GoBack navigates back in history. | ||
| 920 | - | // Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility. | 948 | + | // After calling WebKit's GoBack, dispatches a popstate event via JS to ensure |
| 949 | + | // SPAs that use history.pushState receive the navigation notification. | |||
| 950 | + | // WebKit's programmatic go_back() doesn't fire popstate for pushState entries. | |||
| 921 | func (wv *WebView) GoBack(ctx context.Context) error { | 951 | func (wv *WebView) GoBack(ctx context.Context) error { | ||
| 922 | if wv.destroyed.Load() { | 952 | if wv.destroyed.Load() { | ||
| 923 | return fmt.Errorf("webview %d is destroyed", wv.id) | 953 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 924 | } | 954 | } | ||
| 925 | - | log := logging.FromContext(ctx) | |||
| 926 | 955 | ||||
| 927 | - | // Try WebKit native navigation first if available (for non-SPA pages) | 956 | + | logging.FromContext(ctx).Debug(). |
| 928 | - | if wv.inner.CanGoBack() { | 957 | + | Int("webview_id", int(wv.id)). |
| 929 | - | wv.inner.GoBack() | 958 | + | Bool("can_go_back", wv.inner.CanGoBack()). |
| 930 | - | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)") | 959 | + | Str("current_uri", wv.inner.GetUri()). |
| 931 | - | return nil | 960 | + | Msg("webview go back") |
| 932 | - | } | 961 | + | |
| 962 | + | wv.inner.GoBack() | |||
| 963 | + | ||||
| 964 | + | // Note: We don't dispatch popstate manually anymore. | |||
| 965 | + | // WebKit's go_back() should handle SPA navigation correctly. | |||
| 966 | + | // If issues persist, the problem is likely elsewhere. | |||
| 933 | 967 | ||||
| 934 | - | // Fall back to JavaScript history.back() for SPA navigation | |||
| 935 | - | // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track | |||
| 936 | - | wv.RunJavaScript(ctx, "history.back()", "") | |||
| 937 | - | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js fallback)") | |||
| 938 | return nil | 968 | return nil | ||
| 939 | } | 969 | } | ||
| 940 | 970 | ||||
| 941 | // GoForward navigates forward in history. | 971 | // GoForward navigates forward in history. | ||
| 942 | - | // Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility. | |||
| 943 | func (wv *WebView) GoForward(ctx context.Context) error { | 972 | func (wv *WebView) GoForward(ctx context.Context) error { | ||
| 944 | if wv.destroyed.Load() { | 973 | if wv.destroyed.Load() { | ||
| 945 | return fmt.Errorf("webview %d is destroyed", wv.id) | 974 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 946 | } | 975 | } | ||
| 947 | - | log := logging.FromContext(ctx) | |||
| 948 | 976 | ||||
| 949 | - | // Try WebKit native navigation first if available (for non-SPA pages) | 977 | + | logging.FromContext(ctx).Debug(). |
| 950 | - | if wv.inner.CanGoForward() { | 978 | + | Int("webview_id", int(wv.id)). |
| 951 | - | wv.inner.GoForward() | 979 | + | Bool("can_go_forward", wv.inner.CanGoForward()). |
| 952 | - | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)") | 980 | + | Str("current_uri", wv.inner.GetUri()). |
| 953 | - | return nil | 981 | + | Msg("webview go forward") |
| 954 | - | } | 982 | + | |
| 983 | + | wv.inner.GoForward() | |||
| 955 | 984 | ||||
| 956 | - | // Fall back to JavaScript history.forward() for SPA navigation | |||
| 957 | - | // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track | |||
| 958 | - | wv.RunJavaScript(ctx, "history.forward()", "") | |||
| 959 | - | log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js fallback)") | |||
| 960 | return nil | 985 | return nil | ||
| 961 | } | 986 | } | ||
| 962 | 987 | ||||
| 988 | + | // GoBackDirect calls WebKit's go_back directly without any wrappers. | |||
| 989 | + | // This is intended for use from gesture handlers where preserving user | |||
| 990 | + | // gesture context is critical for SPA popstate handling. | |||
| 991 | + | // Matches Epiphany: webkit_web_view_go_back(web_view) directly in gesture callback. | |||
| 992 | + | func (wv *WebView) GoBackDirect() { | |||
| 993 | + | if wv.destroyed.Load() { | |||
| 994 | + | return | |||
| 995 | + | } | |||
| 996 | + | wv.inner.GoBack() | |||
| 997 | + | } | |||
| 998 | + | ||||
| 999 | + | // GoForwardDirect calls WebKit's go_forward directly without any wrappers. | |||
| 1000 | + | // This is intended for use from gesture handlers where preserving user | |||
| 1001 | + | // gesture context is critical for SPA popstate handling. | |||
| 1002 | + | func (wv *WebView) GoForwardDirect() { | |||
| 1003 | + | if wv.destroyed.Load() { | |||
| 1004 | + | return | |||
| 1005 | + | } | |||
| 1006 | + | wv.inner.GoForward() | |||
| 1007 | + | } | |||
| 1008 | + | ||||
| 963 | // CanGoBack returns true if back navigation is possible. | 1009 | // CanGoBack returns true if back navigation is possible. | ||
| 964 | func (wv *WebView) CanGoBack() bool { | 1010 | func (wv *WebView) CanGoBack() bool { | ||
| 965 | wv.mu.RLock() | 1011 | wv.mu.RLock() | ||
M internal/ui/coordinator/content.go +8 -3
| @@ -329,11 +329,16 @@ func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) | |||
| 329 | 329 | widget := c.widgetFactory.WrapWidget(>kView.Widget) | |
| 330 | 330 | ||
| 331 | 331 | // Attach gesture handler for mouse button 8/9 navigation | |
| 332 | - | if widget != nil && c.gestureActionHandler != nil { | |
| 332 | + | if widget != nil { | |
| 333 | 333 | gestureHandler := input.NewGestureHandler(ctx) | |
| 334 | - | gestureHandler.SetOnAction(c.gestureActionHandler) | |
| 334 | + | // Pass WebView directly to preserve user gesture context (like Epiphany) | |
| 335 | + | gestureHandler.SetNavigator(wv) | |
| 336 | + | // Keep callback as fallback | |
| 337 | + | if c.gestureActionHandler != nil { | |
| 338 | + | gestureHandler.SetOnAction(c.gestureActionHandler) | |
| 339 | + | } | |
| 335 | 340 | gestureHandler.AttachTo(widget.GtkWidget()) | |
| 336 | - | log.Debug().Msg("gesture handler attached to webview") | |
| 341 | + | log.Debug().Msg("gesture handler attached to webview with direct navigator") | |
| 337 | 342 | } | |
| 338 | 343 | ||
| 339 | 344 | return widget | |
| @@ -329,11 +329,16 @@ func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) | |||||
| 329 | widget := c.widgetFactory.WrapWidget(>kView.Widget) | 329 | widget := c.widgetFactory.WrapWidget(>kView.Widget) | ||
| 330 | 330 | ||||
| 331 | // Attach gesture handler for mouse button 8/9 navigation | 331 | // Attach gesture handler for mouse button 8/9 navigation | ||
| 332 | - | if widget != nil && c.gestureActionHandler != nil { | 332 | + | if widget != nil { |
| 333 | gestureHandler := input.NewGestureHandler(ctx) | 333 | gestureHandler := input.NewGestureHandler(ctx) | ||
| 334 | - | gestureHandler.SetOnAction(c.gestureActionHandler) | 334 | + | // Pass WebView directly to preserve user gesture context (like Epiphany) |
| 335 | + | gestureHandler.SetNavigator(wv) | |||
| 336 | + | // Keep callback as fallback | |||
| 337 | + | if c.gestureActionHandler != nil { | |||
| 338 | + | gestureHandler.SetOnAction(c.gestureActionHandler) | |||
| 339 | + | } | |||
| 335 | gestureHandler.AttachTo(widget.GtkWidget()) | 340 | gestureHandler.AttachTo(widget.GtkWidget()) | ||
| 336 | - | log.Debug().Msg("gesture handler attached to webview") | 341 | + | log.Debug().Msg("gesture handler attached to webview with direct navigator") |
| 337 | } | 342 | } | ||
| 338 | 343 | ||||
| 339 | return widget | 344 | return widget | ||
M internal/ui/input/gesture.go +51 -28
| @@ -8,6 +8,18 @@ import ( | |||
| 8 | 8 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | // DirectNavigator allows direct back/forward navigation on a WebView. | |
| 12 | + | // This is used to call GoBack/GoForward directly from gesture handlers | |
| 13 | + | // without going through callback chains, preserving user gesture context. | |
| 14 | + | type DirectNavigator interface { | |
| 15 | + | GoBackDirect() | |
| 16 | + | GoForwardDirect() | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // gtkEventSequenceClaimed matches GTK_EVENT_SEQUENCE_CLAIMED (value 1) | |
| 20 | + | // Used to tell GTK we've handled the gesture and to stop propagating it. | |
| 21 | + | const gtkEventSequenceClaimed = gtk.EventSequenceClaimedValue | |
| 22 | + | ||
| 11 | 23 | const ( | |
| 12 | 24 | // Mouse button constants for X11/Wayland | |
| 13 | 25 | mouseButtonBack = 8 // Side button - back | |
| @@ -22,9 +34,13 @@ type GestureHandler struct { | |||
| 22 | 34 | // Callback retention: must stay reachable by Go GC. | |
| 23 | 35 | pressedCb func(gtk.GestureClick, int, float64, float64) | |
| 24 | 36 | ||
| 25 | - | // Action handler callback | |
| 37 | + | // Action handler callback (fallback if no direct navigator) | |
| 26 | 38 | onAction ActionHandler | |
| 27 | 39 | ||
| 40 | + | // Direct navigator for calling GoBack/GoForward without callback chains. | |
| 41 | + | // This preserves user gesture context like Epiphany does. | |
| 42 | + | navigator DirectNavigator | |
| 43 | + | ||
| 28 | 44 | ctx context.Context | |
| 29 | 45 | mu sync.RWMutex | |
| 30 | 46 | } | |
| @@ -40,12 +56,22 @@ func NewGestureHandler(ctx context.Context) *GestureHandler { | |||
| 40 | 56 | } | |
| 41 | 57 | ||
| 42 | 58 | // SetOnAction sets the callback for when navigation actions are triggered. | |
| 59 | + | // This is a fallback if no DirectNavigator is set. | |
| 43 | 60 | func (h *GestureHandler) SetOnAction(fn ActionHandler) { | |
| 44 | 61 | h.mu.Lock() | |
| 45 | 62 | defer h.mu.Unlock() | |
| 46 | 63 | h.onAction = fn | |
| 47 | 64 | } | |
| 48 | 65 | ||
| 66 | + | // SetNavigator sets a direct navigator for back/forward operations. | |
| 67 | + | // When set, GoBack/GoForward are called directly without callback chains, | |
| 68 | + | // preserving user gesture context. | |
| 69 | + | func (h *GestureHandler) SetNavigator(nav DirectNavigator) { | |
| 70 | + | h.mu.Lock() | |
| 71 | + | defer h.mu.Unlock() | |
| 72 | + | h.navigator = nav | |
| 73 | + | } | |
| 74 | + | ||
| 49 | 75 | // AttachTo attaches the gesture handler to a GTK widget. | |
| 50 | 76 | // Typically called with a WebView widget to enable mouse button navigation. | |
| 51 | 77 | func (h *GestureHandler) AttachTo(widget *gtk.Widget) { | |
| @@ -88,41 +114,38 @@ func (h *GestureHandler) handlePressed(nPress int) { | |||
| 88 | 114 | // Get the button that was pressed | |
| 89 | 115 | button := h.clickGesture.GetCurrentButton() | |
| 90 | 116 | ||
| 91 | - | var action Action | |
| 117 | + | log := logging.FromContext(h.ctx) | |
| 118 | + | ||
| 119 | + | // Try direct navigation first (preserves user gesture context) | |
| 120 | + | h.mu.RLock() | |
| 121 | + | nav := h.navigator | |
| 122 | + | handler := h.onAction | |
| 123 | + | h.mu.RUnlock() | |
| 124 | + | ||
| 92 | 125 | switch button { | |
| 93 | 126 | case mouseButtonBack: | |
| 94 | - | action = ActionGoBack | |
| 127 | + | if nav != nil { | |
| 128 | + | log.Debug().Uint("button", button).Msg("gesture: direct go back") | |
| 129 | + | nav.GoBackDirect() | |
| 130 | + | } else if handler != nil { | |
| 131 | + | handler(h.ctx, ActionGoBack) | |
| 132 | + | } | |
| 95 | 133 | case mouseButtonForward: | |
| 96 | - | action = ActionGoForward | |
| 134 | + | if nav != nil { | |
| 135 | + | log.Debug().Uint("button", button).Msg("gesture: direct go forward") | |
| 136 | + | nav.GoForwardDirect() | |
| 137 | + | } else if handler != nil { | |
| 138 | + | handler(h.ctx, ActionGoForward) | |
| 139 | + | } | |
| 97 | 140 | default: | |
| 98 | 141 | // Not a navigation button, ignore | |
| 99 | 142 | return | |
| 100 | 143 | } | |
| 101 | 144 | ||
| 102 | - | // Dispatch action to handler | |
| 103 | - | h.mu.RLock() | |
| 104 | - | handler := h.onAction | |
| 105 | - | h.mu.RUnlock() | |
| 106 | - | ||
| 107 | - | if handler != nil { | |
| 108 | - | if err := handler(h.ctx, action); err != nil { | |
| 109 | - | log := logging.FromContext(h.ctx) | |
| 110 | - | // Navigation errors are expected (e.g., no back/forward history) | |
| 111 | - | if action == ActionGoBack || action == ActionGoForward { | |
| 112 | - | log.Debug(). | |
| 113 | - | Err(err). | |
| 114 | - | Str("action", string(action)). | |
| 115 | - | Uint("button", button). | |
| 116 | - | Msg("gesture navigation not available") | |
| 117 | - | } else { | |
| 118 | - | log.Error(). | |
| 119 | - | Err(err). | |
| 120 | - | Str("action", string(action)). | |
| 121 | - | Uint("button", button). | |
| 122 | - | Msg("gesture action handler error") | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | } | |
| 145 | + | // Claim the gesture sequence to stop event propagation. | |
| 146 | + | // This matches Epiphany's behavior: gtk_gesture_set_state(gesture, GTK_EVENT_SEQUENCE_CLAIMED) | |
| 147 | + | // Without this, GTK may continue propagating the event which can interfere with WebKit. | |
| 148 | + | h.clickGesture.Gesture.SetState(gtkEventSequenceClaimed) | |
| 126 | 149 | } | |
| 127 | 150 | ||
| 128 | 151 | // Detach removes the gesture handler. | |
| @@ -8,6 +8,18 @@ import ( | |||||
| 8 | "github.com/jwijenbergh/puregotk/v4/gtk" | 8 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | // DirectNavigator allows direct back/forward navigation on a WebView. | |||
| 12 | + | // This is used to call GoBack/GoForward directly from gesture handlers | |||
| 13 | + | // without going through callback chains, preserving user gesture context. | |||
| 14 | + | type DirectNavigator interface { | |||
| 15 | + | GoBackDirect() | |||
| 16 | + | GoForwardDirect() | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // gtkEventSequenceClaimed matches GTK_EVENT_SEQUENCE_CLAIMED (value 1) | |||
| 20 | + | // Used to tell GTK we've handled the gesture and to stop propagating it. | |||
| 21 | + | const gtkEventSequenceClaimed = gtk.EventSequenceClaimedValue | |||
| 22 | + | ||||
| 11 | const ( | 23 | const ( | ||
| 12 | // Mouse button constants for X11/Wayland | 24 | // Mouse button constants for X11/Wayland | ||
| 13 | mouseButtonBack = 8 // Side button - back | 25 | mouseButtonBack = 8 // Side button - back | ||
| @@ -22,9 +34,13 @@ type GestureHandler struct { | |||||
| 22 | // Callback retention: must stay reachable by Go GC. | 34 | // Callback retention: must stay reachable by Go GC. | ||
| 23 | pressedCb func(gtk.GestureClick, int, float64, float64) | 35 | pressedCb func(gtk.GestureClick, int, float64, float64) | ||
| 24 | 36 | ||||
| 25 | - | // Action handler callback | 37 | + | // Action handler callback (fallback if no direct navigator) |
| 26 | onAction ActionHandler | 38 | onAction ActionHandler | ||
| 27 | 39 | ||||
| 40 | + | // Direct navigator for calling GoBack/GoForward without callback chains. | |||
| 41 | + | // This preserves user gesture context like Epiphany does. | |||
| 42 | + | navigator DirectNavigator | |||
| 43 | + | ||||
| 28 | ctx context.Context | 44 | ctx context.Context | ||
| 29 | mu sync.RWMutex | 45 | mu sync.RWMutex | ||
| 30 | } | 46 | } | ||
| @@ -40,12 +56,22 @@ func NewGestureHandler(ctx context.Context) *GestureHandler { | |||||
| 40 | } | 56 | } | ||
| 41 | 57 | ||||
| 42 | // SetOnAction sets the callback for when navigation actions are triggered. | 58 | // SetOnAction sets the callback for when navigation actions are triggered. | ||
| 59 | + | // This is a fallback if no DirectNavigator is set. | |||
| 43 | func (h *GestureHandler) SetOnAction(fn ActionHandler) { | 60 | func (h *GestureHandler) SetOnAction(fn ActionHandler) { | ||
| 44 | h.mu.Lock() | 61 | h.mu.Lock() | ||
| 45 | defer h.mu.Unlock() | 62 | defer h.mu.Unlock() | ||
| 46 | h.onAction = fn | 63 | h.onAction = fn | ||
| 47 | } | 64 | } | ||
| 48 | 65 | ||||
| 66 | + | // SetNavigator sets a direct navigator for back/forward operations. | |||
| 67 | + | // When set, GoBack/GoForward are called directly without callback chains, | |||
| 68 | + | // preserving user gesture context. | |||
| 69 | + | func (h *GestureHandler) SetNavigator(nav DirectNavigator) { | |||
| 70 | + | h.mu.Lock() | |||
| 71 | + | defer h.mu.Unlock() | |||
| 72 | + | h.navigator = nav | |||
| 73 | + | } | |||
| 74 | + | ||||
| 49 | // AttachTo attaches the gesture handler to a GTK widget. | 75 | // AttachTo attaches the gesture handler to a GTK widget. | ||
| 50 | // Typically called with a WebView widget to enable mouse button navigation. | 76 | // Typically called with a WebView widget to enable mouse button navigation. | ||
| 51 | func (h *GestureHandler) AttachTo(widget *gtk.Widget) { | 77 | func (h *GestureHandler) AttachTo(widget *gtk.Widget) { | ||
| @@ -88,41 +114,38 @@ func (h *GestureHandler) handlePressed(nPress int) { | |||||
| 88 | // Get the button that was pressed | 114 | // Get the button that was pressed | ||
| 89 | button := h.clickGesture.GetCurrentButton() | 115 | button := h.clickGesture.GetCurrentButton() | ||
| 90 | 116 | ||||
| 91 | - | var action Action | 117 | + | log := logging.FromContext(h.ctx) |
| 118 | + | ||||
| 119 | + | // Try direct navigation first (preserves user gesture context) | |||
| 120 | + | h.mu.RLock() | |||
| 121 | + | nav := h.navigator | |||
| 122 | + | handler := h.onAction | |||
| 123 | + | h.mu.RUnlock() | |||
| 124 | + | ||||
| 92 | switch button { | 125 | switch button { | ||
| 93 | case mouseButtonBack: | 126 | case mouseButtonBack: | ||
| 94 | - | action = ActionGoBack | 127 | + | if nav != nil { |
| 128 | + | log.Debug().Uint("button", button).Msg("gesture: direct go back") | |||
| 129 | + | nav.GoBackDirect() | |||
| 130 | + | } else if handler != nil { | |||
| 131 | + | handler(h.ctx, ActionGoBack) | |||
| 132 | + | } | |||
| 95 | case mouseButtonForward: | 133 | case mouseButtonForward: | ||
| 96 | - | action = ActionGoForward | 134 | + | if nav != nil { |
| 135 | + | log.Debug().Uint("button", button).Msg("gesture: direct go forward") | |||
| 136 | + | nav.GoForwardDirect() | |||
| 137 | + | } else if handler != nil { | |||
| 138 | + | handler(h.ctx, ActionGoForward) | |||
| 139 | + | } | |||
| 97 | default: | 140 | default: | ||
| 98 | // Not a navigation button, ignore | 141 | // Not a navigation button, ignore | ||
| 99 | return | 142 | return | ||
| 100 | } | 143 | } | ||
| 101 | 144 | ||||
| 102 | - | // Dispatch action to handler | 145 | + | // Claim the gesture sequence to stop event propagation. |
| 103 | - | h.mu.RLock() | 146 | + | // This matches Epiphany's behavior: gtk_gesture_set_state(gesture, GTK_EVENT_SEQUENCE_CLAIMED) |
| 104 | - | handler := h.onAction | 147 | + | // Without this, GTK may continue propagating the event which can interfere with WebKit. |
| 105 | - | h.mu.RUnlock() | 148 | + | h.clickGesture.Gesture.SetState(gtkEventSequenceClaimed) |
| 106 | - | ||||
| 107 | - | if handler != nil { | |||
| 108 | - | if err := handler(h.ctx, action); err != nil { | |||
| 109 | - | log := logging.FromContext(h.ctx) | |||
| 110 | - | // Navigation errors are expected (e.g., no back/forward history) | |||
| 111 | - | if action == ActionGoBack || action == ActionGoForward { | |||
| 112 | - | log.Debug(). | |||
| 113 | - | Err(err). | |||
| 114 | - | Str("action", string(action)). | |||
| 115 | - | Uint("button", button). | |||
| 116 | - | Msg("gesture navigation not available") | |||
| 117 | - | } else { | |||
| 118 | - | log.Error(). | |||
| 119 | - | Err(err). | |||
| 120 | - | Str("action", string(action)). | |||
| 121 | - | Uint("button", button). | |||
| 122 | - | Msg("gesture action handler error") | |||
| 123 | - | } | |||
| 124 | - | } | |||
| 125 | - | } | |||
| 126 | } | 149 | } | ||
| 127 | 150 | ||||
| 128 | // Detach removes the gesture handler. | 151 | // Detach removes the gesture handler. | ||