gesture.go RawHistoryFolder
1package input
2
3import (
4 "context"
5 "sync"
6
7 "github.com/bnema/dumber/internal/logging"
8 "github.com/bnema/puregotk/v4/gtk"
9)
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.
14type 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.
21const gtkEventSequenceClaimed = gtk.EventSequenceClaimedValue
22
23const (
24 // Mouse button constants for X11/Wayland
25 mouseButtonBack = 8 // Side button - back
26 mouseButtonForward = 9 // Side button - forward
27)
28
29// GestureHandler handles mouse button gestures for navigation.
30// It recognizes mouse buttons 8 (back) and 9 (forward) on WebView widgets.
31type GestureHandler struct {
32 clickGesture *gtk.GestureClick
33 widget *gtk.Widget
34 pressedHandlerID uint
35 destroyHandlerID uint
36
37 // Callback retention: must stay reachable by Go GC.
38 pressedCb func(gtk.GestureClick, int, float64, float64)
39 destroyCb func(gtk.Widget)
40
41 // Action handler callback (fallback if no direct navigator)
42 onAction ActionHandler
43
44 // Direct navigator for calling GoBack/GoForward without callback chains.
45 // This preserves user gesture context like Epiphany does.
46 navigator DirectNavigator
47
48 ctx context.Context
49 mu sync.RWMutex
50}
51
52// NewGestureHandler creates a new gesture handler.
53func NewGestureHandler(ctx context.Context) *GestureHandler {
54 log := logging.FromContext(ctx)
55 log.Debug().Msg("creating gesture handler")
56
57 return &GestureHandler{
58 ctx: ctx,
59 }
60}
61
62// SetOnAction sets the callback for when navigation actions are triggered.
63// This is a fallback if no DirectNavigator is set.
64func (h *GestureHandler) SetOnAction(fn ActionHandler) {
65 h.mu.Lock()
66 defer h.mu.Unlock()
67 h.onAction = fn
68}
69
70// SetNavigator sets a direct navigator for back/forward operations.
71// When set, GoBack/GoForward are called directly without callback chains,
72// preserving user gesture context.
73func (h *GestureHandler) SetNavigator(nav DirectNavigator) {
74 h.mu.Lock()
75 defer h.mu.Unlock()
76 h.navigator = nav
77}
78
79// AttachTo attaches the gesture handler to a GTK widget.
80// Typically called with a WebView widget to enable mouse button navigation.
81func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
82 log := logging.FromContext(h.ctx)
83
84 if widget == nil {
85 log.Error().Msg("cannot attach gesture handler to nil widget")
86 return
87 }
88 h.detachExistingAttachment()
89
90 // Create click gesture that listens to all buttons
91 h.clickGesture = gtk.NewGestureClick()
92 if h.clickGesture == nil {
93 log.Error().Msg("failed to create gesture click")
94 return
95 }
96
97 // Listen to all mouse buttons (not just primary)
98 h.clickGesture.SetButton(0)
99
100 // Connect pressed handler (retain callback to prevent GC)
101 h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) {
102 h.handlePressed(nPress)
103 }
104 h.pressedHandlerID = h.clickGesture.ConnectPressed(&h.pressedCb)
105 h.widget = widget
106 h.destroyCb = func(_ gtk.Widget) {
107 h.DetachForDestroy()
108 }
109 h.destroyHandlerID = widget.ConnectDestroy(&h.destroyCb)
110
111 // Add controller to widget
112 widget.AddController(&h.clickGesture.EventController)
113
114 log.Debug().Msg("gesture handler attached to widget")
115}
116
117// handlePressed processes a mouse button press event.
118func (h *GestureHandler) handlePressed(nPress int) {
119 // Only handle single clicks for navigation
120 if nPress != 1 {
121 return
122 }
123
124 // Get the button that was pressed
125 button := h.clickGesture.GetCurrentButton()
126
127 log := logging.FromContext(h.ctx)
128
129 // Try direct navigation first (preserves user gesture context)
130 h.mu.RLock()
131 nav := h.navigator
132 handler := h.onAction
133 h.mu.RUnlock()
134
135 handled := false
136
137 switch button {
138 case mouseButtonBack:
139 if nav != nil {
140 log.Debug().Uint("button", button).Msg("gesture: direct go back")
141 nav.GoBackDirect()
142 handled = true
143 } else if handler != nil {
144 if err := handler(h.ctx, ActionGoBack); err != nil {
145 log.Warn().Err(err).Msg("gesture action failed")
146 }
147 handled = true
148 }
149 case mouseButtonForward:
150 if nav != nil {
151 log.Debug().Uint("button", button).Msg("gesture: direct go forward")
152 nav.GoForwardDirect()
153 handled = true
154 } else if handler != nil {
155 if err := handler(h.ctx, ActionGoForward); err != nil {
156 log.Warn().Err(err).Msg("gesture action failed")
157 }
158 handled = true
159 }
160 default:
161 // Not a navigation button, ignore
162 return
163 }
164
165 // Claim the gesture sequence to stop event propagation.
166 // This matches Epiphany's behavior: gtk_gesture_set_state(gesture, GTK_EVENT_SEQUENCE_CLAIMED)
167 // Without this, GTK may continue propagating the event which can interfere with WebKit.
168 if handled {
169 h.clickGesture.SetState(gtkEventSequenceClaimed)
170 }
171}
172
173func (h *GestureHandler) detachExistingAttachment() {
174 if h == nil {
175 return
176 }
177 h.mu.RLock()
178 attached := h.widget != nil || h.clickGesture != nil || h.pressedHandlerID != 0 || h.destroyHandlerID != 0
179 h.mu.RUnlock()
180 if attached {
181 h.Detach()
182 }
183}
184
185// Detach removes the gesture handler from a live widget and disconnects its
186// GTK signals.
187func (h *GestureHandler) Detach() {
188 h.detach(true)
189}
190
191// DetachForDestroy disconnects retained GTK callbacks during widget teardown
192// without mutating the widget's controller list while GTK is destroying it.
193func (h *GestureHandler) DetachForDestroy() {
194 h.detach(false)
195}
196
197func (h *GestureHandler) detach(removeController bool) {
198 if h == nil {
199 return
200 }
201 h.mu.Lock()
202 widget := h.widget
203 clickGesture := h.clickGesture
204 pressedHandlerID := h.pressedHandlerID
205 destroyHandlerID := h.destroyHandlerID
206
207 h.widget = nil
208 h.clickGesture = nil
209 h.pressedHandlerID = 0
210 h.destroyHandlerID = 0
211 h.pressedCb = nil
212 h.destroyCb = nil
213 h.onAction = nil
214 h.navigator = nil
215 h.mu.Unlock()
216
217 if clickGesture != nil && pressedHandlerID != 0 {
218 clickGesture.DisconnectSignal(pressedHandlerID)
219 }
220 if widget != nil && destroyHandlerID != 0 {
221 widget.DisconnectSignal(destroyHandlerID)
222 }
223 if removeController && widget != nil && clickGesture != nil {
224 widget.RemoveController(&clickGesture.EventController)
225 }
226}