detached fe663f6

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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() {
384384 wv.connectLeaveFullscreenSignal()
385385 wv.connectAudioStateSignal()
386386 wv.connectMouseTargetChangedSignal()
387+ wv.connectBackForwardListChangedSignal()
387388 }
388389
389390 func (wv *WebView) connectLoadChangedSignal() {
@@ -394,8 +395,8 @@ func (wv *WebView) connectLoadChangedSignal() {
394395 wv.mu.Lock()
395396 wv.uri = uri
396397 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()
399400 wv.progress = inner.GetEstimatedLoadProgress()
400401
401402 switch event {
@@ -820,6 +821,33 @@ func (wv *WebView) connectMouseTargetChangedSignal() {
820821 wv.signalIDs = append(wv.signalIDs, sigID)
821822 }
822823
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+
823851 // ID returns the unique identifier for this WebView.
824852 func (wv *WebView) ID() WebViewID {
825853 return wv.id
@@ -917,49 +945,67 @@ func (wv *WebView) Stop(ctx context.Context) error {
917945 }
918946
919947 // 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.
921951 func (wv *WebView) GoBack(ctx context.Context) error {
922952 if wv.destroyed.Load() {
923953 return fmt.Errorf("webview %d is destroyed", wv.id)
924954 }
925- log := logging.FromContext(ctx)
926955
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.
933967
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)")
938968 return nil
939969 }
940970
941971 // GoForward navigates forward in history.
942-// Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility.
943972 func (wv *WebView) GoForward(ctx context.Context) error {
944973 if wv.destroyed.Load() {
945974 return fmt.Errorf("webview %d is destroyed", wv.id)
946975 }
947- log := logging.FromContext(ctx)
948976
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()
955984
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)")
960985 return nil
961986 }
962987
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+
9631009 // CanGoBack returns true if back navigation is possible.
9641010 func (wv *WebView) CanGoBack() bool {
9651011 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 = uri396 wv.uri = uri
396 wv.title = title397 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.id853 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 nil960+ 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 nil968 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 nil981+ 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 nil985 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)
329329 widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
330330
331331 // Attach gesture handler for mouse button 8/9 navigation
332- if widget != nil && c.gestureActionHandler != nil {
332+ if widget != nil {
333333 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+ }
335340 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")
337342 }
338343
339344 return widget
@@ -329,11 +329,16 @@ func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView)
329 widget := c.widgetFactory.WrapWidget(&gtkView.Widget)329 widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
330 330
331 // Attach gesture handler for mouse button 8/9 navigation331 // 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 widget344 return widget
M internal/ui/input/gesture.go +51 -28
@@ -8,6 +8,18 @@ import (
88 "github.com/jwijenbergh/puregotk/v4/gtk"
99 )
1010
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+
1123 const (
1224 // Mouse button constants for X11/Wayland
1325 mouseButtonBack = 8 // Side button - back
@@ -22,9 +34,13 @@ type GestureHandler struct {
2234 // Callback retention: must stay reachable by Go GC.
2335 pressedCb func(gtk.GestureClick, int, float64, float64)
2436
25- // Action handler callback
37+ // Action handler callback (fallback if no direct navigator)
2638 onAction ActionHandler
2739
40+ // Direct navigator for calling GoBack/GoForward without callback chains.
41+ // This preserves user gesture context like Epiphany does.
42+ navigator DirectNavigator
43+
2844 ctx context.Context
2945 mu sync.RWMutex
3046 }
@@ -40,12 +56,22 @@ func NewGestureHandler(ctx context.Context) *GestureHandler {
4056 }
4157
4258 // SetOnAction sets the callback for when navigation actions are triggered.
59+// This is a fallback if no DirectNavigator is set.
4360 func (h *GestureHandler) SetOnAction(fn ActionHandler) {
4461 h.mu.Lock()
4562 defer h.mu.Unlock()
4663 h.onAction = fn
4764 }
4865
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+
4975 // AttachTo attaches the gesture handler to a GTK widget.
5076 // Typically called with a WebView widget to enable mouse button navigation.
5177 func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
@@ -88,41 +114,38 @@ func (h *GestureHandler) handlePressed(nPress int) {
88114 // Get the button that was pressed
89115 button := h.clickGesture.GetCurrentButton()
90116
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+
92125 switch button {
93126 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+ }
95133 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+ }
97140 default:
98141 // Not a navigation button, ignore
99142 return
100143 }
101144
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)
126149 }
127150
128151 // 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/Wayland24 // Mouse button constants for X11/Wayland
13 mouseButtonBack = 8 // Side button - back25 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 callback37+ // Action handler callback (fallback if no direct navigator)
26 onAction ActionHandler38 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.Context44 ctx context.Context
29 mu sync.RWMutex45 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 = fn63 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 pressed114 // Get the button that was pressed
89 button := h.clickGesture.GetCurrentButton()115 button := h.clickGesture.GetCurrentButton()
90 116
91- var action Action117+ 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 = ActionGoBack127+ 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 = ActionGoForward134+ 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, ignore141 // Not a navigation button, ignore
99 return142 return
100 }143 }
101 144
102- // Dispatch action to handler145+ // 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.onAction147+ // 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close