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