1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5 "context"
6 "strings"
7
8 "github.com/bnema/dumber/internal/infrastructure/config"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/jwijenbergh/puregotk/v4/gdk"
11)
12
13// Modifier represents keyboard modifier flags.
14type Modifier uint
15
16const (
17 // ModNone indicates no modifier is pressed.
18 ModNone Modifier = 0
19 // ModShift indicates the Shift key is pressed.
20 ModShift Modifier = Modifier(gdk.ShiftMaskValue)
21 // ModCtrl indicates the Control key is pressed.
22 ModCtrl Modifier = Modifier(gdk.ControlMaskValue)
23 // ModAlt indicates the Alt key is pressed.
24 ModAlt Modifier = Modifier(gdk.AltMaskValue)
25)
26
27// modifierMask filters out non-standard modifiers from GDK state.
28const modifierMask = ModCtrl | ModShift | ModAlt
29
30var keyvalByName = map[string]uint{
31 "escape": uint(gdk.KEY_Escape),
32 "esc": uint(gdk.KEY_Escape),
33 "return": uint(gdk.KEY_Return),
34 "enter": uint(gdk.KEY_Return),
35 "tab": uint(gdk.KEY_Tab),
36 "space": uint(gdk.KEY_space),
37 "backspace": uint(gdk.KEY_BackSpace),
38 "delete": uint(gdk.KEY_Delete),
39 "del": uint(gdk.KEY_Delete),
40 "home": uint(gdk.KEY_Home),
41 "end": uint(gdk.KEY_End),
42 "pageup": uint(gdk.KEY_Page_Up),
43 "page_up": uint(gdk.KEY_Page_Up),
44 "pagedown": uint(gdk.KEY_Page_Down),
45 "page_down": uint(gdk.KEY_Page_Down),
46 "left": uint(gdk.KEY_Left),
47 "arrowleft": uint(gdk.KEY_Left),
48 "right": uint(gdk.KEY_Right),
49 "arrowright": uint(gdk.KEY_Right),
50 "up": uint(gdk.KEY_Up),
51 "arrowup": uint(gdk.KEY_Up),
52 "down": uint(gdk.KEY_Down),
53 "arrowdown": uint(gdk.KEY_Down),
54 "f1": uint(gdk.KEY_F1),
55 "f2": uint(gdk.KEY_F2),
56 "f3": uint(gdk.KEY_F3),
57 "f4": uint(gdk.KEY_F4),
58 "f5": uint(gdk.KEY_F5),
59 "f6": uint(gdk.KEY_F6),
60 "f7": uint(gdk.KEY_F7),
61 "f8": uint(gdk.KEY_F8),
62 "f9": uint(gdk.KEY_F9),
63 "f10": uint(gdk.KEY_F10),
64 "f11": uint(gdk.KEY_F11),
65 "f12": uint(gdk.KEY_F12),
66 "plus": uint(gdk.KEY_plus),
67 "+": uint(gdk.KEY_plus),
68 "minus": uint(gdk.KEY_minus),
69 "-": uint(gdk.KEY_minus),
70 "equal": uint(gdk.KEY_equal),
71 "=": uint(gdk.KEY_equal),
72 "0": uint(gdk.KEY_0),
73 "1": uint(gdk.KEY_1),
74 "2": uint(gdk.KEY_2),
75 "3": uint(gdk.KEY_3),
76 "4": uint(gdk.KEY_4),
77 "5": uint(gdk.KEY_5),
78 "6": uint(gdk.KEY_6),
79 "7": uint(gdk.KEY_7),
80 "8": uint(gdk.KEY_8),
81 "9": uint(gdk.KEY_9),
82}
83
84// KeyBinding represents a single key combination.
85type KeyBinding struct {
86 Keyval uint // GDK key value (e.g., gdk.KEY_t)
87 Modifiers Modifier // Combined modifiers
88}
89
90// Action represents what happens when a shortcut is triggered.
91type Action string
92
93// Predefined actions for the keyboard system.
94const (
95 // Mode management
96 ActionEnterTabMode Action = "enter_tab_mode"
97 ActionEnterPaneMode Action = "enter_pane_mode"
98 ActionExitMode Action = "exit_mode"
99
100 // Tab actions (global and modal)
101 ActionNewTab Action = "new_tab"
102 ActionCloseTab Action = "close_tab"
103 ActionNextTab Action = "next_tab"
104 ActionPreviousTab Action = "previous_tab"
105 ActionRenameTab Action = "rename_tab"
106 ActionSwitchLastTab Action = "switch_last_tab" // Alt+Tab style switching
107 ActionSwitchTabIndex1 Action = "switch_tab_1"
108 ActionSwitchTabIndex2 Action = "switch_tab_2"
109 ActionSwitchTabIndex3 Action = "switch_tab_3"
110 ActionSwitchTabIndex4 Action = "switch_tab_4"
111 ActionSwitchTabIndex5 Action = "switch_tab_5"
112 ActionSwitchTabIndex6 Action = "switch_tab_6"
113 ActionSwitchTabIndex7 Action = "switch_tab_7"
114 ActionSwitchTabIndex8 Action = "switch_tab_8"
115 ActionSwitchTabIndex9 Action = "switch_tab_9"
116 ActionSwitchTabIndex10 Action = "switch_tab_10"
117
118 // Pane actions (modal)
119 ActionSplitRight Action = "split_right"
120 ActionSplitLeft Action = "split_left"
121 ActionSplitUp Action = "split_up"
122 ActionSplitDown Action = "split_down"
123 ActionClosePane Action = "close_pane"
124 ActionStackPane Action = "stack_pane"
125
126 // Pane focus navigation
127 ActionFocusRight Action = "focus_right"
128 ActionFocusLeft Action = "focus_left"
129 ActionFocusUp Action = "focus_up"
130 ActionFocusDown Action = "focus_down"
131
132 // Stack navigation (within stacked panes)
133 ActionStackNavUp Action = "stack_nav_up"
134 ActionStackNavDown Action = "stack_nav_down"
135
136 // Page navigation
137 ActionGoBack Action = "go_back"
138 ActionGoForward Action = "go_forward"
139 ActionReload Action = "reload"
140 ActionHardReload Action = "hard_reload"
141 ActionStop Action = "stop"
142
143 // Zoom
144 ActionZoomIn Action = "zoom_in"
145 ActionZoomOut Action = "zoom_out"
146 ActionZoomReset Action = "zoom_reset"
147
148 // UI
149 ActionOpenOmnibox Action = "open_omnibox"
150 ActionOpenFind Action = "open_find"
151 ActionFindNext Action = "find_next"
152 ActionFindPrev Action = "find_prev"
153 ActionCloseFind Action = "close_find"
154 ActionOpenDevTools Action = "open_devtools"
155 ActionToggleFullscreen Action = "toggle_fullscreen"
156
157 // Clipboard
158 ActionCopyURL Action = "copy_url"
159
160 // Application
161 ActionQuit Action = "quit"
162)
163
164// ShortcutTable maps KeyBinding to Action.
165type ShortcutTable map[KeyBinding]Action
166
167// ShortcutSet holds all shortcut tables organized by context.
168type ShortcutSet struct {
169 // Global shortcuts are always active regardless of mode.
170 Global ShortcutTable
171 // TabMode shortcuts are only active in tab mode.
172 TabMode ShortcutTable
173 // PaneMode shortcuts are only active in pane mode.
174 PaneMode ShortcutTable
175}
176
177// NewShortcutSet creates a ShortcutSet from the workspace configuration.
178func NewShortcutSet(ctx context.Context, cfg *config.WorkspaceConfig) *ShortcutSet {
179 log := logging.FromContext(ctx)
180 set := &ShortcutSet{
181 Global: make(ShortcutTable),
182 TabMode: make(ShortcutTable),
183 PaneMode: make(ShortcutTable),
184 }
185
186 set.buildGlobalShortcuts(ctx, cfg)
187 set.buildTabModeShortcuts(ctx, cfg)
188 set.buildPaneModeShortcuts(ctx, cfg)
189
190 log.Debug().Int("global", len(set.Global)).Int("tab", len(set.TabMode)).Int("pane", len(set.PaneMode)).Msg("shortcuts registered")
191
192 return set
193}
194
195// buildGlobalShortcuts populates global shortcuts from config.
196func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
197 s.registerActivationShortcuts(ctx, cfg)
198 s.registerConfiguredShortcuts(cfg)
199 s.registerStandardShortcuts()
200 s.registerPaneNavigationShortcuts()
201 s.registerTabSwitchShortcuts()
202}
203
204// buildTabModeShortcuts populates tab mode shortcuts from config.
205func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
206 s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
207}
208
209// buildPaneModeShortcuts populates pane mode shortcuts from config.
210func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
211 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
212}
213
214func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
215 log := logging.FromContext(ctx)
216 if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok {
217 s.Global[binding] = ActionEnterTabMode
218 log.Trace().
219 Str("shortcut", cfg.TabMode.ActivationShortcut).
220 Uint("keyval", binding.Keyval).
221 Uint("mod", uint(binding.Modifiers)).
222 Msg("tab mode activation registered")
223 } else {
224 log.Warn().Str("shortcut", cfg.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
225 }
226 if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok {
227 s.Global[binding] = ActionEnterPaneMode
228 log.Trace().
229 Str("shortcut", cfg.PaneMode.ActivationShortcut).
230 Uint("keyval", binding.Keyval).
231 Uint("mod", uint(binding.Modifiers)).
232 Msg("pane mode activation registered")
233 } else {
234 log.Warn().Str("shortcut", cfg.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
235 }
236}
237
238func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
239 // Note: Ctrl+T is NOT registered globally - it enters tab mode.
240 // In tab mode, use:
241 // n = new tab
242 // x = close tab
243 // l/tab = next tab
244 // h/shift+tab = previous tab
245 // r = rename tab
246 // This follows Zellij-style modal keyboard interface.
247 //
248 // However, these standard browser shortcuts ARE global:
249 if binding, ok := ParseKeyString(cfg.Shortcuts.ClosePane); ok {
250 s.Global[binding] = ActionClosePane // Ctrl+W closes active pane
251 }
252 if binding, ok := ParseKeyString(cfg.Shortcuts.NextTab); ok {
253 s.Global[binding] = ActionNextTab // Ctrl+Tab
254 }
255 if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok {
256 s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab
257 }
258}
259
260func (s *ShortcutSet) registerStandardShortcuts() {
261 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
262 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
263 s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
264 s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev
265 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
266 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
267 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
268 s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload
269 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
270 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
271 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
272 s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionGoBack
273 s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionGoForward
274 s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
275 s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
276 s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
277 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
278 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
279 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
280 s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL
281}
282
283func (s *ShortcutSet) registerPaneNavigationShortcuts() {
284 s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
285 s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
286 s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
287 s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
288
289 s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
290 s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
291}
292
293func (s *ShortcutSet) registerTabSwitchShortcuts() {
294 s.Global[KeyBinding{uint(gdk.KEY_1), ModAlt}] = ActionSwitchTabIndex1
295 s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2
296 s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3
297 s.Global[KeyBinding{uint(gdk.KEY_4), ModAlt}] = ActionSwitchTabIndex4
298 s.Global[KeyBinding{uint(gdk.KEY_5), ModAlt}] = ActionSwitchTabIndex5
299 s.Global[KeyBinding{uint(gdk.KEY_6), ModAlt}] = ActionSwitchTabIndex6
300 s.Global[KeyBinding{uint(gdk.KEY_7), ModAlt}] = ActionSwitchTabIndex7
301 s.Global[KeyBinding{uint(gdk.KEY_8), ModAlt}] = ActionSwitchTabIndex8
302 s.Global[KeyBinding{uint(gdk.KEY_9), ModAlt}] = ActionSwitchTabIndex9
303 s.Global[KeyBinding{uint(gdk.KEY_0), ModAlt}] = ActionSwitchTabIndex10
304
305 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt}] = ActionSwitchLastTab
306 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
307}
308
309func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
310 log := logging.FromContext(ctx)
311 var registered, parseErrors, unknownActions int
312 for key, configAction := range bindings {
313 if binding, ok := ParseKeyString(key); ok {
314 if action := mapConfigAction(configAction); action != "" {
315 dest[binding] = action
316 registered++
317 log.Trace().
318 Str("key", key).
319 Str("configAction", configAction).
320 Str("action", string(action)).
321 Uint("keyval", binding.Keyval).
322 Msg(mode + " mode shortcut registered")
323 } else {
324 unknownActions++
325 log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in " + mode + " mode")
326 }
327 } else {
328 parseErrors++
329 log.Warn().Str("key", key).Msg("failed to parse " + mode + " mode key")
330 }
331 }
332 log.Debug().
333 Int("registered", registered).
334 Int("parseErrors", parseErrors).
335 Int("unknownActions", unknownActions).
336 Msg(mode + " mode shortcuts built")
337}
338
339// mapConfigAction maps config action names to Action constants.
340func mapConfigAction(configAction string) Action {
341 switch configAction {
342 // Mode management
343 case "cancel", "confirm":
344 return ActionExitMode
345
346 // Tab actions
347 case "new-tab":
348 return ActionNewTab
349 case "close-tab":
350 return ActionCloseTab
351 case "next-tab":
352 return ActionNextTab
353 case "previous-tab":
354 return ActionPreviousTab
355 case "rename-tab":
356 return ActionRenameTab
357
358 // Pane actions
359 case "split-right":
360 return ActionSplitRight
361 case "split-left":
362 return ActionSplitLeft
363 case "split-up":
364 return ActionSplitUp
365 case "split-down":
366 return ActionSplitDown
367 case "close-pane":
368 return ActionClosePane
369 case "stack-pane":
370 return ActionStackPane
371
372 // Focus navigation
373 case "focus-right":
374 return ActionFocusRight
375 case "focus-left":
376 return ActionFocusLeft
377 case "focus-up":
378 return ActionFocusUp
379 case "focus-down":
380 return ActionFocusDown
381
382 // Stack navigation
383 case "stack-nav-up", "stack-up":
384 return ActionStackNavUp
385 case "stack-nav-down", "stack-down":
386 return ActionStackNavDown
387
388 default:
389 return ""
390 }
391}
392
393// ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
394// Returns false if the string cannot be parsed.
395func ParseKeyString(s string) (KeyBinding, bool) {
396 if s == "" {
397 return KeyBinding{}, false
398 }
399
400 s = strings.ToLower(strings.TrimSpace(s))
401 parts := strings.Split(s, "+")
402
403 var modifiers Modifier
404 var keyPart string
405
406 for _, part := range parts {
407 part = strings.TrimSpace(part)
408 switch part {
409 case "ctrl", "control":
410 modifiers |= ModCtrl
411 case "shift":
412 modifiers |= ModShift
413 case "alt":
414 modifiers |= ModAlt
415 default:
416 keyPart = part
417 }
418 }
419
420 if keyPart == "" {
421 return KeyBinding{}, false
422 }
423
424 keyval, ok := stringToKeyval(keyPart)
425 if !ok {
426 return KeyBinding{}, false
427 }
428
429 return KeyBinding{
430 Keyval: keyval,
431 Modifiers: modifiers,
432 }, true
433}
434
435// stringToKeyval converts a key name to its GDK keyval.
436func stringToKeyval(s string) (uint, bool) {
437 if keyval, ok := keyvalByName[s]; ok {
438 return keyval, true
439 }
440
441 // Single letter keys (a-z)
442 if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' {
443 // ASCII lowercase a=97, which matches gdk.KEY_a
444 return uint(s[0]), true
445 }
446
447 return 0, false
448}
449
450// Lookup finds an action for the given key binding in the appropriate table.
451// It first checks the mode-specific table, then falls back to global.
452func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
453 // Normalize the binding modifiers
454 binding.Modifiers &= modifierMask
455
456 // Check mode-specific table first
457 var modeTable ShortcutTable
458 switch mode {
459 case ModeTab:
460 modeTable = s.TabMode
461 case ModePane:
462 modeTable = s.PaneMode
463 }
464
465 if modeTable != nil {
466 if action, ok := modeTable[binding]; ok {
467 return action, true
468 }
469 }
470
471 // Fall back to global
472 if action, ok := s.Global[binding]; ok {
473 return action, true
474 }
475
476 return "", false
477}
478
479// ShouldAutoExitMode returns true if the action should cause modal mode to exit.
480func ShouldAutoExitMode(action Action) bool {
481 switch action {
482 case ActionNewTab, ActionCloseTab, ActionRenameTab,
483 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
484 ActionClosePane, ActionStackPane:
485 return true
486 default:
487 return false
488 }
489}