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