v0.22.0 a85da87

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// KeyboardHandler processes keyboard events and dispatches actions.
21// It manages modal input modes and routes key events to the appropriate handlers.
22type KeyboardHandler struct {
23	shortcuts *ShortcutSet
24	modal     *ModalState
25	cfg       *config.Config
26
27	// Action handler callback
28	onAction ActionHandler
29	// Optional bypass check (e.g., omnibox visible)
30	shouldBypass func() bool
31
32	// GTK controller (nil until attached)
33	controller *gtk.EventControllerKey
34
35	// Callback retention: must stay reachable by Go GC.
36	keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
37
38	ctx context.Context
39	mu  sync.RWMutex
40}
41
42// NewKeyboardHandler creates a new keyboard handler.
43func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
44	log := logging.FromContext(ctx)
45
46	log.Debug().Msg("creating keyboard handler")
47
48	h := &KeyboardHandler{
49		shortcuts: NewShortcutSet(ctx, cfg),
50		modal:     NewModalState(ctx),
51		cfg:       cfg,
52		ctx:       ctx,
53	}
54
55	return h
56}
57
58// SetOnAction sets the callback for when actions are triggered.
59func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
60	h.mu.Lock()
61	defer h.mu.Unlock()
62	h.onAction = fn
63}
64
65// SetOnModeChange sets the callback for mode changes (for UI updates).
66func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
67	h.modal.SetOnModeChange(fn)
68}
69
70// SetShouldBypassInput sets a hook to bypass keyboard handling entirely.
71// When true, events propagate to focused widgets instead.
72func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
73	h.mu.Lock()
74	defer h.mu.Unlock()
75	h.shouldBypass = fn
76}
77
78// Mode returns the current input mode.
79func (h *KeyboardHandler) Mode() Mode {
80	return h.modal.Mode()
81}
82
83// AttachTo attaches the keyboard handler to a GTK window.
84// The handler will intercept key events in the capture phase.
85func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
86	log := logging.FromContext(h.ctx)
87
88	if window == nil {
89		log.Error().Msg("cannot attach keyboard handler to nil window")
90		return
91	}
92
93	h.controller = gtk.NewEventControllerKey()
94	if h.controller == nil {
95		log.Error().Msg("failed to create event controller key")
96		return
97	}
98
99	// Set capture phase to intercept events before WebView gets them
100	h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
101
102	// Connect key pressed handler (retain callback to prevent GC).
103	// The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
104	h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
105		return h.handleKeyPress(keyval, keycode, state)
106	}
107	h.controller.ConnectKeyPressed(&h.keyPressedCb)
108
109	// Add controller to window
110	window.AddController(&h.controller.EventController)
111
112	log.Debug().Msg("keyboard handler attached to window")
113}
114
115// Detach removes the keyboard handler.
116// Note: GTK handles cleanup when the widget is destroyed,
117// but we clear our reference here.
118func (h *KeyboardHandler) Detach() {
119	h.controller = nil
120	h.keyPressedCb = nil
121}
122
123// handleKeyPress processes a key press event.
124// Returns true if the event was handled and should not propagate further.
125// Parameters:
126//   - keyval: the translated key value (depends on keyboard layout)
127//   - keycode: the hardware keycode (physical key position, layout-independent)
128//   - state: modifier keys state
129func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
130	log := logging.FromContext(h.ctx)
131
132	h.mu.RLock()
133	shouldBypass := h.shouldBypass
134	h.mu.RUnlock()
135	if shouldBypass != nil && shouldBypass() {
136		log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
137		return false
138	}
139
140	// Build KeyBinding from event
141	modifiers := Modifier(state) & modifierMask
142
143	// GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M).
144	// Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase
145	// keyvals and rely on ModShift to represent Shift.
146	if keyval >= uint('A') && keyval <= uint('Z') {
147		keyval += (uint('a') - uint('A'))
148	}
149
150	binding := KeyBinding{
151		Keyval:    keyval,
152		Modifiers: modifiers,
153	}
154
155	mode := h.modal.Mode()
156
157	action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
158
159	if !found {
160		if mode != ModeNormal {
161			// In modal mode, consume unrecognized keys to prevent WebView handling
162			return true
163		}
164		// In normal mode, let the key pass through to WebView
165		return false
166	}
167
168	if h.handleModeAction(action) {
169		return true
170	}
171
172	// Dispatch action to handler
173	h.mu.RLock()
174	handler := h.onAction
175	h.mu.RUnlock()
176
177	if handler != nil {
178		if err := handler(h.ctx, action); err != nil {
179			log := logging.FromContext(h.ctx)
180			log.Error().
181				Err(err).
182				Str("action", string(action)).
183				Msg("action handler error")
184		}
185	}
186
187	// In resize mode, any resize keypress should extend the timeout.
188	if mode == ModeResize && isResizeAction(action) {
189		h.modal.ResetTimeout(h.ctx)
190	}
191
192	// Auto-exit modal mode after certain actions
193	if mode != ModeNormal && ShouldAutoExitMode(action) {
194		h.modal.ExitMode(h.ctx)
195	}
196
197	return true // Consumed the key
198}
199
200func (h *KeyboardHandler) lookupAction(
201	log *zerolog.Logger,
202	binding KeyBinding,
203	mode Mode,
204	modifiers Modifier,
205	keycode uint,
206) (Action, bool) {
207	action, found := h.shortcuts.Lookup(binding, mode)
208
209	// Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
210	if !found && mode == ModeNormal && modifiers == ModAlt {
211		if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
212			action = keycodeAction
213			found = true
214			if log != nil {
215				log.Debug().
216					Uint("keycode", keycode).
217					Str("action", string(action)).
218					Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
219			}
220		}
221	}
222
223	if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
224		if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
225			if modifiers == (ModAlt | ModShift) {
226				action = bracketActions.WithShift
227			} else {
228				action = bracketActions.NoShift
229			}
230			found = action != ""
231			if found && log != nil {
232				log.Debug().
233					Uint("keycode", keycode).
234					Str("action", string(action)).
235					Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
236			}
237		}
238	}
239
240	return action, found
241}
242
243func isResizeAction(action Action) bool {
244	switch action {
245	case ActionResizeIncreaseLeft,
246		ActionResizeIncreaseRight,
247		ActionResizeIncreaseUp,
248		ActionResizeIncreaseDown,
249		ActionResizeDecreaseLeft,
250		ActionResizeDecreaseRight,
251		ActionResizeDecreaseUp,
252		ActionResizeDecreaseDown,
253		ActionResizeIncrease,
254		ActionResizeDecrease:
255		return true
256	default:
257		return false
258	}
259}
260
261func (h *KeyboardHandler) handleModeAction(action Action) bool {
262	switch action {
263	case ActionEnterTabMode:
264		timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
265		h.modal.EnterTabMode(h.ctx, timeout)
266		return true
267	case ActionEnterPaneMode:
268		timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
269		h.modal.EnterPaneMode(h.ctx, timeout)
270		return true
271	case ActionEnterSessionMode:
272		timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
273		h.modal.EnterSessionMode(h.ctx, timeout)
274		return true
275	case ActionEnterResizeMode:
276		if h.modal.Mode() == ModeResize {
277			h.modal.ExitMode(h.ctx)
278			return true
279		}
280		timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
281		h.modal.EnterResizeMode(h.ctx, timeout)
282		return true
283	case ActionExitMode:
284		h.modal.ExitMode(h.ctx)
285		return true
286	default:
287		return false
288	}
289}
290
291// EnterTabMode programmatically enters tab mode.
292// Useful for testing or programmatic mode changes.
293func (h *KeyboardHandler) EnterTabMode() {
294	timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
295	h.modal.EnterTabMode(h.ctx, timeout)
296}
297
298// EnterPaneMode programmatically enters pane mode.
299// Useful for testing or programmatic mode changes.
300func (h *KeyboardHandler) EnterPaneMode() {
301	timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
302	h.modal.EnterPaneMode(h.ctx, timeout)
303}
304
305// EnterSessionMode programmatically enters session mode.
306// Useful for testing or programmatic mode changes.
307func (h *KeyboardHandler) EnterSessionMode() {
308	timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
309	h.modal.EnterSessionMode(h.ctx, timeout)
310}
311
312// ExitMode programmatically exits modal mode.
313// Useful for testing or programmatic mode changes.
314func (h *KeyboardHandler) ExitMode() {
315	h.modal.ExitMode(h.ctx)
316}

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