v0.23.2 bfd5eb4

keyboard.go RawHistoryFolder

1package input
2
3import (
4	"context"
5	"sync"
6	"time"
7
8	"github.com/bnema/dumber/internal/infrastructure/config"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/jwijenbergh/puregotk/v4/gdk"
11	"github.com/jwijenbergh/puregotk/v4/gtk"
12	"github.com/rs/zerolog"
13)
14
15// ActionHandler is called when a keyboard action is triggered.
16// It receives the context and the action to perform.
17// Return an error if the action fails.
18type ActionHandler func(ctx context.Context, action Action) error
19
20// AccentHandler handles long-press accent detection.
21// Called for character keys that may have accent variants.
22type AccentHandler interface {
23	// OnKeyPressed is called when a key is pressed.
24	// Returns true if the accent handler is handling this key (picker visible or will show).
25	OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
26	// OnKeyReleased is called when a key is released.
27	OnKeyReleased(ctx context.Context, char rune)
28	// IsPickerVisible returns true if the accent picker is currently visible.
29	IsPickerVisible() bool
30	// Cancel cancels any pending long-press detection.
31	Cancel(ctx context.Context)
32}
33
34// KeyboardHandler processes keyboard events and dispatches actions.
35// It manages modal input modes and routes key events to the appropriate handlers.
36type KeyboardHandler struct {
37	shortcuts *ShortcutSet
38	modal     *ModalState
39	cfg       *config.Config
40
41	// Action handler callback
42	onAction ActionHandler
43	// Optional bypass check (e.g., omnibox visible)
44	shouldBypass func() bool
45	// Optional accent handler for dead keys support
46	accentHandler AccentHandler
47
48	// GTK controller (nil until attached)
49	controller *gtk.EventControllerKey
50
51	// Callback retention: must stay reachable by Go GC.
52	keyPressedCb  func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
53	keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
54
55	ctx context.Context
56	mu  sync.RWMutex
57}
58
59// NewKeyboardHandler creates a new keyboard handler.
60func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
61	log := logging.FromContext(ctx)
62
63	log.Debug().Msg("creating keyboard handler")
64
65	h := &KeyboardHandler{
66		shortcuts: NewShortcutSet(ctx, cfg),
67		modal:     NewModalState(ctx),
68		cfg:       cfg,
69		ctx:       ctx,
70	}
71
72	return h
73}
74
75// SetOnAction sets the callback for when actions are triggered.
76func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
77	h.mu.Lock()
78	defer h.mu.Unlock()
79	h.onAction = fn
80}
81
82// SetOnModeChange sets the callback for mode changes (for UI updates).
83func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
84	h.modal.SetOnModeChange(fn)
85}
86
87// SetShouldBypassInput sets a hook to bypass keyboard handling entirely.
88// When true, events propagate to focused widgets instead.
89func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
90	h.mu.Lock()
91	defer h.mu.Unlock()
92	h.shouldBypass = fn
93}
94
95// SetAccentHandler sets the handler for long-press accent detection.
96func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) {
97	h.mu.Lock()
98	defer h.mu.Unlock()
99	h.accentHandler = handler
100}
101
102// Mode returns the current input mode.
103func (h *KeyboardHandler) Mode() Mode {
104	return h.modal.Mode()
105}
106
107// AttachTo attaches the keyboard handler to a GTK window.
108// The handler will intercept key events in the capture phase.
109func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
110	log := logging.FromContext(h.ctx)
111
112	if window == nil {
113		log.Error().Msg("cannot attach keyboard handler to nil window")
114		return
115	}
116
117	h.controller = gtk.NewEventControllerKey()
118	if h.controller == nil {
119		log.Error().Msg("failed to create event controller key")
120		return
121	}
122
123	// Set capture phase to intercept events before WebView gets them
124	h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
125
126	// Connect key pressed handler (retain callback to prevent GC).
127	// The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
128	h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
129		return h.handleKeyPress(keyval, keycode, state)
130	}
131	h.controller.ConnectKeyPressed(&h.keyPressedCb)
132
133	// Connect key released handler for accent detection.
134	h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
135		h.handleKeyRelease(keyval)
136	}
137	h.controller.ConnectKeyReleased(&h.keyReleasedCb)
138
139	// Add controller to window
140	window.AddController(&h.controller.EventController)
141
142	log.Debug().Msg("keyboard handler attached to window")
143}
144
145// Detach removes the keyboard handler.
146// Note: GTK handles cleanup when the widget is destroyed,
147// but we clear our reference here.
148func (h *KeyboardHandler) Detach() {
149	h.controller = nil
150	h.keyPressedCb = nil
151	h.keyReleasedCb = nil
152}
153
154// handleKeyPress processes a key press event.
155// Returns true if the event was handled and should not propagate further.
156// Parameters:
157//   - keyval: the translated key value (depends on keyboard layout)
158//   - keycode: the hardware keycode (physical key position, layout-independent)
159//   - state: modifier keys state
160func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
161	log := logging.FromContext(h.ctx)
162
163	h.mu.RLock()
164	shouldBypass := h.shouldBypass
165	accentHandler := h.accentHandler
166	h.mu.RUnlock()
167
168	// Check if accent picker is visible - it takes priority
169	if accentHandler != nil && accentHandler.IsPickerVisible() {
170		return false // Let the accent picker handle the key via its own controller
171	}
172
173	modifiers := Modifier(state) & modifierMask
174
175	// Check accent detection first - works in both normal and bypass mode
176	// This enables accent picker for omnibox (GTK SearchEntry) as well as WebView
177	if h.tryAccentDetection(accentHandler, keyval, modifiers) {
178		return true
179	}
180
181	if shouldBypass != nil && shouldBypass() {
182		log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
183		return false
184	}
185
186	// Normalize uppercase letters for consistent binding lookup
187	keyval = normalizeKeyval(keyval)
188
189	binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
190	mode := h.modal.Mode()
191	action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
192
193	if !found {
194		return mode != ModeNormal // Consume unrecognized keys in modal mode
195	}
196
197	return h.dispatchAction(action, mode)
198}
199
200// tryAccentDetection starts long-press detection for accent-eligible keys.
201// Returns true if the key should be suppressed (blocked from reaching the WebView).
202func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
203	if h.modal.Mode() != ModeNormal || accentHandler == nil {
204		return false
205	}
206	// Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
207	if modifiers != 0 && modifiers != ModShift {
208		return false
209	}
210	if char := keyvalToRune(keyval); char != 0 {
211		return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
212	}
213	return false
214}
215
216// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
217func normalizeKeyval(keyval uint) uint {
218	if keyval >= uint('A') && keyval <= uint('Z') {
219		return keyval + (uint('a') - uint('A'))
220	}
221	return keyval
222}
223
224// dispatchAction dispatches the action and handles mode-related logic.
225func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
226	if h.handleModeAction(action) {
227		return true
228	}
229
230	h.mu.RLock()
231	handler := h.onAction
232	h.mu.RUnlock()
233
234	if handler != nil {
235		if err := handler(h.ctx, action); err != nil {
236			log := logging.FromContext(h.ctx)
237			log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
238		}
239	}
240
241	if mode == ModeResize && isResizeAction(action) {
242		h.modal.ResetTimeout(h.ctx)
243	}
244
245	if mode != ModeNormal && ShouldAutoExitMode(action) {
246		h.modal.ExitMode(h.ctx)
247	}
248
249	return true // Consumed the key
250}
251
252func (h *KeyboardHandler) lookupAction(
253	log *zerolog.Logger,
254	binding KeyBinding,
255	mode Mode,
256	modifiers Modifier,
257	keycode uint,
258) (Action, bool) {
259	action, found := h.shortcuts.Lookup(binding, mode)
260
261	// Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
262	if !found && mode == ModeNormal && modifiers == ModAlt {
263		if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
264			action = keycodeAction
265			found = true
266			if log != nil {
267				log.Debug().
268					Uint("keycode", keycode).
269					Str("action", string(action)).
270					Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
271			}
272		}
273	}
274
275	if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
276		if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
277			if modifiers == (ModAlt | ModShift) {
278				action = bracketActions.WithShift
279			} else {
280				action = bracketActions.NoShift
281			}
282			found = action != ""
283			if found && log != nil {
284				log.Debug().
285					Uint("keycode", keycode).
286					Str("action", string(action)).
287					Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
288			}
289		}
290	}
291
292	return action, found
293}
294
295func isResizeAction(action Action) bool {
296	switch action {
297	case ActionResizeIncreaseLeft,
298		ActionResizeIncreaseRight,
299		ActionResizeIncreaseUp,
300		ActionResizeIncreaseDown,
301		ActionResizeDecreaseLeft,
302		ActionResizeDecreaseRight,
303		ActionResizeDecreaseUp,
304		ActionResizeDecreaseDown,
305		ActionResizeIncrease,
306		ActionResizeDecrease:
307		return true
308	default:
309		return false
310	}
311}
312
313func (h *KeyboardHandler) handleModeAction(action Action) bool {
314	switch action {
315	case ActionEnterTabMode:
316		timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
317		h.modal.EnterTabMode(h.ctx, timeout)
318		return true
319	case ActionEnterPaneMode:
320		timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
321		h.modal.EnterPaneMode(h.ctx, timeout)
322		return true
323	case ActionEnterSessionMode:
324		timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
325		h.modal.EnterSessionMode(h.ctx, timeout)
326		return true
327	case ActionEnterResizeMode:
328		if h.modal.Mode() == ModeResize {
329			h.modal.ExitMode(h.ctx)
330			return true
331		}
332		timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
333		h.modal.EnterResizeMode(h.ctx, timeout)
334		return true
335	case ActionExitMode:
336		h.modal.ExitMode(h.ctx)
337		return true
338	default:
339		return false
340	}
341}
342
343// EnterTabMode programmatically enters tab mode.
344// Useful for testing or programmatic mode changes.
345func (h *KeyboardHandler) EnterTabMode() {
346	timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
347	h.modal.EnterTabMode(h.ctx, timeout)
348}
349
350// EnterPaneMode programmatically enters pane mode.
351// Useful for testing or programmatic mode changes.
352func (h *KeyboardHandler) EnterPaneMode() {
353	timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
354	h.modal.EnterPaneMode(h.ctx, timeout)
355}
356
357// EnterSessionMode programmatically enters session mode.
358// Useful for testing or programmatic mode changes.
359func (h *KeyboardHandler) EnterSessionMode() {
360	timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
361	h.modal.EnterSessionMode(h.ctx, timeout)
362}
363
364// ExitMode programmatically exits modal mode.
365// Useful for testing or programmatic mode changes.
366func (h *KeyboardHandler) ExitMode() {
367	h.modal.ExitMode(h.ctx)
368}
369
370// handleKeyRelease processes a key release event for accent detection.
371func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
372	h.mu.RLock()
373	accentHandler := h.accentHandler
374	h.mu.RUnlock()
375
376	if accentHandler == nil {
377		return
378	}
379
380	// Convert keyval to rune for the accent handler
381	if char := keyvalToRune(keyval); char != 0 {
382		accentHandler.OnKeyReleased(h.ctx, char)
383	}
384}
385
386// keyvalToRune converts a GTK keyval to a lowercase rune.
387// Returns 0 if the keyval is not a printable character with potential accents.
388func keyvalToRune(keyval uint) rune {
389	// Handle lowercase a-z
390	if keyval >= uint('a') && keyval <= uint('z') {
391		return rune(keyval)
392	}
393	// Handle uppercase A-Z (convert to lowercase for accent lookup)
394	if keyval >= uint('A') && keyval <= uint('Z') {
395		return rune(keyval + ('a' - 'A'))
396	}
397	return 0
398}

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