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
34 // Callback retention: must stay reachable by Go GC.
35 pressedCb func(gtk.GestureClick, int, float64, float64)
36
37 // Action handler callback (fallback if no direct navigator)
38 onAction ActionHandler
39
40 // Direct navigator for calling GoBack/GoForward without callback chains.
41 // This preserves user gesture context like Epiphany does.
42 navigator DirectNavigator
43
44 ctx context.Context
45 mu sync.RWMutex
46}
47
48// NewGestureHandler creates a new gesture handler.
49func NewGestureHandler(ctx context.Context) *GestureHandler {
50 log := logging.FromContext(ctx)
51 log.Debug().Msg("creating gesture handler")
52
53 return &GestureHandler{
54 ctx: ctx,
55 }
56}
57
58// SetOnAction sets the callback for when navigation actions are triggered.
59// This is a fallback if no DirectNavigator is set.
60func (h *GestureHandler) SetOnAction(fn ActionHandler) {
61 h.mu.Lock()
62 defer h.mu.Unlock()
63 h.onAction = fn
64}
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.
69func (h *GestureHandler) SetNavigator(nav DirectNavigator) {
70 h.mu.Lock()
71 defer h.mu.Unlock()
72 h.navigator = nav
73}
74
75// AttachTo attaches the gesture handler to a GTK widget.
76// Typically called with a WebView widget to enable mouse button navigation.
77func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
78 log := logging.FromContext(h.ctx)
79
80 if widget == nil {
81 log.Error().Msg("cannot attach gesture handler to nil widget")
82 return
83 }
84
85 // Create click gesture that listens to all buttons
86 h.clickGesture = gtk.NewGestureClick()
87 if h.clickGesture == nil {
88 log.Error().Msg("failed to create gesture click")
89 return
90 }
91
92 // Listen to all mouse buttons (not just primary)
93 h.clickGesture.SetButton(0)
94
95 // Connect pressed handler (retain callback to prevent GC)
96 h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) {
97 h.handlePressed(nPress)
98 }
99 h.clickGesture.ConnectPressed(&h.pressedCb)
100
101 // Add controller to widget
102 widget.AddController(&h.clickGesture.EventController)
103
104 log.Debug().Msg("gesture handler attached to widget")
105}
106
107// handlePressed processes a mouse button press event.
108func (h *GestureHandler) handlePressed(nPress int) {
109 // Only handle single clicks for navigation
110 if nPress != 1 {
111 return
112 }
113
114 // Get the button that was pressed
115 button := h.clickGesture.GetCurrentButton()
116
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
125 handled := false
126
127 switch button {
128 case mouseButtonBack:
129 if nav != nil {
130 log.Debug().Uint("button", button).Msg("gesture: direct go back")
131 nav.GoBackDirect()
132 handled = true
133 } else if handler != nil {
134 if err := handler(h.ctx, ActionGoBack); err != nil {
135 log.Warn().Err(err).Msg("gesture action failed")
136 }
137 handled = true
138 }
139 case mouseButtonForward:
140 if nav != nil {
141 log.Debug().Uint("button", button).Msg("gesture: direct go forward")
142 nav.GoForwardDirect()
143 handled = true
144 } else if handler != nil {
145 if err := handler(h.ctx, ActionGoForward); err != nil {
146 log.Warn().Err(err).Msg("gesture action failed")
147 }
148 handled = true
149 }
150 default:
151 // Not a navigation button, ignore
152 return
153 }
154
155 // Claim the gesture sequence to stop event propagation.
156 // This matches Epiphany's behavior: gtk_gesture_set_state(gesture, GTK_EVENT_SEQUENCE_CLAIMED)
157 // Without this, GTK may continue propagating the event which can interfere with WebKit.
158 if handled {
159 h.clickGesture.SetState(gtkEventSequenceClaimed)
160 }
161}
162
163// Detach removes the gesture handler.
164// Note: GTK handles cleanup when the widget is destroyed,
165// but we clear our reference here.
166func (h *GestureHandler) Detach() {
167 h.clickGesture = nil
168 h.pressedCb = nil
169}