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