v0.27.2 5960dd8

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

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