v0.21.0 73bf644

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	binding := KeyBinding{
143		Keyval:    keyval,
144		Modifiers: modifiers,
145	}
146
147	mode := h.modal.Mode()
148
149	action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
150
151	if !found {
152		if mode != ModeNormal {
153			// In modal mode, consume unrecognized keys to prevent WebView handling
154			return true
155		}
156		// In normal mode, let the key pass through to WebView
157		return false
158	}
159
160	if h.handleModeAction(action) {
161		return true
162	}
163
164	// Dispatch action to handler
165	h.mu.RLock()
166	handler := h.onAction
167	h.mu.RUnlock()
168
169	if handler != nil {
170		if err := handler(h.ctx, action); err != nil {
171			log := logging.FromContext(h.ctx)
172			log.Error().
173				Err(err).
174				Str("action", string(action)).
175				Msg("action handler error")
176		}
177	}
178
179	// In resize mode, any resize keypress should extend the timeout.
180	if mode == ModeResize && isResizeAction(action) {
181		h.modal.ResetTimeout(h.ctx)
182	}
183
184	// Auto-exit modal mode after certain actions
185	if mode != ModeNormal && ShouldAutoExitMode(action) {
186		h.modal.ExitMode(h.ctx)
187	}
188
189	return true // Consumed the key
190}
191
192func (h *KeyboardHandler) lookupAction(
193	log *zerolog.Logger,
194	binding KeyBinding,
195	mode Mode,
196	modifiers Modifier,
197	keycode uint,
198) (Action, bool) {
199	action, found := h.shortcuts.Lookup(binding, mode)
200
201	// Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
202	if !found && mode == ModeNormal && modifiers == ModAlt {
203		if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
204			action = keycodeAction
205			found = true
206			if log != nil {
207				log.Debug().
208					Uint("keycode", keycode).
209					Str("action", string(action)).
210					Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
211			}
212		}
213	}
214
215	if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
216		if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
217			if modifiers == (ModAlt | ModShift) {
218				action = bracketActions.WithShift
219			} else {
220				action = bracketActions.NoShift
221			}
222			found = action != ""
223			if found && log != nil {
224				log.Debug().
225					Uint("keycode", keycode).
226					Str("action", string(action)).
227					Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
228			}
229		}
230	}
231
232	return action, found
233}
234
235func isResizeAction(action Action) bool {
236	switch action {
237	case ActionResizeIncreaseLeft,
238		ActionResizeIncreaseRight,
239		ActionResizeIncreaseUp,
240		ActionResizeIncreaseDown,
241		ActionResizeDecreaseLeft,
242		ActionResizeDecreaseRight,
243		ActionResizeDecreaseUp,
244		ActionResizeDecreaseDown,
245		ActionResizeIncrease,
246		ActionResizeDecrease:
247		return true
248	default:
249		return false
250	}
251}
252
253func (h *KeyboardHandler) handleModeAction(action Action) bool {
254	switch action {
255	case ActionEnterTabMode:
256		timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
257		h.modal.EnterTabMode(h.ctx, timeout)
258		return true
259	case ActionEnterPaneMode:
260		timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
261		h.modal.EnterPaneMode(h.ctx, timeout)
262		return true
263	case ActionEnterSessionMode:
264		timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
265		h.modal.EnterSessionMode(h.ctx, timeout)
266		return true
267	case ActionEnterResizeMode:
268		if h.modal.Mode() == ModeResize {
269			h.modal.ExitMode(h.ctx)
270			return true
271		}
272		timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
273		h.modal.EnterResizeMode(h.ctx, timeout)
274		return true
275	case ActionExitMode:
276		h.modal.ExitMode(h.ctx)
277		return true
278	default:
279		return false
280	}
281}
282
283// EnterTabMode programmatically enters tab mode.
284// Useful for testing or programmatic mode changes.
285func (h *KeyboardHandler) EnterTabMode() {
286	timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
287	h.modal.EnterTabMode(h.ctx, timeout)
288}
289
290// EnterPaneMode programmatically enters pane mode.
291// Useful for testing or programmatic mode changes.
292func (h *KeyboardHandler) EnterPaneMode() {
293	timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
294	h.modal.EnterPaneMode(h.ctx, timeout)
295}
296
297// EnterSessionMode programmatically enters session mode.
298// Useful for testing or programmatic mode changes.
299func (h *KeyboardHandler) EnterSessionMode() {
300	timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
301	h.modal.EnterSessionMode(h.ctx, timeout)
302}
303
304// ExitMode programmatically exits modal mode.
305// Useful for testing or programmatic mode changes.
306func (h *KeyboardHandler) ExitMode() {
307	h.modal.ExitMode(h.ctx)
308}

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