1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5 "context"
6 "sort"
7 "strings"
8
9 "github.com/bnema/dumber/internal/infrastructure/config"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/jwijenbergh/puregotk/v4/gdk"
12)
13
14// Modifier represents keyboard modifier flags.
15type Modifier uint
16
17const (
18 // ModNone indicates no modifier is pressed.
19 ModNone Modifier = 0
20 // ModShift indicates the Shift key is pressed.
21 ModShift Modifier = Modifier(gdk.ShiftMaskValue)
22 // ModCtrl indicates the Control key is pressed.
23 ModCtrl Modifier = Modifier(gdk.ControlMaskValue)
24 // ModAlt indicates the Alt key is pressed.
25 ModAlt Modifier = Modifier(gdk.AltMaskValue)
26)
27
28// modifierMask filters out non-standard modifiers from GDK state.
29const modifierMask = ModCtrl | ModShift | ModAlt
30
31var keyvalByName = map[string]uint{
32 "escape": uint(gdk.KEY_Escape),
33 "esc": uint(gdk.KEY_Escape),
34 "return": uint(gdk.KEY_Return),
35 "enter": uint(gdk.KEY_Return),
36 "tab": uint(gdk.KEY_Tab),
37 "space": uint(gdk.KEY_space),
38 "backspace": uint(gdk.KEY_BackSpace),
39 "delete": uint(gdk.KEY_Delete),
40 "del": uint(gdk.KEY_Delete),
41 "home": uint(gdk.KEY_Home),
42 "end": uint(gdk.KEY_End),
43 "pageup": uint(gdk.KEY_Page_Up),
44 "page_up": uint(gdk.KEY_Page_Up),
45 "pagedown": uint(gdk.KEY_Page_Down),
46 "page_down": uint(gdk.KEY_Page_Down),
47 "left": uint(gdk.KEY_Left),
48 "arrowleft": uint(gdk.KEY_Left),
49 "right": uint(gdk.KEY_Right),
50 "arrowright": uint(gdk.KEY_Right),
51 "up": uint(gdk.KEY_Up),
52 "arrowup": uint(gdk.KEY_Up),
53 "down": uint(gdk.KEY_Down),
54 "arrowdown": uint(gdk.KEY_Down),
55 "f1": uint(gdk.KEY_F1),
56 "f2": uint(gdk.KEY_F2),
57 "f3": uint(gdk.KEY_F3),
58 "f4": uint(gdk.KEY_F4),
59 "f5": uint(gdk.KEY_F5),
60 "f6": uint(gdk.KEY_F6),
61 "f7": uint(gdk.KEY_F7),
62 "f8": uint(gdk.KEY_F8),
63 "f9": uint(gdk.KEY_F9),
64 "f10": uint(gdk.KEY_F10),
65 "f11": uint(gdk.KEY_F11),
66 "f12": uint(gdk.KEY_F12),
67 "plus": uint(gdk.KEY_plus),
68 "+": uint(gdk.KEY_plus),
69 "minus": uint(gdk.KEY_minus),
70 "-": uint(gdk.KEY_minus),
71 "equal": uint(gdk.KEY_equal),
72 "=": uint(gdk.KEY_equal),
73 "0": uint(gdk.KEY_0),
74 "1": uint(gdk.KEY_1),
75 "2": uint(gdk.KEY_2),
76 "3": uint(gdk.KEY_3),
77 "4": uint(gdk.KEY_4),
78 "5": uint(gdk.KEY_5),
79 "6": uint(gdk.KEY_6),
80 "7": uint(gdk.KEY_7),
81 "8": uint(gdk.KEY_8),
82 "9": uint(gdk.KEY_9),
83
84 "bracketleft": uint(gdk.KEY_bracketleft),
85 "bracketright": uint(gdk.KEY_bracketright),
86 "[": uint(gdk.KEY_bracketleft),
87 "]": uint(gdk.KEY_bracketright),
88 "braceleft": uint(gdk.KEY_braceleft),
89 "braceright": uint(gdk.KEY_braceright),
90 "{": uint(gdk.KEY_braceleft),
91 "}": uint(gdk.KEY_braceright),
92}
93
94// KeyBinding represents a single key combination.
95type KeyBinding struct {
96 Keyval uint // GDK key value (e.g., gdk.KEY_t)
97 Modifiers Modifier // Combined modifiers
98}
99
100// Action represents what happens when a shortcut is triggered.
101type Action string
102
103// Predefined actions for the keyboard system.
104const (
105 // Mode management
106 ActionEnterTabMode Action = "enter_tab_mode"
107 ActionEnterPaneMode Action = "enter_pane_mode"
108 ActionEnterSessionMode Action = "enter_session_mode"
109 ActionEnterResizeMode Action = "enter_resize_mode"
110 ActionExitMode Action = "exit_mode"
111
112 // Tab actions (global and modal)
113 ActionNewTab Action = "new_tab"
114 ActionCloseTab Action = "close_tab"
115 ActionNextTab Action = "next_tab"
116 ActionPreviousTab Action = "previous_tab"
117 ActionRenameTab Action = "rename_tab"
118 ActionSwitchLastTab Action = "switch_last_tab" // Alt+Tab style switching
119 ActionSwitchTabIndex1 Action = "switch_tab_1"
120 ActionSwitchTabIndex2 Action = "switch_tab_2"
121 ActionSwitchTabIndex3 Action = "switch_tab_3"
122 ActionSwitchTabIndex4 Action = "switch_tab_4"
123 ActionSwitchTabIndex5 Action = "switch_tab_5"
124 ActionSwitchTabIndex6 Action = "switch_tab_6"
125 ActionSwitchTabIndex7 Action = "switch_tab_7"
126 ActionSwitchTabIndex8 Action = "switch_tab_8"
127 ActionSwitchTabIndex9 Action = "switch_tab_9"
128 ActionSwitchTabIndex10 Action = "switch_tab_10"
129
130 // Pane actions (modal)
131 ActionSplitRight Action = "split_right"
132 ActionSplitLeft Action = "split_left"
133 ActionSplitUp Action = "split_up"
134 ActionSplitDown Action = "split_down"
135 ActionClosePane Action = "close_pane"
136 ActionStackPane Action = "stack_pane"
137
138 ActionMovePaneToTab Action = "move_pane_to_tab"
139 ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
140
141 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
142 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
143 ActionConsumeOrExpelUp Action = "consume_or_expel_up"
144 ActionConsumeOrExpelDown Action = "consume_or_expel_down"
145
146 // Pane focus navigation
147 ActionFocusRight Action = "focus_right"
148 ActionFocusLeft Action = "focus_left"
149 ActionFocusUp Action = "focus_up"
150 ActionFocusDown Action = "focus_down"
151
152 // Resize actions (modal)
153 ActionResizeIncreaseLeft Action = "resize_increase_left"
154 ActionResizeIncreaseRight Action = "resize_increase_right"
155 ActionResizeIncreaseUp Action = "resize_increase_up"
156 ActionResizeIncreaseDown Action = "resize_increase_down"
157 ActionResizeDecreaseLeft Action = "resize_decrease_left"
158 ActionResizeDecreaseRight Action = "resize_decrease_right"
159 ActionResizeDecreaseUp Action = "resize_decrease_up"
160 ActionResizeDecreaseDown Action = "resize_decrease_down"
161 ActionResizeIncrease Action = "resize_increase"
162 ActionResizeDecrease Action = "resize_decrease"
163
164 // Stack navigation (within stacked panes)
165 ActionStackNavUp Action = "stack_nav_up"
166 ActionStackNavDown Action = "stack_nav_down"
167
168 // Page navigation
169 ActionGoBack Action = "go_back"
170 ActionGoForward Action = "go_forward"
171 ActionReload Action = "reload"
172 ActionHardReload Action = "hard_reload"
173 ActionStop Action = "stop"
174 ActionPrintPage Action = "print_page"
175
176 // Zoom
177 ActionZoomIn Action = "zoom_in"
178 ActionZoomOut Action = "zoom_out"
179 ActionZoomReset Action = "zoom_reset"
180
181 // UI
182 ActionOpenOmnibox Action = "open_omnibox"
183 ActionOpenFind Action = "open_find"
184 ActionFindNext Action = "find_next"
185 ActionFindPrev Action = "find_prev"
186 ActionCloseFind Action = "close_find"
187 ActionOpenDevTools Action = "open_devtools"
188 ActionToggleFullscreen Action = "toggle_fullscreen"
189 ActionToggleFloatingPane Action = "toggle_floating_pane"
190
191 // Clipboard
192 ActionCopyURL Action = "copy_url"
193
194 // Session management
195 ActionOpenSessionManager Action = "open_session_manager"
196
197 // Application
198 ActionQuit Action = "quit"
199)
200
201const floatingProfileActionPrefix = "open_floating_profile:"
202const floatingProfileActionSeparator = "\u241F"
203
204// ShortcutTable maps KeyBinding to Action.
205type ShortcutTable map[KeyBinding]Action
206
207// ShortcutSet holds all shortcut tables organized by context.
208type ShortcutSet struct {
209 // Global shortcuts are always active regardless of mode.
210 Global ShortcutTable
211 // TabMode shortcuts are only active in tab mode.
212 TabMode ShortcutTable
213 // PaneMode shortcuts are only active in pane mode.
214 PaneMode ShortcutTable
215 // SessionMode shortcuts are only active in session mode.
216 SessionMode ShortcutTable
217 // ResizeMode shortcuts are only active in resize mode.
218 ResizeMode ShortcutTable
219}
220
221// NewShortcutSet creates a ShortcutSet from the configuration.
222func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
223 log := logging.FromContext(ctx)
224 set := &ShortcutSet{
225 Global: make(ShortcutTable),
226 TabMode: make(ShortcutTable),
227 PaneMode: make(ShortcutTable),
228 SessionMode: make(ShortcutTable),
229 ResizeMode: make(ShortcutTable),
230 }
231
232 set.buildGlobalShortcuts(ctx, cfg)
233 set.buildTabModeShortcuts(ctx, &cfg.Workspace)
234 set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
235 set.buildResizeModeShortcuts(ctx, &cfg.Workspace)
236 set.buildSessionModeShortcuts(ctx, &cfg.Session)
237
238 log.Debug().
239 Int("global", len(set.Global)).
240 Int("tab", len(set.TabMode)).
241 Int("pane", len(set.PaneMode)).
242 Int("resize", len(set.ResizeMode)).
243 Int("session", len(set.SessionMode)).
244 Msg("shortcuts registered")
245
246 return set
247}
248
249// buildGlobalShortcuts populates global shortcuts from config.
250func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Config) {
251 s.registerActivationShortcuts(ctx, cfg)
252 s.registerConfiguredShortcuts(&cfg.Workspace)
253 s.registerStandardShortcuts()
254 s.registerPaneNavigationShortcuts()
255 s.registerTabSwitchShortcuts()
256 s.registerFloatingProfileShortcuts(ctx, cfg)
257}
258
259// buildTabModeShortcuts populates tab mode shortcuts from config.
260func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
261 s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
262}
263
264// buildPaneModeShortcuts populates pane mode shortcuts from config.
265func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
266 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
267}
268
269// buildSessionModeShortcuts populates session mode shortcuts from config.
270func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config.SessionConfig) {
271 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
272}
273
274// buildResizeModeShortcuts populates resize mode shortcuts from config.
275func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
276 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
277}
278
279func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {
280 log := logging.FromContext(ctx)
281 if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
282 s.Global[binding] = ActionEnterTabMode
283 log.Trace().
284 Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).
285 Uint("keyval", binding.Keyval).
286 Uint("mod", uint(binding.Modifiers)).
287 Msg("tab mode activation registered")
288 } else {
289 log.Warn().Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
290 }
291 if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok {
292 s.Global[binding] = ActionEnterPaneMode
293 log.Trace().
294 Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).
295 Uint("keyval", binding.Keyval).
296 Uint("mod", uint(binding.Modifiers)).
297 Msg("pane mode activation registered")
298 } else {
299 log.Warn().Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
300 }
301 if binding, ok := ParseKeyString(cfg.Session.SessionMode.ActivationShortcut); ok {
302 s.Global[binding] = ActionEnterSessionMode
303 log.Trace().
304 Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).
305 Uint("keyval", binding.Keyval).
306 Uint("mod", uint(binding.Modifiers)).
307 Msg("session mode activation registered")
308 } else {
309 log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
310 }
311 if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok {
312 s.Global[binding] = ActionEnterResizeMode
313 log.Trace().
314 Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).
315 Uint("keyval", binding.Keyval).
316 Uint("mod", uint(binding.Modifiers)).
317 Msg("resize mode activation registered")
318 } else {
319 log.Warn().Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
320 }
321}
322
323func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
324 // Note: Ctrl+T is NOT registered globally - it enters tab mode.
325 // In tab mode, use:
326 // n = new tab
327 // x = close tab
328 // l/tab = next tab
329 // h/shift+tab = previous tab
330 // r = rename tab
331 // This follows Zellij-style modal keyboard interface.
332 //
333 // However, these standard browser shortcuts ARE global:
334 // Map action names to action constants
335 actionMap := map[string]Action{
336 "toggle_floating_pane": ActionToggleFloatingPane,
337 "toggle-floating-pane": ActionToggleFloatingPane,
338 "close_pane": ActionClosePane,
339 "next_tab": ActionNextTab,
340 "previous_tab": ActionPreviousTab,
341 "consume_or_expel_left": ActionConsumeOrExpelLeft,
342 "consume_or_expel_right": ActionConsumeOrExpelRight,
343 "consume_or_expel_up": ActionConsumeOrExpelUp,
344 "consume_or_expel_down": ActionConsumeOrExpelDown,
345 }
346
347 for actionName, actionBinding := range cfg.Shortcuts.Actions {
348 action, ok := actionMap[actionName]
349 if !ok {
350 continue
351 }
352 for _, keyStr := range actionBinding.Keys {
353 if binding, ok := ParseKeyString(keyStr); ok {
354 s.Global[binding] = action
355 }
356 }
357 }
358}
359
360func (s *ShortcutSet) registerFloatingProfileShortcuts(ctx context.Context, cfg *config.Config) {
361 occupied := make(map[KeyBinding]Action, len(s.Global))
362 for binding, action := range s.Global {
363 occupied[binding] = action
364 }
365
366 for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
367 s.Global[shortcut.Binding] = shortcut.Action
368 }
369}
370
371type floatingProfileShortcut struct {
372 Binding KeyBinding
373 Action Action
374}
375
376func collectFloatingProfileShortcuts(
377 ctx context.Context,
378 cfg *config.Config,
379 occupied map[KeyBinding]Action,
380) []floatingProfileShortcut {
381 if cfg == nil {
382 return nil
383 }
384
385 log := logging.FromContext(ctx)
386 reserveGlobalOnlyShortcutBindings(occupied)
387 result := make([]floatingProfileShortcut, 0)
388 profileNames := make([]string, 0, len(cfg.Workspace.FloatingPane.Profiles))
389 for profileName := range cfg.Workspace.FloatingPane.Profiles {
390 profileNames = append(profileNames, profileName)
391 }
392 sort.Strings(profileNames)
393
394 for _, profileName := range profileNames {
395 profile := cfg.Workspace.FloatingPane.Profiles[profileName]
396 url := strings.TrimSpace(profile.URL)
397 if url == "" {
398 log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
399 continue
400 }
401
402 action := NewFloatingProfileAction(profileName, url)
403 keys := append([]string(nil), profile.Keys...)
404 sort.Strings(keys)
405 for _, keyStr := range keys {
406 binding, ok := ParseKeyString(keyStr)
407 if !ok {
408 log.Warn().Str("profile", profileName).Str("shortcut", keyStr).Msg("failed to parse floating profile shortcut")
409 continue
410 }
411
412 if existing, exists := occupied[binding]; exists {
413 log.Warn().
414 Str("profile", profileName).
415 Str("shortcut", keyStr).
416 Str("existing_action", string(existing)).
417 Msg("floating profile shortcut conflicts with existing global shortcut, skipping")
418 continue
419 }
420
421 occupied[binding] = action
422 result = append(result, floatingProfileShortcut{Binding: binding, Action: action})
423 }
424 }
425
426 return result
427}
428
429func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
430 if occupied == nil {
431 return
432 }
433
434 tabIndexActions := []Action{
435 ActionSwitchTabIndex1,
436 ActionSwitchTabIndex2,
437 ActionSwitchTabIndex3,
438 ActionSwitchTabIndex4,
439 ActionSwitchTabIndex5,
440 ActionSwitchTabIndex6,
441 ActionSwitchTabIndex7,
442 ActionSwitchTabIndex8,
443 ActionSwitchTabIndex9,
444 }
445
446 // Assumes gdk.KEY_1..gdk.KEY_9 are contiguous (ASCII '1'..'9').
447 // If that ever changes on a platform, map digits explicitly.
448 for i, action := range tabIndexActions {
449 binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
450 if _, exists := occupied[binding]; exists {
451 continue
452 }
453 occupied[binding] = action
454 }
455
456 altZero := KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModAlt}
457 if _, exists := occupied[altZero]; !exists {
458 occupied[altZero] = ActionSwitchTabIndex10
459 }
460
461 altTab := KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}
462 if _, exists := occupied[altTab]; !exists {
463 occupied[altTab] = ActionSwitchLastTab
464 }
465}
466
467func (s *ShortcutSet) registerStandardShortcuts() {
468 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
469 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
470 s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
471 s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev
472 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
473 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
474 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
475 s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
476 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
477 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
478 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
479 s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack
480 s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward
481 s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
482 s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
483 s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
484 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
485 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
486 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
487 s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
488 s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage
489 // Session management - direct shortcut to open session manager
490 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
491}
492
493func (s *ShortcutSet) registerPaneNavigationShortcuts() {
494 s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
495 s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
496 s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
497 s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
498
499 s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft
500 s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight
501 s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
502 s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
503}
504
505func (s *ShortcutSet) registerTabSwitchShortcuts() {
506 // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
507 // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
508 // This is necessary because WebKitGTK's WebView consumes these key events
509 // before they reach the EventControllerKey in capture phase.
510 //
511 // Only Alt+Shift+Tab remains here as a fallback binding.
512 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
513}
514
515func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
516 log := logging.FromContext(ctx)
517 var registered, parseErrors, unknownActions int
518 for key, configAction := range bindings {
519 if binding, ok := ParseKeyString(key); ok {
520 if action := mapConfigAction(configAction); action != "" {
521 dest[binding] = action
522 registered++
523 log.Trace().
524 Str("key", key).
525 Str("configAction", configAction).
526 Str("action", string(action)).
527 Uint("keyval", binding.Keyval).
528 Msg(mode + " mode shortcut registered")
529 } else {
530 unknownActions++
531 log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in " + mode + " mode")
532 }
533 } else {
534 parseErrors++
535 log.Warn().Str("key", key).Msg("failed to parse " + mode + " mode key")
536 }
537 }
538 log.Debug().
539 Int("registered", registered).
540 Int("parseErrors", parseErrors).
541 Int("unknownActions", unknownActions).
542 Msg(mode + " mode shortcuts built")
543}
544
545var configActionToAction = map[string]Action{
546 "toggle_floating_pane": ActionToggleFloatingPane,
547 "toggle-floating-pane": ActionToggleFloatingPane,
548
549 // Tab actions
550 "new-tab": ActionNewTab,
551 "close-tab": ActionCloseTab,
552 "next-tab": ActionNextTab,
553 "previous-tab": ActionPreviousTab,
554 "rename-tab": ActionRenameTab,
555
556 // Pane actions
557 "split-right": ActionSplitRight,
558 "split-left": ActionSplitLeft,
559 "split-up": ActionSplitUp,
560 "split-down": ActionSplitDown,
561 "close-pane": ActionClosePane,
562 "stack-pane": ActionStackPane,
563 "move-pane-to-tab": ActionMovePaneToTab,
564 "move-pane-to-next-tab": ActionMovePaneToNextTab,
565
566 "consume-or-expel-left": ActionConsumeOrExpelLeft,
567 "consume-or-expel-right": ActionConsumeOrExpelRight,
568 "consume-or-expel-up": ActionConsumeOrExpelUp,
569 "consume-or-expel-down": ActionConsumeOrExpelDown,
570
571 // Focus navigation
572 "focus-right": ActionFocusRight,
573 "focus-left": ActionFocusLeft,
574 "focus-up": ActionFocusUp,
575 "focus-down": ActionFocusDown,
576
577 // Stack navigation
578 "stack-nav-up": ActionStackNavUp,
579 "stack-up": ActionStackNavUp,
580 "stack-nav-down": ActionStackNavDown,
581 "stack-down": ActionStackNavDown,
582
583 // Resize actions
584 "resize-increase-left": ActionResizeIncreaseLeft,
585 "resize-increase-right": ActionResizeIncreaseRight,
586 "resize-increase-up": ActionResizeIncreaseUp,
587 "resize-increase-down": ActionResizeIncreaseDown,
588 "resize-decrease-left": ActionResizeDecreaseLeft,
589 "resize-decrease-right": ActionResizeDecreaseRight,
590 "resize-decrease-up": ActionResizeDecreaseUp,
591 "resize-decrease-down": ActionResizeDecreaseDown,
592 "resize-increase": ActionResizeIncrease,
593 "resize-decrease": ActionResizeDecrease,
594
595 // Session actions
596 "session-manager": ActionOpenSessionManager,
597}
598
599// FloatingProfileTarget carries the session identity and URL for a floating profile action.
600type FloatingProfileTarget struct {
601 SessionID string
602 URL string
603}
604
605// NewFloatingProfileAction builds an action value carrying a floating profile session and URL.
606func NewFloatingProfileAction(profileName, url string) Action {
607 sessionID := strings.TrimSpace(profileName)
608 trimmedURL := strings.TrimSpace(url)
609 if sessionID == "" {
610 sessionID = trimmedURL
611 }
612 return Action(floatingProfileActionPrefix + sessionID + floatingProfileActionSeparator + trimmedURL)
613}
614
615// ParseFloatingProfileTarget extracts the session identity and target URL.
616func ParseFloatingProfileTarget(action Action) (FloatingProfileTarget, bool) {
617 value := string(action)
618 if !strings.HasPrefix(value, floatingProfileActionPrefix) {
619 return FloatingProfileTarget{}, false
620 }
621
622 payload := strings.TrimPrefix(value, floatingProfileActionPrefix)
623 sessionID := ""
624 url := payload
625 if parts := strings.SplitN(payload, floatingProfileActionSeparator, 2); len(parts) == 2 {
626 sessionID = strings.TrimSpace(parts[0])
627 url = parts[1]
628 }
629
630 url = strings.TrimSpace(url)
631 if url == "" {
632 return FloatingProfileTarget{}, false
633 }
634 if sessionID == "" {
635 sessionID = url
636 }
637
638 return FloatingProfileTarget{SessionID: sessionID, URL: url}, true
639}
640
641// ParseFloatingProfileAction extracts the target URL from a floating profile action.
642func ParseFloatingProfileAction(action Action) (string, bool) {
643 target, ok := ParseFloatingProfileTarget(action)
644 if !ok {
645 return "", false
646 }
647 return target.URL, true
648}
649
650// mapConfigAction maps config action names to Action constants.
651func mapConfigAction(configAction string) Action {
652 if configAction == "cancel" || configAction == "confirm" {
653 return ActionExitMode
654 }
655 return configActionToAction[configAction]
656}
657
658// ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
659// Returns false if the string cannot be parsed.
660func ParseKeyString(s string) (KeyBinding, bool) {
661 if s == "" {
662 return KeyBinding{}, false
663 }
664
665 s = strings.TrimSpace(s)
666 if s == "" {
667 return KeyBinding{}, false
668 }
669 if s == "+" {
670 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
671 }
672
673 parts := strings.Split(s, "+")
674
675 var modifiers Modifier
676 var keyPart string
677
678 for _, part := range parts {
679 part = strings.TrimSpace(part)
680 if part == "" {
681 continue
682 }
683
684 lower := strings.ToLower(part)
685 switch lower {
686 case "ctrl", "control":
687 modifiers |= ModCtrl
688 case "shift":
689 modifiers |= ModShift
690 case "alt":
691 modifiers |= ModAlt
692 default:
693 if keyPart != "" {
694 return KeyBinding{}, false
695 }
696 keyPart = part
697 }
698 }
699
700 // Allow parsing "ctrl++" / "alt+shift++" where the key is "+".
701 if keyPart == "" && strings.HasSuffix(s, "++") {
702 keyPart = "+"
703 }
704
705 if keyPart == "" {
706 return KeyBinding{}, false
707 }
708
709 // Treat uppercase single-letter keys as Shift+<letter>.
710 if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
711 modifiers |= ModShift
712 keyPart = strings.ToLower(keyPart)
713 }
714
715 keyval, ok := stringToKeyval(keyPart)
716 if !ok {
717 return KeyBinding{}, false
718 }
719
720 return KeyBinding{
721 Keyval: keyval,
722 Modifiers: modifiers,
723 }, true
724}
725
726// stringToKeyval converts a key name to its GDK keyval.
727func stringToKeyval(s string) (uint, bool) {
728 if s == "" {
729 return 0, false
730 }
731
732 if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
733 return keyval, true
734 }
735
736 // Single letter keys (a-z)
737 if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' {
738 // ASCII lowercase a=97, which matches gdk.KEY_a
739 return uint(s[0]), true
740 }
741
742 return 0, false
743}
744
745// Lookup finds an action for the given key binding in the appropriate table.
746// It first checks the mode-specific table, then falls back to global.
747func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
748 // Normalize the binding modifiers
749 binding.Modifiers &= modifierMask
750
751 // Check mode-specific table first
752 var modeTable ShortcutTable
753 switch mode {
754 case ModeTab:
755 modeTable = s.TabMode
756 case ModePane:
757 modeTable = s.PaneMode
758 case ModeResize:
759 modeTable = s.ResizeMode
760 case ModeSession:
761 modeTable = s.SessionMode
762 }
763
764 if modeTable != nil {
765 if action, ok := modeTable[binding]; ok {
766 return action, true
767 }
768 }
769
770 // Fall back to global
771 if action, ok := s.Global[binding]; ok {
772 return action, true
773 }
774
775 return "", false
776}
777
778// ShouldAutoExitMode returns true if the action should cause modal mode to exit.
779func ShouldAutoExitMode(action Action) bool {
780 switch action {
781 case ActionNewTab, ActionCloseTab, ActionRenameTab,
782 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
783 ActionClosePane, ActionStackPane,
784 ActionMovePaneToTab, ActionMovePaneToNextTab,
785 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
786 ActionOpenSessionManager:
787 return true
788 default:
789 return false
790 }
791}