v0.27.0 dcb4c51

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

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