main 5041353

hover.go RawHistoryFolder

1package input
2
3import (
4	"context"
5	"sync"
6	"time"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/bnema/puregotk/v4/glib"
11	"github.com/bnema/puregotk/v4/gobject"
12	"github.com/bnema/puregotk/v4/gtk"
13)
14
15const (
16	// HoverFocusDelay is the delay before switching focus on hover.
17	HoverFocusDelay = 150 * time.Millisecond
18)
19
20// HoverCallback is called when a pane should receive focus from hover.
21type HoverCallback func(paneID entity.PaneID)
22
23// MotionCallback is called when intentional mouse movement is detected.
24type MotionCallback func()
25
26// HoverHandler handles mouse hover events for focus-follows-mouse behavior.
27// It uses a debounce timer to avoid rapid focus switches.
28type HoverHandler struct {
29	attachedWidget *gtk.Widget
30	motionCtrl     *gtk.EventControllerMotion
31	enterSignalID  uint
32	leaveSignalID  uint
33	motionSignalID uint
34	paneID         entity.PaneID
35	onEnter        HoverCallback
36	onMotion       MotionCallback
37
38	// Callback retention: must stay reachable by Go GC.
39	enterCb  func(gtk.EventControllerMotion, float64, float64)
40	leaveCb  func(gtk.EventControllerMotion)
41	motionCb func(gtk.EventControllerMotion, float64, float64)
42
43	timerMu          sync.Mutex
44	sourceID         uint
45	generation       uint64
46	detached         bool
47	schedule         func(uint, func(uintptr) bool) uint
48	removeSource     func(uint) bool
49	disconnectSignal func(*gtk.EventControllerMotion, uint)
50	removeController func(*gtk.Widget, *gtk.EventController)
51
52	ctx context.Context
53}
54
55// NewHoverHandler creates a new hover handler for a specific pane.
56func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
57	log := logging.FromContext(ctx)
58	log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
59
60	return &HoverHandler{
61		ctx:              ctx,
62		paneID:           paneID,
63		schedule:         scheduleHoverSource,
64		removeSource:     glib.SourceRemove,
65		disconnectSignal: disconnectHoverSignal,
66		removeController: removeHoverController,
67	}
68}
69
70// SetOnEnter sets the callback for when the pane should receive focus.
71func (h *HoverHandler) SetOnEnter(fn HoverCallback) {
72	h.onEnter = fn
73}
74
75// SetOnMotion sets the callback for intentional mouse movement.
76// This fires on actual mouse motion within the pane, not on synthetic
77// enter/leave events from GTK widget rearrangement.
78func (h *HoverHandler) SetOnMotion(fn MotionCallback) {
79	h.onMotion = fn
80}
81
82// AttachTo attaches the hover handler to a GTK widget.
83func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
84	log := logging.FromContext(h.ctx)
85
86	if widget == nil {
87		log.Error().Msg("cannot attach hover handler to nil widget")
88		return
89	}
90
91	// Reattachment must not leave a controller owned by the previous widget.
92	if h.motionCtrl != nil || h.attachedWidget != nil {
93		h.Detach()
94	}
95	h.timerMu.Lock()
96	h.detached = false
97	h.attachedWidget = widget
98	h.timerMu.Unlock()
99
100	h.motionCtrl = gtk.NewEventControllerMotion()
101	if h.motionCtrl == nil {
102		log.Error().Msg("failed to create motion controller")
103		return
104	}
105
106	// Connect enter handler with debounce (retain callbacks to prevent GC)
107	h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
108		h.handleEnter()
109	}
110	h.enterSignalID = h.motionCtrl.ConnectEnter(&h.enterCb)
111
112	// Connect leave handler to cancel pending focus
113	h.leaveCb = func(_ gtk.EventControllerMotion) {
114		h.handleLeave()
115	}
116	h.leaveSignalID = h.motionCtrl.ConnectLeave(&h.leaveCb)
117
118	// Connect motion handler to detect intentional mouse movement.
119	// Unlike enter/leave, motion only fires when the cursor physically moves
120	// inside the widget — not on synthetic events from widget rearrangement.
121	h.motionCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
122		if h.onMotion != nil {
123			h.onMotion()
124		}
125	}
126	h.motionSignalID = h.motionCtrl.ConnectMotion(&h.motionCb)
127
128	// Add controller to widget
129	widget.AddController(&h.motionCtrl.EventController)
130
131	log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")
132}
133
134// scheduleHoverSource schedules a one-shot callback on GTK's owning GLib main
135// context. GTK state must never be touched by a Go timer goroutine.
136func scheduleHoverSource(delay uint, fn func(uintptr) bool) uint {
137	cb := glib.SourceFunc(fn)
138	return glib.TimeoutAdd(delay, &cb, 0)
139}
140
141// handleEnter processes the mouse enter event with debouncing.
142func (h *HoverHandler) handleEnter() {
143	h.timerMu.Lock()
144	if h.detached {
145		h.timerMu.Unlock()
146		return
147	}
148	h.cancelSourceLocked()
149	h.generation++
150	generation := h.generation
151	schedule := h.schedule
152	h.sourceID = schedule(uint(HoverFocusDelay.Milliseconds()), func(_ uintptr) bool {
153		h.timerMu.Lock()
154		if h.detached || h.generation != generation {
155			h.timerMu.Unlock()
156			return false
157		}
158		h.sourceID = 0
159		callback := h.onEnter
160		paneID := h.paneID
161		h.timerMu.Unlock()
162
163		if callback != nil {
164			callback(paneID)
165		}
166		return false
167	})
168	h.timerMu.Unlock()
169}
170
171func (h *HoverHandler) cancelSourceLocked() {
172	if h.sourceID == 0 {
173		return
174	}
175	h.removeSource(h.sourceID)
176	h.sourceID = 0
177}
178
179// handleLeave cancels any pending focus switch.
180func (h *HoverHandler) handleLeave() {
181	h.Cancel()
182}
183
184// Cancel cancels any pending focus switch.
185// This can be called externally to cancel hover when keyboard navigation occurs.
186func (h *HoverHandler) Cancel() {
187	h.timerMu.Lock()
188	defer h.timerMu.Unlock()
189
190	h.generation++
191	h.cancelSourceLocked()
192}
193
194// Detach removes the hover handler and invalidates callbacks already queued on
195// the GLib main context. The generation check is required because removing a
196// source can race with dispatch.
197func (h *HoverHandler) Detach() {
198	h.timerMu.Lock()
199	h.detached = true
200	h.generation++
201	h.cancelSourceLocked()
202	h.onEnter = nil
203	h.onMotion = nil
204	widget := h.attachedWidget
205	controller := h.motionCtrl
206	enterID := h.enterSignalID
207	leaveID := h.leaveSignalID
208	motionID := h.motionSignalID
209	h.attachedWidget = nil
210	h.motionCtrl = nil
211	h.enterSignalID = 0
212	h.leaveSignalID = 0
213	h.motionSignalID = 0
214	h.enterCb = nil
215	h.leaveCb = nil
216	h.motionCb = nil
217	disconnectSignal := h.disconnectSignal
218	removeController := h.removeController
219	h.timerMu.Unlock()
220
221	if controller == nil {
222		return
223	}
224	if disconnectSignal != nil {
225		for _, id := range []uint{enterID, leaveID, motionID} {
226			if id != 0 {
227				disconnectSignal(controller, id)
228			}
229		}
230	}
231	if widget != nil && removeController != nil {
232		removeController(widget, &controller.EventController)
233	}
234}
235
236func disconnectHoverSignal(controller *gtk.EventControllerMotion, id uint) {
237	if controller == nil || id == 0 || controller.GoPointer() == 0 {
238		return
239	}
240	gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), id)
241}
242
243func removeHoverController(widget *gtk.Widget, controller *gtk.EventController) {
244	if widget != nil && controller != nil {
245		widget.RemoveController(controller)
246	}
247}

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