v0.20.1 d9a391f

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)
13
14// ActionHandler is called when a keyboard action is triggered.
15// It receives the context and the action to perform.
16// Return an error if the action fails.
17type ActionHandler func(ctx context.Context, action Action) error
18
19// KeyboardHandler processes keyboard events and dispatches actions.
20// It manages modal input modes and routes key events to the appropriate handlers.
21type KeyboardHandler struct {
22	shortcuts *ShortcutSet
23	modal     *ModalState
24	cfg       *config.WorkspaceConfig
25
26	// Action handler callback
27	onAction ActionHandler
28	// Optional bypass check (e.g., omnibox visible)
29	shouldBypass func() bool
30
31	// GTK controller (nil until attached)
32	controller *gtk.EventControllerKey
33
34	// Callback retention: must stay reachable by Go GC.
35	keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
36
37	ctx context.Context
38	mu  sync.RWMutex
39}
40
41// NewKeyboardHandler creates a new keyboard handler.
42func NewKeyboardHandler(
43	ctx context.Context,
44	cfg *config.WorkspaceConfig,
45) *KeyboardHandler {
46	log := logging.FromContext(ctx)
47
48	log.Debug().Msg("creating keyboard handler")
49
50	h := &KeyboardHandler{
51		shortcuts: NewShortcutSet(ctx, cfg),
52		modal:     NewModalState(ctx),
53		cfg:       cfg,
54		ctx:       ctx,
55	}
56
57	return h
58}
59
60// SetOnAction sets the callback for when actions are triggered.
61func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
62	h.mu.Lock()
63	defer h.mu.Unlock()
64	h.onAction = fn
65}
66
67// SetOnModeChange sets the callback for mode changes (for UI updates).
68func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
69	h.modal.SetOnModeChange(fn)
70}
71
72// SetShouldBypassInput sets a hook to bypass keyboard handling entirely.
73// When true, events propagate to focused widgets instead.
74func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
75	h.mu.Lock()
76	defer h.mu.Unlock()
77	h.shouldBypass = fn
78}
79
80// Mode returns the current input mode.
81func (h *KeyboardHandler) Mode() Mode {
82	return h.modal.Mode()
83}
84
85// AttachTo attaches the keyboard handler to a GTK window.
86// The handler will intercept key events in the capture phase.
87func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
88	log := logging.FromContext(h.ctx)
89
90	if window == nil {
91		log.Error().Msg("cannot attach keyboard handler to nil window")
92		return
93	}
94
95	h.controller = gtk.NewEventControllerKey()
96	if h.controller == nil {
97		log.Error().Msg("failed to create event controller key")
98		return
99	}
100
101	// Set capture phase to intercept events before WebView gets them
102	h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
103
104	// Connect key pressed handler (retain callback to prevent GC).
105	h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
106		return h.handleKeyPress(keyval, state)
107	}
108	h.controller.ConnectKeyPressed(&h.keyPressedCb)
109
110	// Add controller to window
111	window.AddController(&h.controller.EventController)
112
113	log.Debug().Msg("keyboard handler attached to window")
114}
115
116// Detach removes the keyboard handler.
117// Note: GTK handles cleanup when the widget is destroyed,
118// but we clear our reference here.
119func (h *KeyboardHandler) Detach() {
120	h.controller = nil
121	h.keyPressedCb = nil
122}
123
124// handleKeyPress processes a key press event.
125// Returns true if the event was handled and should not propagate further.
126func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
127	h.mu.RLock()
128	shouldBypass := h.shouldBypass
129	h.mu.RUnlock()
130	if shouldBypass != nil && shouldBypass() {
131		log := logging.FromContext(h.ctx)
132		log.Debug().Uint("keyval", keyval).Uint("state", uint(state)).Msg("keyboard handler bypassed")
133		return false
134	}
135
136	// Build KeyBinding from event
137	modifiers := Modifier(state) & modifierMask
138	binding := KeyBinding{
139		Keyval:    keyval,
140		Modifiers: modifiers,
141	}
142
143	mode := h.modal.Mode()
144
145	// Look up the action for this key binding
146	action, found := h.shortcuts.Lookup(binding, mode)
147
148	if !found {
149		if mode != ModeNormal {
150			// In modal mode, consume unrecognized keys to prevent WebView handling
151			return true
152		}
153		// In normal mode, let the key pass through to WebView
154		return false
155	}
156
157	// Handle special mode actions first
158	switch action {
159	case ActionEnterTabMode:
160		timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
161		h.modal.EnterTabMode(h.ctx, timeout)
162		return true
163
164	case ActionEnterPaneMode:
165		timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
166		h.modal.EnterPaneMode(h.ctx, timeout)
167		return true
168
169	case ActionExitMode:
170		h.modal.ExitMode(h.ctx)
171		return true
172	}
173
174	// Dispatch action to handler
175	h.mu.RLock()
176	handler := h.onAction
177	h.mu.RUnlock()
178
179	if handler != nil {
180		if err := handler(h.ctx, action); err != nil {
181			log := logging.FromContext(h.ctx)
182			log.Error().
183				Err(err).
184				Str("action", string(action)).
185				Msg("action handler error")
186		}
187	}
188
189	// Auto-exit modal mode after certain actions
190	if mode != ModeNormal && ShouldAutoExitMode(action) {
191		h.modal.ExitMode(h.ctx)
192	}
193
194	return true // Consumed the key
195}
196
197// EnterTabMode programmatically enters tab mode.
198// Useful for testing or programmatic mode changes.
199func (h *KeyboardHandler) EnterTabMode() {
200	timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
201	h.modal.EnterTabMode(h.ctx, timeout)
202}
203
204// EnterPaneMode programmatically enters pane mode.
205// Useful for testing or programmatic mode changes.
206func (h *KeyboardHandler) EnterPaneMode() {
207	timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
208	h.modal.EnterPaneMode(h.ctx, timeout)
209}
210
211// ExitMode programmatically exits modal mode.
212// Useful for testing or programmatic mode changes.
213func (h *KeyboardHandler) ExitMode() {
214	h.modal.ExitMode(h.ctx)
215}

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