main 5041353

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}

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