1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5 "context"
6 "maps"
7 "slices"
8 "sort"
9 "strings"
10
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/domain/vimkeys"
13 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/puregotk/v4/gdk"
15)
16
17// Modifier represents keyboard modifier flags.
18type Modifier uint
19
20const (
21 // ModNone indicates no modifier is pressed.
22 ModNone Modifier = 0
23 // ModShift indicates the Shift key is pressed.
24 ModShift Modifier = Modifier(gdk.ShiftMaskValue)
25 // ModCtrl indicates the Control key is pressed.
26 ModCtrl Modifier = Modifier(gdk.ControlMaskValue)
27 // ModAlt indicates the Alt key is pressed.
28 ModAlt Modifier = Modifier(gdk.AltMaskValue)
29)
30
31// modifierMask filters out non-standard modifiers from GDK state.
32const modifierMask = ModCtrl | ModShift | ModAlt
33
34var keyvalByName = map[string]uint{
35 "escape": uint(gdk.KEY_Escape),
36 "esc": uint(gdk.KEY_Escape),
37 "return": uint(gdk.KEY_Return),
38 "enter": uint(gdk.KEY_Return),
39 "tab": uint(gdk.KEY_Tab),
40 "space": uint(gdk.KEY_space),
41 "backspace": uint(gdk.KEY_BackSpace),
42 "delete": uint(gdk.KEY_Delete),
43 "del": uint(gdk.KEY_Delete),
44 "home": uint(gdk.KEY_Home),
45 "end": uint(gdk.KEY_End),
46 "pageup": uint(gdk.KEY_Page_Up),
47 "page_up": uint(gdk.KEY_Page_Up),
48 "pagedown": uint(gdk.KEY_Page_Down),
49 "page_down": uint(gdk.KEY_Page_Down),
50 "left": uint(gdk.KEY_Left),
51 "arrowleft": uint(gdk.KEY_Left),
52 "right": uint(gdk.KEY_Right),
53 "arrowright": uint(gdk.KEY_Right),
54 "up": uint(gdk.KEY_Up),
55 "arrowup": uint(gdk.KEY_Up),
56 "down": uint(gdk.KEY_Down),
57 "arrowdown": uint(gdk.KEY_Down),
58 "f1": uint(gdk.KEY_F1),
59 "f2": uint(gdk.KEY_F2),
60 "f3": uint(gdk.KEY_F3),
61 "f4": uint(gdk.KEY_F4),
62 "f5": uint(gdk.KEY_F5),
63 "f6": uint(gdk.KEY_F6),
64 "f7": uint(gdk.KEY_F7),
65 "f8": uint(gdk.KEY_F8),
66 "f9": uint(gdk.KEY_F9),
67 "f10": uint(gdk.KEY_F10),
68 "f11": uint(gdk.KEY_F11),
69 "f12": uint(gdk.KEY_F12),
70 "plus": uint(gdk.KEY_plus),
71 "+": uint(gdk.KEY_plus),
72 "minus": uint(gdk.KEY_minus),
73 "-": uint(gdk.KEY_minus),
74 "equal": uint(gdk.KEY_equal),
75 "=": uint(gdk.KEY_equal),
76 "0": uint(gdk.KEY_0),
77 "1": uint(gdk.KEY_1),
78 "2": uint(gdk.KEY_2),
79 "3": uint(gdk.KEY_3),
80 "4": uint(gdk.KEY_4),
81 "5": uint(gdk.KEY_5),
82 "6": uint(gdk.KEY_6),
83 "7": uint(gdk.KEY_7),
84 "8": uint(gdk.KEY_8),
85 "9": uint(gdk.KEY_9),
86
87 "bracketleft": uint(gdk.KEY_bracketleft),
88 "bracketright": uint(gdk.KEY_bracketright),
89 "[": uint(gdk.KEY_bracketleft),
90 "]": uint(gdk.KEY_bracketright),
91 "braceleft": uint(gdk.KEY_braceleft),
92 "braceright": uint(gdk.KEY_braceright),
93 "{": uint(gdk.KEY_braceleft),
94 "}": uint(gdk.KEY_braceright),
95}
96
97// KeyBinding represents a single key combination.
98type KeyBinding struct {
99 Keyval uint // GDK key value (e.g., gdk.KEY_t)
100 Modifiers Modifier // Combined modifiers
101}
102
103// Action represents what happens when a shortcut is triggered.
104type Action string
105
106// Predefined actions for the keyboard system.
107const (
108 // Mode management
109 ActionEnterTabMode Action = "enter_tab_mode"
110 ActionEnterPaneMode Action = "enter_pane_mode"
111 ActionEnterSessionMode Action = "enter_session_mode"
112 ActionEnterResizeMode Action = "enter_resize_mode"
113 ActionEnterPrefixMode Action = "enter_prefix_mode"
114 ActionExitMode Action = "exit_mode"
115
116 // Tab actions (global and modal)
117 ActionNewTab Action = "new_tab"
118 ActionCloseTab Action = "close_tab"
119 ActionNextTab Action = "next_tab"
120 ActionPreviousTab Action = "previous_tab"
121 ActionRenameTab Action = "rename_tab"
122 ActionSwitchLastTab Action = "switch_last_tab" // Alt+Tab style switching
123 ActionSwitchTabIndex1 Action = "switch_tab_1"
124 ActionSwitchTabIndex2 Action = "switch_tab_2"
125 ActionSwitchTabIndex3 Action = "switch_tab_3"
126 ActionSwitchTabIndex4 Action = "switch_tab_4"
127 ActionSwitchTabIndex5 Action = "switch_tab_5"
128 ActionSwitchTabIndex6 Action = "switch_tab_6"
129 ActionSwitchTabIndex7 Action = "switch_tab_7"
130 ActionSwitchTabIndex8 Action = "switch_tab_8"
131 ActionSwitchTabIndex9 Action = "switch_tab_9"
132 ActionSwitchTabIndex10 Action = "switch_tab_10"
133
134 // Pane actions (modal)
135 ActionSplitRight Action = "split_right"
136 ActionSplitLeft Action = "split_left"
137 ActionSplitUp Action = "split_up"
138 ActionSplitDown Action = "split_down"
139 ActionClosePane Action = "close_pane"
140 ActionStackPane Action = "stack_pane"
141
142 ActionMovePaneToTab Action = "move_pane_to_tab"
143 ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
144 ActionEjectPaneToWindow Action = "eject_pane_to_window"
145
146 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
147 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
148 ActionConsumeOrExpelUp Action = "consume_or_expel_up"
149 ActionConsumeOrExpelDown Action = "consume_or_expel_down"
150
151 // Pane focus navigation
152 ActionFocusRight Action = "focus_right"
153 ActionFocusLeft Action = "focus_left"
154 ActionFocusUp Action = "focus_up"
155 ActionFocusDown Action = "focus_down"
156
157 // Resize actions (modal)
158 ActionResizeIncreaseLeft Action = "resize_increase_left"
159 ActionResizeIncreaseRight Action = "resize_increase_right"
160 ActionResizeIncreaseUp Action = "resize_increase_up"
161 ActionResizeIncreaseDown Action = "resize_increase_down"
162 ActionResizeDecreaseLeft Action = "resize_decrease_left"
163 ActionResizeDecreaseRight Action = "resize_decrease_right"
164 ActionResizeDecreaseUp Action = "resize_decrease_up"
165 ActionResizeDecreaseDown Action = "resize_decrease_down"
166 ActionResizeIncrease Action = "resize_increase"
167 ActionResizeDecrease Action = "resize_decrease"
168
169 // Stack navigation (within stacked panes)
170 ActionStackNavUp Action = "stack_nav_up"
171 ActionStackNavDown Action = "stack_nav_down"
172
173 // Page navigation
174 ActionGoBack Action = "go_back"
175 ActionGoForward Action = "go_forward"
176 ActionReload Action = "reload"
177 ActionHardReload Action = "hard_reload"
178 ActionStop Action = "stop"
179 ActionPrintPage Action = "print_page"
180
181 // Zoom
182 ActionZoomIn Action = "zoom_in"
183 ActionZoomOut Action = "zoom_out"
184 ActionZoomReset Action = "zoom_reset"
185
186 // UI
187 ActionOpenOmnibox Action = "open_omnibox"
188 ActionOpenFind Action = "open_find"
189 ActionFindNext Action = "find_next"
190 ActionFindPrev Action = "find_prev"
191 ActionCloseFind Action = "close_find"
192 ActionOpenDevTools Action = "open_devtools"
193 ActionToggleFullscreen Action = "toggle_fullscreen"
194 ActionToggleFloatingPane Action = "toggle_floating_pane"
195 ActionToggleHistorySystemView Action = "toggle_history_systemview"
196 ActionToggleFavoritesSidebar Action = "toggle_favorites_sidebar"
197 ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
198 ActionToggleConfigSystemView Action = "toggle_config_systemview"
199
200 // Clipboard
201 ActionCopyURL Action = "copy_url"
202
203 // Session management
204 ActionOpenSessionManager Action = "open_session_manager"
205
206 // Application
207 ActionQuit Action = "quit"
208
209 // Vim mode actions
210 ActionEnterVimMode Action = "enter_vim_mode"
211 ActionVimConfirm Action = "vim_confirm"
212 ActionVimScrollLeft Action = "vim_scroll_left"
213 ActionVimScrollDown Action = "vim_scroll_down"
214 ActionVimScrollUp Action = "vim_scroll_up"
215 ActionVimScrollRight Action = "vim_scroll_right"
216 ActionVimScrollDownFast Action = "vim_scroll_down_fast"
217 ActionVimScrollUpFast Action = "vim_scroll_up_fast"
218)
219
220const floatingProfileActionPrefix = "open_floating_profile:"
221const floatingProfileActionSeparator = "\u241F"
222
223// ShortcutTable maps KeyBinding to Action.
224type ShortcutTable map[KeyBinding]Action
225
226// ShortcutSet holds all shortcut tables organized by context.
227type ShortcutSet struct {
228 // Global shortcuts are always active regardless of mode.
229 Global ShortcutTable
230 // TabMode shortcuts are only active in tab mode.
231 TabMode ShortcutTable
232 // PaneMode shortcuts are only active in pane mode.
233 PaneMode ShortcutTable
234 // SessionMode shortcuts are only active in session mode.
235 SessionMode ShortcutTable
236 // ResizeMode shortcuts are only active in resize mode.
237 ResizeMode ShortcutTable
238 // VimMode shortcuts are only active in vim mode.
239 VimMode ShortcutTable
240 // PrefixMode shortcuts are one-shot actions after the tmux-style prefix.
241 PrefixMode ShortcutTable
242 // vimModeSequences holds multi-key Vim mode bindings.
243 vimModeSequences *vimkeys.Trie
244}
245
246// NewShortcutSet creates a ShortcutSet from workspace and session configuration.
247// workspace may be nil (workspace-specific shortcuts will be empty).
248// session may be nil (session mode shortcuts will be empty).
249func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *ShortcutSet {
250 log := logging.FromContext(ctx)
251 set := &ShortcutSet{
252 Global: make(ShortcutTable),
253 TabMode: make(ShortcutTable),
254 PaneMode: make(ShortcutTable),
255 SessionMode: make(ShortcutTable),
256 ResizeMode: make(ShortcutTable),
257 VimMode: make(ShortcutTable),
258 PrefixMode: make(ShortcutTable),
259 }
260
261 set.buildGlobalShortcutsFromParts(ctx, workspace, session)
262 if workspace != nil {
263 set.buildTabModeShortcuts(ctx, workspace)
264 set.buildPaneModeShortcuts(ctx, workspace)
265 set.buildResizeModeShortcuts(ctx, workspace)
266 set.buildVimModeShortcuts(ctx, workspace)
267 set.buildModeShortcuts(ctx, workspace.PrefixMode.GetKeyBindings(), set.PrefixMode, "prefix")
268 }
269 if session != nil {
270 set.buildSessionModeShortcuts(ctx, session)
271 }
272
273 log.Debug().
274 Int("global", len(set.Global)).
275 Int("tab", len(set.TabMode)).
276 Int("pane", len(set.PaneMode)).
277 Int("resize", len(set.ResizeMode)).
278 Int("session", len(set.SessionMode)).
279 Int("vim", len(set.VimMode)).
280 Int("prefix", len(set.PrefixMode)).
281 Msg("shortcuts registered")
282
283 return set
284}
285
286// buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
287func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
288 s.registerBuiltinShortcutFallbacks(workspace)
289 s.registerActivationShortcutsFromParts(ctx, workspace, session)
290 s.registerConfiguredShortcuts(workspace)
291 // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler;
292 // Alt+Shift+Tab stays here as a fallback binding.
293 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
294 s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)
295}
296
297// buildTabModeShortcuts populates tab mode shortcuts from config.
298func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
299 s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
300}
301
302// buildPaneModeShortcuts populates pane mode shortcuts from config.
303func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
304 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
305}
306
307// buildSessionModeShortcuts populates session mode shortcuts from config.
308func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *entity.SessionConfig) {
309 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
310}
311
312// buildResizeModeShortcuts populates resize mode shortcuts from config.
313func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
314 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
315}
316
317// buildVimModeShortcuts populates Vim mode shortcuts from config.
318// Trie-owned multi-key bindings are excluded from the legacy table.
319func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
320 log := logging.FromContext(ctx)
321 trie, owned := buildVimModeTrie(&cfg.VimMode)
322 s.vimModeSequences = trie
323 legacy := filterLegacyVimModeBindings(cfg.VimMode.GetKeyBindings(), owned)
324 log.Debug().
325 Int("owned", len(owned)).
326 Int("legacy", len(legacy)).
327 Msg("vim mode sequence trie built")
328 s.buildModeShortcuts(ctx, legacy, s.VimMode, "vim")
329 if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok {
330 s.VimMode[binding] = ActionEnterVimMode
331 log.Trace().
332 Str("shortcut", cfg.VimMode.ActivationShortcut).
333 Uint("keyval", binding.Keyval).
334 Uint("mod", uint(binding.Modifiers)).
335 Msg("vim mode toggle registered in vim mode table")
336 } else {
337 log.Warn().Str("shortcut", cfg.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut for vim mode table")
338 }
339}
340
341// registerActivationShortcutsFromParts registers mode activation shortcuts.
342// The zellij keymap binds one shortcut per mode; the tmux keymap binds a single
343// one-shot prefix instead of the pane, tab, resize, and session activations.
344// Vim Mode keeps its own activation in both keymaps.
345func (s *ShortcutSet) registerActivationShortcutsFromParts(
346 ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
347) {
348 if workspace != nil {
349 s.registerActivation(ctx, workspace.VimMode.ActivationShortcut, ActionEnterVimMode)
350 if workspace.Keymap == entity.KeymapTmux {
351 s.registerActivation(ctx, workspace.PrefixMode.ActivationShortcut, ActionEnterPrefixMode)
352 return
353 }
354 s.registerActivation(ctx, workspace.TabMode.ActivationShortcut, ActionEnterTabMode)
355 s.registerActivation(ctx, workspace.PaneMode.ActivationShortcut, ActionEnterPaneMode)
356 s.registerActivation(ctx, workspace.ResizeMode.ActivationShortcut, ActionEnterResizeMode)
357 }
358 if session != nil {
359 s.registerActivation(ctx, session.SessionMode.ActivationShortcut, ActionEnterSessionMode)
360 }
361}
362
363func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, action Action) {
364 log := logging.FromContext(ctx)
365 binding, ok := ParseKeyString(shortcut)
366 if !ok {
367 log.Warn().Str("shortcut", shortcut).Str("action", string(action)).Msg("failed to parse mode activation shortcut")
368 return
369 }
370 s.Global[binding] = action
371 log.Trace().
372 Str("shortcut", shortcut).
373 Str("action", string(action)).
374 Uint("keyval", binding.Keyval).
375 Uint("mod", uint(binding.Modifiers)).
376 Msg("mode activation registered")
377}
378
379// registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose
380// action is missing from the configured global shortcuts, so configs written
381// before these actions became configurable keep working until migrated.
382func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) {
383 for name, binding := range entity.BuiltinGlobalShortcuts() {
384 if cfg != nil {
385 if _, configured := cfg.Shortcuts.Actions[name]; configured {
386 continue
387 }
388 }
389 action, ok := configActionToAction[name]
390 if !ok {
391 continue
392 }
393 for _, key := range binding.Keys {
394 if kb, ok := ParseKeyString(key); ok {
395 s.Global[kb] = action
396 }
397 }
398 }
399}
400
401func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
402 if cfg == nil {
403 return
404 }
405 for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) {
406 action, ok := configActionToAction[actionName]
407 if !ok {
408 continue
409 }
410 for _, keyStr := range cfg.Shortcuts.Actions[actionName].Keys {
411 if binding, ok := ParseKeyString(keyStr); ok {
412 s.Global[binding] = action
413 }
414 }
415 }
416}
417
418// orderedGlobalShortcutActions returns action names in registration order:
419// sorted, with built-in browser actions last so they win a shared key, as
420// they did when they were hard-coded. A stable order keeps the winner of a
421// key conflict the same across restarts.
422func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {
423 builtins := entity.BuiltinGlobalShortcuts()
424 names := slices.Sorted(maps.Keys(actions))
425 slices.SortStableFunc(names, func(a, b string) int {
426 _, aBuiltin := builtins[a]
427 _, bBuiltin := builtins[b]
428 switch {
429 case aBuiltin == bBuiltin:
430 return 0
431 case aBuiltin:
432 return 1
433 default:
434 return -1
435 }
436 })
437 return names
438}
439
440func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {
441 occupied := make(map[KeyBinding]Action, len(s.Global))
442 maps.Copy(occupied, s.Global)
443
444 for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
445 s.Global[shortcut.Binding] = shortcut.Action
446 }
447}
448
449type floatingProfileShortcut struct {
450 Binding KeyBinding
451 Action Action
452}
453
454// collectFloatingProfileShortcutsFromWorkspace collects floating profile shortcuts from a workspace config.
455func collectFloatingProfileShortcutsFromWorkspace(
456 ctx context.Context,
457 workspace *entity.WorkspaceConfig,
458 occupied map[KeyBinding]Action,
459) []floatingProfileShortcut {
460 if workspace == nil {
461 return nil
462 }
463
464 log := logging.FromContext(ctx)
465 reserveGlobalOnlyShortcutBindings(occupied)
466 result := make([]floatingProfileShortcut, 0)
467 profileNames := make([]string, 0, len(workspace.FloatingPane.Profiles))
468 for profileName := range workspace.FloatingPane.Profiles {
469 profileNames = append(profileNames, profileName)
470 }
471 sort.Strings(profileNames)
472
473 for _, profileName := range profileNames {
474 profile := workspace.FloatingPane.Profiles[profileName]
475 url := strings.TrimSpace(profile.URL)
476 if url == "" {
477 log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
478 continue
479 }
480
481 action := NewFloatingProfileAction(profileName, url)
482 keys := append([]string(nil), profile.Keys...)
483 sort.Strings(keys)
484 for _, keyStr := range keys {
485 binding, ok := ParseKeyString(keyStr)
486 if !ok {
487 log.Warn().Str("profile", profileName).Str("shortcut", keyStr).Msg("failed to parse floating profile shortcut")
488 continue
489 }
490
491 if existing, exists := occupied[binding]; exists {
492 log.Warn().
493 Str("profile", profileName).
494 Str("shortcut", keyStr).
495 Str("existing_action", string(existing)).
496 Msg("floating profile shortcut conflicts with existing global shortcut, skipping")
497 continue
498 }
499
500 occupied[binding] = action
501 result = append(result, floatingProfileShortcut{Binding: binding, Action: action})
502 }
503 }
504
505 return result
506}
507
508func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
509 if occupied == nil {
510 return
511 }
512
513 tabIndexActions := []Action{
514 ActionSwitchTabIndex1,
515 ActionSwitchTabIndex2,
516 ActionSwitchTabIndex3,
517 ActionSwitchTabIndex4,
518 ActionSwitchTabIndex5,
519 ActionSwitchTabIndex6,
520 ActionSwitchTabIndex7,
521 ActionSwitchTabIndex8,
522 ActionSwitchTabIndex9,
523 }
524
525 // Assumes gdk.KEY_1..gdk.KEY_9 are contiguous (ASCII '1'..'9').
526 // If that ever changes on a platform, map digits explicitly.
527 for i, action := range tabIndexActions {
528 binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
529 if _, exists := occupied[binding]; exists {
530 continue
531 }
532 occupied[binding] = action
533 }
534
535 altZero := KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModAlt}
536 if _, exists := occupied[altZero]; !exists {
537 occupied[altZero] = ActionSwitchTabIndex10
538 }
539
540 altTab := KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}
541 if _, exists := occupied[altTab]; !exists {
542 occupied[altTab] = ActionSwitchLastTab
543 }
544}
545
546func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
547 log := logging.FromContext(ctx)
548 var registered, parseErrors, unknownActions int
549 for key, configAction := range bindings {
550 if binding, ok := ParseKeyString(key); ok {
551 if action := mapModeConfigAction(configAction, mode); action != "" {
552 dest[binding] = action
553 registered++
554 log.Trace().
555 Str("key", key).
556 Str("configAction", configAction).
557 Str("action", string(action)).
558 Uint("keyval", binding.Keyval).
559 Msg(mode + " mode shortcut registered")
560 } else {
561 unknownActions++
562 log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in " + mode + " mode")
563 }
564 } else {
565 parseErrors++
566 log.Warn().Str("key", key).Msg("failed to parse " + mode + " mode key")
567 }
568 }
569 log.Debug().
570 Int("registered", registered).
571 Int("parseErrors", parseErrors).
572 Int("unknownActions", unknownActions).
573 Msg(mode + " mode shortcuts built")
574}
575
576var configActionToAction = map[string]Action{
577 "toggle_floating_pane": ActionToggleFloatingPane,
578 "toggle-floating-pane": ActionToggleFloatingPane,
579 "toggle_history_systemview": ActionToggleHistorySystemView,
580 "toggle-history-systemview": ActionToggleHistorySystemView,
581 "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
582 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
583 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
584 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
585 "toggle_config_systemview": ActionToggleConfigSystemView,
586 "toggle-config-systemview": ActionToggleConfigSystemView,
587
588 // Tab actions
589 "new_tab": ActionNewTab,
590 "new-tab": ActionNewTab,
591 "close_tab": ActionCloseTab,
592 "close-tab": ActionCloseTab,
593 "next_tab": ActionNextTab,
594 "next-tab": ActionNextTab,
595 "previous_tab": ActionPreviousTab,
596 "previous-tab": ActionPreviousTab,
597 "rename_tab": ActionRenameTab,
598 "rename-tab": ActionRenameTab,
599
600 // Pane actions
601 "split_right": ActionSplitRight,
602 "split-right": ActionSplitRight,
603 "split_left": ActionSplitLeft,
604 "split-left": ActionSplitLeft,
605 "split_up": ActionSplitUp,
606 "split-up": ActionSplitUp,
607 "split_down": ActionSplitDown,
608 "split-down": ActionSplitDown,
609 "close_pane": ActionClosePane,
610 "close-pane": ActionClosePane,
611 "stack_pane": ActionStackPane,
612 "stack-pane": ActionStackPane,
613 "move_pane_to_tab": ActionMovePaneToTab,
614 "move-pane-to-tab": ActionMovePaneToTab,
615 "move_pane_to_next_tab": ActionMovePaneToNextTab,
616 "move-pane-to-next-tab": ActionMovePaneToNextTab,
617 "eject_pane_to_window": ActionEjectPaneToWindow,
618 "eject-pane-to-window": ActionEjectPaneToWindow,
619
620 "consume_or_expel_left": ActionConsumeOrExpelLeft,
621 "consume-or-expel-left": ActionConsumeOrExpelLeft,
622 "consume_or_expel_right": ActionConsumeOrExpelRight,
623 "consume-or-expel-right": ActionConsumeOrExpelRight,
624 "consume_or_expel_up": ActionConsumeOrExpelUp,
625 "consume-or-expel-up": ActionConsumeOrExpelUp,
626 "consume_or_expel_down": ActionConsumeOrExpelDown,
627 "consume-or-expel-down": ActionConsumeOrExpelDown,
628
629 // Focus navigation
630 "focus-right": ActionFocusRight,
631 "focus-left": ActionFocusLeft,
632 "focus-up": ActionFocusUp,
633 "focus-down": ActionFocusDown,
634
635 // Stack navigation
636 "stack-nav-up": ActionStackNavUp,
637 "stack-up": ActionStackNavUp,
638 "stack-nav-down": ActionStackNavDown,
639 "stack-down": ActionStackNavDown,
640
641 // Resize actions
642 "resize-increase-left": ActionResizeIncreaseLeft,
643 "resize-increase-right": ActionResizeIncreaseRight,
644 "resize-increase-up": ActionResizeIncreaseUp,
645 "resize-increase-down": ActionResizeIncreaseDown,
646 "resize-decrease-left": ActionResizeDecreaseLeft,
647 "resize-decrease-right": ActionResizeDecreaseRight,
648 "resize-decrease-up": ActionResizeDecreaseUp,
649 "resize-decrease-down": ActionResizeDecreaseDown,
650 "resize-increase": ActionResizeIncrease,
651 "resize-decrease": ActionResizeDecrease,
652
653 // Session actions
654 "session-manager": ActionOpenSessionManager,
655
656 // Browser actions
657 "open-omnibox": ActionOpenOmnibox,
658 "open-find": ActionOpenFind,
659 "find-next": ActionFindNext,
660 "find-prev": ActionFindPrev,
661 "reload": ActionReload,
662 "hard-reload": ActionHardReload,
663 "open-devtools": ActionOpenDevTools,
664 "go-back": ActionGoBack,
665 "go-forward": ActionGoForward,
666 "zoom-in": ActionZoomIn,
667 "zoom-out": ActionZoomOut,
668 "zoom-reset": ActionZoomReset,
669 "quit": ActionQuit,
670 "toggle-fullscreen": ActionToggleFullscreen,
671 "copy-url": ActionCopyURL,
672 "print-page": ActionPrintPage,
673
674 // Mode activation (used by the tmux prefix)
675 "enter-pane-mode": ActionEnterPaneMode,
676 "enter-tab-mode": ActionEnterTabMode,
677 "enter-resize-mode": ActionEnterResizeMode,
678 "enter-session-mode": ActionEnterSessionMode,
679 "enter-vim-mode": ActionEnterVimMode,
680
681 // Vim mode scroll actions
682 "vim_scroll_left": ActionVimScrollLeft,
683 "vim-scroll-left": ActionVimScrollLeft,
684 "vim_scroll_down": ActionVimScrollDown,
685 "vim-scroll-down": ActionVimScrollDown,
686 "vim_scroll_up": ActionVimScrollUp,
687 "vim-scroll-up": ActionVimScrollUp,
688 "vim_scroll_right": ActionVimScrollRight,
689 "vim-scroll-right": ActionVimScrollRight,
690 "vim_scroll_down_fast": ActionVimScrollDownFast,
691 "vim-scroll-down-fast": ActionVimScrollDownFast,
692 "vim_scroll_up_fast": ActionVimScrollUpFast,
693 "vim-scroll-up-fast": ActionVimScrollUpFast,
694}
695
696// FloatingProfileTarget carries the session identity and URL for a floating profile action.
697type FloatingProfileTarget struct {
698 SessionID string
699 URL string
700}
701
702// NewFloatingProfileAction builds an action value carrying a floating profile session and URL.
703func NewFloatingProfileAction(profileName, url string) Action {
704 sessionID := strings.TrimSpace(profileName)
705 trimmedURL := strings.TrimSpace(url)
706 if sessionID == "" {
707 sessionID = trimmedURL
708 }
709 return Action(floatingProfileActionPrefix + sessionID + floatingProfileActionSeparator + trimmedURL)
710}
711
712// ParseFloatingProfileTarget extracts the session identity and target URL.
713func ParseFloatingProfileTarget(action Action) (FloatingProfileTarget, bool) {
714 value := string(action)
715 if !strings.HasPrefix(value, floatingProfileActionPrefix) {
716 return FloatingProfileTarget{}, false
717 }
718
719 payload := strings.TrimPrefix(value, floatingProfileActionPrefix)
720 sessionID := ""
721 url := payload
722 if parts := strings.SplitN(payload, floatingProfileActionSeparator, 2); len(parts) == 2 {
723 sessionID = strings.TrimSpace(parts[0])
724 url = parts[1]
725 }
726
727 url = strings.TrimSpace(url)
728 if url == "" {
729 return FloatingProfileTarget{}, false
730 }
731 if sessionID == "" {
732 sessionID = url
733 }
734
735 return FloatingProfileTarget{SessionID: sessionID, URL: url}, true
736}
737
738// ParseFloatingProfileAction extracts the target URL from a floating profile action.
739func ParseFloatingProfileAction(action Action) (string, bool) {
740 target, ok := ParseFloatingProfileTarget(action)
741 if !ok {
742 return "", false
743 }
744 return target.URL, true
745}
746
747func mapModeConfigAction(configAction, mode string) Action {
748 if mode == "vim" && configAction == "confirm" {
749 return ActionVimConfirm
750 }
751 return mapConfigAction(configAction)
752}
753
754// mapConfigAction maps config action names to Action constants.
755func mapConfigAction(configAction string) Action {
756 if configAction == "cancel" || configAction == "confirm" {
757 return ActionExitMode
758 }
759 return configActionToAction[configAction]
760}
761
762// ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
763// Returns false if the string cannot be parsed.
764func ParseKeyString(s string) (KeyBinding, bool) {
765 if s == "" {
766 return KeyBinding{}, false
767 }
768
769 s = strings.TrimSpace(s)
770 if s == "" {
771 return KeyBinding{}, false
772 }
773 if s == "+" {
774 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
775 }
776
777 parts := strings.Split(s, "+")
778
779 var modifiers Modifier
780 var keyPart string
781
782 for _, part := range parts {
783 part = strings.TrimSpace(part)
784 if part == "" {
785 continue
786 }
787
788 lower := strings.ToLower(part)
789 switch lower {
790 case "ctrl", "control":
791 modifiers |= ModCtrl
792 case "shift":
793 modifiers |= ModShift
794 case "alt":
795 modifiers |= ModAlt
796 default:
797 if keyPart != "" {
798 return KeyBinding{}, false
799 }
800 keyPart = part
801 }
802 }
803
804 // Allow parsing "ctrl++" / "alt+shift++" where the key is "+".
805 if keyPart == "" && strings.HasSuffix(s, "++") {
806 keyPart = "+"
807 }
808
809 if keyPart == "" {
810 return KeyBinding{}, false
811 }
812
813 // Treat uppercase single-letter keys as Shift+<letter>.
814 if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
815 modifiers |= ModShift
816 keyPart = strings.ToLower(keyPart)
817 }
818
819 keyval, ok := stringToKeyval(keyPart)
820 if !ok {
821 return KeyBinding{}, false
822 }
823
824 return KeyBinding{
825 Keyval: keyval,
826 Modifiers: modifiers,
827 }, true
828}
829
830// stringToKeyval converts a key name to its GDK keyval.
831func stringToKeyval(s string) (uint, bool) {
832 if s == "" {
833 return 0, false
834 }
835
836 if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
837 return keyval, true
838 }
839
840 // Single printable ASCII keys (a-z, symbols such as %, ", &).
841 // GDK keyvals for printable ASCII match their character codes.
842 if len(s) == 1 && s[0] > ' ' && s[0] <= '~' {
843 return uint(s[0]), true
844 }
845
846 return 0, false
847}
848
849// Lookup finds an action for the given key binding in the appropriate table.
850// It first checks the mode-specific table, then falls back to global unless
851// the current mode is Vim Mode. Vim Mode intentionally stays self-contained
852// so app-global shortcuts do not interrupt held Vim-style scrolling.
853func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
854 // Normalize the binding modifiers
855 binding.Modifiers &= modifierMask
856
857 // Check mode-specific table first
858 var modeTable ShortcutTable
859 switch mode {
860 case ModeTab:
861 modeTable = s.TabMode
862 case ModePane:
863 modeTable = s.PaneMode
864 case ModeResize:
865 modeTable = s.ResizeMode
866 case ModeSession:
867 modeTable = s.SessionMode
868 case ModeVim:
869 modeTable = s.VimMode
870 case ModePrefix:
871 modeTable = s.PrefixMode
872 }
873
874 if modeTable != nil {
875 if action, ok := modeTable[binding]; ok {
876 return action, true
877 }
878 // Shifted symbols (%, ", {, ...) arrive with Shift held but are
879 // configured without it, since the symbol already implies Shift.
880 if isShiftedSymbol(binding) {
881 if action, ok := modeTable[KeyBinding{Keyval: binding.Keyval}]; ok {
882 return action, true
883 }
884 }
885 // International layouts deliver some symbols as dead keys.
886 if symbol, ok := deadKeySymbols[binding.Keyval]; ok {
887 if action, ok := modeTable[KeyBinding{Keyval: symbol}]; ok {
888 return action, true
889 }
890 }
891 if mode == ModeVim {
892 return "", false
893 }
894 }
895
896 // Fall back to global
897 if action, ok := s.Global[binding]; ok {
898 return action, true
899 }
900
901 return "", false
902}
903
904// deadKeySymbols maps dead keysyms to the ASCII symbol they stand for.
905var deadKeySymbols = map[uint]uint{
906 uint(gdk.KEY_dead_diaeresis): '"',
907 uint(gdk.KEY_dead_acute): '\'',
908 uint(gdk.KEY_dead_grave): '`',
909 uint(gdk.KEY_dead_circumflex): '^',
910 uint(gdk.KEY_dead_tilde): '~',
911}
912
913// isShiftedSymbol reports a Shift-only binding on a printable ASCII symbol.
914func isShiftedSymbol(binding KeyBinding) bool {
915 k := binding.Keyval
916 isSymbol := k > ' ' && k <= '~' && (k < 'a' || k > 'z') && (k < 'A' || k > 'Z')
917 return binding.Modifiers == ModShift && isSymbol
918}
919
920// ShouldAutoExitMode returns true if the action should cause modal mode to exit.
921func ShouldAutoExitMode(action Action) bool {
922 switch action {
923 case ActionNewTab, ActionCloseTab, ActionRenameTab,
924 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
925 ActionClosePane, ActionStackPane,
926 ActionMovePaneToTab, ActionMovePaneToNextTab, ActionEjectPaneToWindow,
927 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
928 ActionOpenSessionManager:
929 return true
930 default:
931 return false
932 }
933}