v0.28.0 3cb3770

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/gtk"
11)
12
13const (
14	// HoverFocusDelay is the delay before switching focus on hover.
15	HoverFocusDelay = 150 * time.Millisecond
16)
17
18// HoverCallback is called when a pane should receive focus from hover.
19type HoverCallback func(paneID entity.PaneID)
20
21// MotionCallback is called when intentional mouse movement is detected.
22type MotionCallback func()
23
24// HoverHandler handles mouse hover events for focus-follows-mouse behavior.
25// It uses a debounce timer to avoid rapid focus switches.
26type HoverHandler struct {
27	motionCtrl *gtk.EventControllerMotion
28	paneID     entity.PaneID
29	onEnter    HoverCallback
30	onMotion   MotionCallback
31
32	// Callback retention: must stay reachable by Go GC.
33	enterCb  func(gtk.EventControllerMotion, float64, float64)
34	leaveCb  func(gtk.EventControllerMotion)
35	motionCb func(gtk.EventControllerMotion, float64, float64)
36
37	timer    *time.Timer
38	timerMu  sync.Mutex
39	canceled bool
40
41	ctx context.Context
42}
43
44// NewHoverHandler creates a new hover handler for a specific pane.
45func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
46	log := logging.FromContext(ctx)
47	log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
48
49	return &HoverHandler{
50		ctx:    ctx,
51		paneID: paneID,
52	}
53}
54
55// SetOnEnter sets the callback for when the pane should receive focus.
56func (h *HoverHandler) SetOnEnter(fn HoverCallback) {
57	h.onEnter = fn
58}
59
60// SetOnMotion sets the callback for intentional mouse movement.
61// This fires on actual mouse motion within the pane, not on synthetic
62// enter/leave events from GTK widget rearrangement.
63func (h *HoverHandler) SetOnMotion(fn MotionCallback) {
64	h.onMotion = fn
65}
66
67// AttachTo attaches the hover handler to a GTK widget.
68func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
69	log := logging.FromContext(h.ctx)
70
71	if widget == nil {
72		log.Error().Msg("cannot attach hover handler to nil widget")
73		return
74	}
75
76	h.motionCtrl = gtk.NewEventControllerMotion()
77	if h.motionCtrl == nil {
78		log.Error().Msg("failed to create motion controller")
79		return
80	}
81
82	// Connect enter handler with debounce (retain callbacks to prevent GC)
83	h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
84		h.handleEnter()
85	}
86	h.motionCtrl.ConnectEnter(&h.enterCb)
87
88	// Connect leave handler to cancel pending focus
89	h.leaveCb = func(_ gtk.EventControllerMotion) {
90		h.handleLeave()
91	}
92	h.motionCtrl.ConnectLeave(&h.leaveCb)
93
94	// Connect motion handler to detect intentional mouse movement.
95	// Unlike enter/leave, motion only fires when the cursor physically moves
96	// inside the widget — not on synthetic events from widget rearrangement.
97	h.motionCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
98		if h.onMotion != nil {
99			h.onMotion()
100		}
101	}
102	h.motionCtrl.ConnectMotion(&h.motionCb)
103
104	// Add controller to widget
105	widget.AddController(&h.motionCtrl.EventController)
106
107	log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")
108}
109
110// handleEnter processes the mouse enter event with debouncing.
111func (h *HoverHandler) handleEnter() {
112	h.timerMu.Lock()
113	defer h.timerMu.Unlock()
114
115	// Cancel any existing timer
116	if h.timer != nil {
117		h.timer.Stop()
118	}
119
120	h.canceled = false
121
122	// Start new debounce timer
123	h.timer = time.AfterFunc(HoverFocusDelay, func() {
124		h.timerMu.Lock()
125		canceled := h.canceled
126		h.timerMu.Unlock()
127
128		if canceled {
129			return
130		}
131
132		if h.onEnter != nil {
133			h.onEnter(h.paneID)
134		}
135	})
136}
137
138// handleLeave cancels any pending focus switch.
139func (h *HoverHandler) handleLeave() {
140	h.Cancel()
141}
142
143// Cancel cancels any pending focus switch.
144// This can be called externally to cancel hover when keyboard navigation occurs.
145func (h *HoverHandler) Cancel() {
146	h.timerMu.Lock()
147	defer h.timerMu.Unlock()
148
149	h.canceled = true
150	if h.timer != nil {
151		h.timer.Stop()
152		h.timer = nil
153	}
154}
155
156// Detach removes the hover handler.
157func (h *HoverHandler) Detach() {
158	h.timerMu.Lock()
159	if h.timer != nil {
160		h.timer.Stop()
161		h.timer = nil
162	}
163	h.timerMu.Unlock()
164
165	h.motionCtrl = nil
166	h.enterCb = nil
167	h.leaveCb = nil
168	h.motionCb = nil
169}

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