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 ActionVimScrollLeft Action = "vim_scroll_left"
210 ActionVimScrollDown Action = "vim_scroll_down"
211 ActionVimScrollUp Action = "vim_scroll_up"
212 ActionVimScrollRight Action = "vim_scroll_right"
213 ActionVimScrollDownFast Action = "vim_scroll_down_fast"
214 ActionVimScrollUpFast Action = "vim_scroll_up_fast"
215)
216
217const floatingProfileActionPrefix = "open_floating_profile:"
218const floatingProfileActionSeparator = "\u241F"
219
220// ShortcutTable maps KeyBinding to Action.
221type ShortcutTable map[KeyBinding]Action
222
223// ShortcutSet holds all shortcut tables organized by context.
224type ShortcutSet struct {
225 // Global shortcuts are always active regardless of mode.
226 Global ShortcutTable
227 // TabMode shortcuts are only active in tab mode.
228 TabMode ShortcutTable
229 // PaneMode shortcuts are only active in pane mode.
230 PaneMode ShortcutTable
231 // SessionMode shortcuts are only active in session mode.
232 SessionMode ShortcutTable
233 // ResizeMode shortcuts are only active in resize mode.
234 ResizeMode ShortcutTable
235 // VimMode shortcuts are only active in vim mode.
236 VimMode ShortcutTable
237 // vimModeSequences holds multi-key Vim mode bindings.
238 vimModeSequences *vimkeys.Trie
239}
240
241// NewShortcutSet creates a ShortcutSet from workspace and session configuration.
242// workspace may be nil (workspace-specific shortcuts will be empty).
243// session may be nil (session mode shortcuts will be empty).
244func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *ShortcutSet {
245 log := logging.FromContext(ctx)
246 set := &ShortcutSet{
247 Global: make(ShortcutTable),
248 TabMode: make(ShortcutTable),
249 PaneMode: make(ShortcutTable),
250 SessionMode: make(ShortcutTable),
251 ResizeMode: make(ShortcutTable),
252 VimMode: make(ShortcutTable),
253 }
254
255 set.buildGlobalShortcutsFromParts(ctx, workspace, session)
256 if workspace != nil {
257 set.buildTabModeShortcuts(ctx, workspace)
258 set.buildPaneModeShortcuts(ctx, workspace)
259 set.buildResizeModeShortcuts(ctx, workspace)
260 set.buildVimModeShortcuts(ctx, workspace)
261 }
262 if session != nil {
263 set.buildSessionModeShortcuts(ctx, session)
264 }
265
266 log.Debug().
267 Int("global", len(set.Global)).
268 Int("tab", len(set.TabMode)).
269 Int("pane", len(set.PaneMode)).
270 Int("resize", len(set.ResizeMode)).
271 Int("session", len(set.SessionMode)).
272 Int("vim", len(set.VimMode)).
273 Msg("shortcuts registered")
274
275 return set
276}
277
278// buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
279func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
280 s.registerActivationShortcutsFromParts(ctx, workspace, session)
281 s.registerConfiguredShortcuts(workspace)
282 s.registerStandardShortcuts()
283 s.registerPaneNavigationShortcuts()
284 s.registerTabSwitchShortcuts()
285 s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)
286}
287
288// buildTabModeShortcuts populates tab mode shortcuts from config.
289func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
290 s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
291}
292
293// buildPaneModeShortcuts populates pane mode shortcuts from config.
294func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
295 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
296}
297
298// buildSessionModeShortcuts populates session mode shortcuts from config.
299func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *entity.SessionConfig) {
300 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
301}
302
303// buildResizeModeShortcuts populates resize mode shortcuts from config.
304func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
305 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
306}
307
308// buildVimModeShortcuts populates Vim mode shortcuts from config.
309// Trie-owned multi-key bindings are excluded from the legacy table.
310func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
311 log := logging.FromContext(ctx)
312 trie, owned := buildVimModeTrie(&cfg.VimMode)
313 s.vimModeSequences = trie
314 legacy := filterLegacyVimModeBindings(cfg.VimMode.GetKeyBindings(), owned)
315 log.Debug().
316 Int("owned", len(owned)).
317 Int("legacy", len(legacy)).
318 Msg("vim mode sequence trie built")
319 s.buildModeShortcuts(ctx, legacy, s.VimMode, "vim")
320 if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok {
321 s.VimMode[binding] = ActionEnterVimMode
322 log.Trace().
323 Str("shortcut", cfg.VimMode.ActivationShortcut).
324 Uint("keyval", binding.Keyval).
325 Uint("mod", uint(binding.Modifiers)).
326 Msg("vim mode toggle registered in vim mode table")
327 } else {
328 log.Warn().Str("shortcut", cfg.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut for vim mode table")
329 }
330}
331
332func (s *ShortcutSet) registerActivationShortcutsFromParts(
333 ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
334) {
335 log := logging.FromContext(ctx)
336 if workspace == nil {
337 // Still register session mode if available.
338 if session != nil {
339 if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
340 s.Global[binding] = ActionEnterSessionMode
341 log.Trace().
342 Str("shortcut", session.SessionMode.ActivationShortcut).
343 Uint("keyval", binding.Keyval).
344 Uint("mod", uint(binding.Modifiers)).
345 Msg("session mode activation registered")
346 } else {
347 log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
348 }
349 }
350 return
351 }
352 if binding, ok := ParseKeyString(workspace.VimMode.ActivationShortcut); ok {
353 s.Global[binding] = ActionEnterVimMode
354 log.Trace().
355 Str("shortcut", workspace.VimMode.ActivationShortcut).
356 Uint("keyval", binding.Keyval).
357 Uint("mod", uint(binding.Modifiers)).
358 Msg("vim mode activation registered")
359 } else {
360 log.Warn().Str("shortcut", workspace.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut")
361 }
362 if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
363 s.Global[binding] = ActionEnterTabMode
364 log.Trace().
365 Str("shortcut", workspace.TabMode.ActivationShortcut).
366 Uint("keyval", binding.Keyval).
367 Uint("mod", uint(binding.Modifiers)).
368 Msg("tab mode activation registered")
369 } else {
370 log.Warn().Str("shortcut", workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
371 }
372 if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok {
373 s.Global[binding] = ActionEnterPaneMode
374 log.Trace().
375 Str("shortcut", workspace.PaneMode.ActivationShortcut).
376 Uint("keyval", binding.Keyval).
377 Uint("mod", uint(binding.Modifiers)).
378 Msg("pane mode activation registered")
379 } else {
380 log.Warn().Str("shortcut", workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
381 }
382 if session != nil {
383 if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
384 s.Global[binding] = ActionEnterSessionMode
385 log.Trace().
386 Str("shortcut", session.SessionMode.ActivationShortcut).
387 Uint("keyval", binding.Keyval).
388 Uint("mod", uint(binding.Modifiers)).
389 Msg("session mode activation registered")
390 } else {
391 log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
392 }
393 }
394 if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok {
395 s.Global[binding] = ActionEnterResizeMode
396 log.Trace().
397 Str("shortcut", workspace.ResizeMode.ActivationShortcut).
398 Uint("keyval", binding.Keyval).
399 Uint("mod", uint(binding.Modifiers)).
400 Msg("resize mode activation registered")
401 } else {
402 log.Warn().Str("shortcut", workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
403 }
404}
405
406func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
407 if cfg == nil {
408 return
409 }
410 // Note: Ctrl+T is NOT registered globally - it enters tab mode.
411 // In tab mode, use:
412 // n = new tab
413 // x = close tab
414 // l/tab = next tab
415 // h/shift+tab = previous tab
416 // r = rename tab
417 // This follows Zellij-style modal keyboard interface.
418 //
419 // However, these standard browser shortcuts ARE global.
420 for actionName, actionBinding := range cfg.Shortcuts.Actions {
421 action, ok := configActionToAction[actionName]
422 if !ok {
423 continue
424 }
425 for _, keyStr := range actionBinding.Keys {
426 if binding, ok := ParseKeyString(keyStr); ok {
427 s.Global[binding] = action
428 }
429 }
430 }
431}
432
433func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {
434 occupied := make(map[KeyBinding]Action, len(s.Global))
435 maps.Copy(occupied, s.Global)
436
437 for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
438 s.Global[shortcut.Binding] = shortcut.Action
439 }
440}
441
442type floatingProfileShortcut struct {
443 Binding KeyBinding
444 Action Action
445}
446
447// collectFloatingProfileShortcutsFromWorkspace collects floating profile shortcuts from a workspace config.
448func collectFloatingProfileShortcutsFromWorkspace(
449 ctx context.Context,
450 workspace *entity.WorkspaceConfig,
451 occupied map[KeyBinding]Action,
452) []floatingProfileShortcut {
453 if workspace == nil {
454 return nil
455 }
456
457 log := logging.FromContext(ctx)
458 reserveGlobalOnlyShortcutBindings(occupied)
459 result := make([]floatingProfileShortcut, 0)
460 profileNames := make([]string, 0, len(workspace.FloatingPane.Profiles))
461 for profileName := range workspace.FloatingPane.Profiles {
462 profileNames = append(profileNames, profileName)
463 }
464 sort.Strings(profileNames)
465
466 for _, profileName := range profileNames {
467 profile := workspace.FloatingPane.Profiles[profileName]
468 url := strings.TrimSpace(profile.URL)
469 if url == "" {
470 log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
471 continue
472 }
473
474 action := NewFloatingProfileAction(profileName, url)
475 keys := append([]string(nil), profile.Keys...)
476 sort.Strings(keys)
477 for _, keyStr := range keys {
478 binding, ok := ParseKeyString(keyStr)
479 if !ok {
480 log.Warn().Str("profile", profileName).Str("shortcut", keyStr).Msg("failed to parse floating profile shortcut")
481 continue
482 }
483
484 if existing, exists := occupied[binding]; exists {
485 log.Warn().
486 Str("profile", profileName).
487 Str("shortcut", keyStr).
488 Str("existing_action", string(existing)).
489 Msg("floating profile shortcut conflicts with existing global shortcut, skipping")
490 continue
491 }
492
493 occupied[binding] = action
494 result = append(result, floatingProfileShortcut{Binding: binding, Action: action})
495 }
496 }
497
498 return result
499}
500
501func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
502 if occupied == nil {
503 return
504 }
505
506 tabIndexActions := []Action{
507 ActionSwitchTabIndex1,
508 ActionSwitchTabIndex2,
509 ActionSwitchTabIndex3,
510 ActionSwitchTabIndex4,
511 ActionSwitchTabIndex5,
512 ActionSwitchTabIndex6,
513 ActionSwitchTabIndex7,
514 ActionSwitchTabIndex8,
515 ActionSwitchTabIndex9,
516 }
517
518 // Assumes gdk.KEY_1..gdk.KEY_9 are contiguous (ASCII '1'..'9').
519 // If that ever changes on a platform, map digits explicitly.
520 for i, action := range tabIndexActions {
521 binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
522 if _, exists := occupied[binding]; exists {
523 continue
524 }
525 occupied[binding] = action
526 }
527
528 altZero := KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModAlt}
529 if _, exists := occupied[altZero]; !exists {
530 occupied[altZero] = ActionSwitchTabIndex10
531 }
532
533 altTab := KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}
534 if _, exists := occupied[altTab]; !exists {
535 occupied[altTab] = ActionSwitchLastTab
536 }
537}
538
539func (s *ShortcutSet) registerStandardShortcuts() {
540 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
541 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
542 s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
543 s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev
544 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
545 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
546 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
547 s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
548 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
549 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
550 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
551 s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack
552 s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward
553 s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
554 s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
555 s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
556 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
557 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
558 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
559 s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
560 s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage
561 // Session management - direct shortcut to open session manager
562 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
563}
564
565func (s *ShortcutSet) registerPaneNavigationShortcuts() {
566 s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
567 s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
568 s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
569 s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
570
571 s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft
572 s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight
573 s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
574 s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
575}
576
577func (s *ShortcutSet) registerTabSwitchShortcuts() {
578 // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
579 // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
580 // This is necessary because WebKitGTK's WebView consumes these key events
581 // before they reach the EventControllerKey in capture phase.
582 //
583 // Only Alt+Shift+Tab remains here as a fallback binding.
584 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
585}
586
587func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
588 log := logging.FromContext(ctx)
589 var registered, parseErrors, unknownActions int
590 for key, configAction := range bindings {
591 if binding, ok := ParseKeyString(key); ok {
592 if action := mapConfigAction(configAction); action != "" {
593 dest[binding] = action
594 registered++
595 log.Trace().
596 Str("key", key).
597 Str("configAction", configAction).
598 Str("action", string(action)).
599 Uint("keyval", binding.Keyval).
600 Msg(mode + " mode shortcut registered")
601 } else {
602 unknownActions++
603 log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in " + mode + " mode")
604 }
605 } else {
606 parseErrors++
607 log.Warn().Str("key", key).Msg("failed to parse " + mode + " mode key")
608 }
609 }
610 log.Debug().
611 Int("registered", registered).
612 Int("parseErrors", parseErrors).
613 Int("unknownActions", unknownActions).
614 Msg(mode + " mode shortcuts built")
615}
616
617var configActionToAction = map[string]Action{
618 "toggle_floating_pane": ActionToggleFloatingPane,
619 "toggle-floating-pane": ActionToggleFloatingPane,
620 "toggle_history_systemview": ActionToggleHistorySystemView,
621 "toggle-history-systemview": ActionToggleHistorySystemView,
622 "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
623 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
624 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
625 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
626 "toggle_config_systemview": ActionToggleConfigSystemView,
627 "toggle-config-systemview": ActionToggleConfigSystemView,
628
629 // Tab actions
630 "new_tab": ActionNewTab,
631 "new-tab": ActionNewTab,
632 "close_tab": ActionCloseTab,
633 "close-tab": ActionCloseTab,
634 "next_tab": ActionNextTab,
635 "next-tab": ActionNextTab,
636 "previous_tab": ActionPreviousTab,
637 "previous-tab": ActionPreviousTab,
638 "rename_tab": ActionRenameTab,
639 "rename-tab": ActionRenameTab,
640
641 // Pane actions
642 "split_right": ActionSplitRight,
643 "split-right": ActionSplitRight,
644 "split_left": ActionSplitLeft,
645 "split-left": ActionSplitLeft,
646 "split_up": ActionSplitUp,
647 "split-up": ActionSplitUp,
648 "split_down": ActionSplitDown,
649 "split-down": ActionSplitDown,
650 "close_pane": ActionClosePane,
651 "close-pane": ActionClosePane,
652 "stack_pane": ActionStackPane,
653 "stack-pane": ActionStackPane,
654 "move_pane_to_tab": ActionMovePaneToTab,
655 "move-pane-to-tab": ActionMovePaneToTab,
656 "move_pane_to_next_tab": ActionMovePaneToNextTab,
657 "move-pane-to-next-tab": ActionMovePaneToNextTab,
658 "eject_pane_to_window": ActionEjectPaneToWindow,
659 "eject-pane-to-window": ActionEjectPaneToWindow,
660
661 "consume_or_expel_left": ActionConsumeOrExpelLeft,
662 "consume-or-expel-left": ActionConsumeOrExpelLeft,
663 "consume_or_expel_right": ActionConsumeOrExpelRight,
664 "consume-or-expel-right": ActionConsumeOrExpelRight,
665 "consume_or_expel_up": ActionConsumeOrExpelUp,
666 "consume-or-expel-up": ActionConsumeOrExpelUp,
667 "consume_or_expel_down": ActionConsumeOrExpelDown,
668 "consume-or-expel-down": ActionConsumeOrExpelDown,
669
670 // Focus navigation
671 "focus-right": ActionFocusRight,
672 "focus-left": ActionFocusLeft,
673 "focus-up": ActionFocusUp,
674 "focus-down": ActionFocusDown,
675
676 // Stack navigation
677 "stack-nav-up": ActionStackNavUp,
678 "stack-up": ActionStackNavUp,
679 "stack-nav-down": ActionStackNavDown,
680 "stack-down": ActionStackNavDown,
681
682 // Resize actions
683 "resize-increase-left": ActionResizeIncreaseLeft,
684 "resize-increase-right": ActionResizeIncreaseRight,
685 "resize-increase-up": ActionResizeIncreaseUp,
686 "resize-increase-down": ActionResizeIncreaseDown,
687 "resize-decrease-left": ActionResizeDecreaseLeft,
688 "resize-decrease-right": ActionResizeDecreaseRight,
689 "resize-decrease-up": ActionResizeDecreaseUp,
690 "resize-decrease-down": ActionResizeDecreaseDown,
691 "resize-increase": ActionResizeIncrease,
692 "resize-decrease": ActionResizeDecrease,
693
694 // Session actions
695 "session-manager": ActionOpenSessionManager,
696
697 // Vim mode scroll actions
698 "vim_scroll_left": ActionVimScrollLeft,
699 "vim-scroll-left": ActionVimScrollLeft,
700 "vim_scroll_down": ActionVimScrollDown,
701 "vim-scroll-down": ActionVimScrollDown,
702 "vim_scroll_up": ActionVimScrollUp,
703 "vim-scroll-up": ActionVimScrollUp,
704 "vim_scroll_right": ActionVimScrollRight,
705 "vim-scroll-right": ActionVimScrollRight,
706 "vim_scroll_down_fast": ActionVimScrollDownFast,
707 "vim-scroll-down-fast": ActionVimScrollDownFast,
708 "vim_scroll_up_fast": ActionVimScrollUpFast,
709 "vim-scroll-up-fast": ActionVimScrollUpFast,
710}
711
712// FloatingProfileTarget carries the session identity and URL for a floating profile action.
713type FloatingProfileTarget struct {
714 SessionID string
715 URL string
716}
717
718// NewFloatingProfileAction builds an action value carrying a floating profile session and URL.
719func NewFloatingProfileAction(profileName, url string) Action {
720 sessionID := strings.TrimSpace(profileName)
721 trimmedURL := strings.TrimSpace(url)
722 if sessionID == "" {
723 sessionID = trimmedURL
724 }
725 return Action(floatingProfileActionPrefix + sessionID + floatingProfileActionSeparator + trimmedURL)
726}
727
728// ParseFloatingProfileTarget extracts the session identity and target URL.
729func ParseFloatingProfileTarget(action Action) (FloatingProfileTarget, bool) {
730 value := string(action)
731 if !strings.HasPrefix(value, floatingProfileActionPrefix) {
732 return FloatingProfileTarget{}, false
733 }
734
735 payload := strings.TrimPrefix(value, floatingProfileActionPrefix)
736 sessionID := ""
737 url := payload
738 if parts := strings.SplitN(payload, floatingProfileActionSeparator, 2); len(parts) == 2 {
739 sessionID = strings.TrimSpace(parts[0])
740 url = parts[1]
741 }
742
743 url = strings.TrimSpace(url)
744 if url == "" {
745 return FloatingProfileTarget{}, false
746 }
747 if sessionID == "" {
748 sessionID = url
749 }
750
751 return FloatingProfileTarget{SessionID: sessionID, URL: url}, true
752}
753
754// ParseFloatingProfileAction extracts the target URL from a floating profile action.
755func ParseFloatingProfileAction(action Action) (string, bool) {
756 target, ok := ParseFloatingProfileTarget(action)
757 if !ok {
758 return "", false
759 }
760 return target.URL, true
761}
762
763// mapConfigAction maps config action names to Action constants.
764func mapConfigAction(configAction string) Action {
765 if configAction == "cancel" || configAction == "confirm" {
766 return ActionExitMode
767 }
768 return configActionToAction[configAction]
769}
770
771// ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
772// Returns false if the string cannot be parsed.
773func ParseKeyString(s string) (KeyBinding, bool) {
774 if s == "" {
775 return KeyBinding{}, false
776 }
777
778 s = strings.TrimSpace(s)
779 if s == "" {
780 return KeyBinding{}, false
781 }
782 if s == "+" {
783 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
784 }
785
786 parts := strings.Split(s, "+")
787
788 var modifiers Modifier
789 var keyPart string
790
791 for _, part := range parts {
792 part = strings.TrimSpace(part)
793 if part == "" {
794 continue
795 }
796
797 lower := strings.ToLower(part)
798 switch lower {
799 case "ctrl", "control":
800 modifiers |= ModCtrl
801 case "shift":
802 modifiers |= ModShift
803 case "alt":
804 modifiers |= ModAlt
805 default:
806 if keyPart != "" {
807 return KeyBinding{}, false
808 }
809 keyPart = part
810 }
811 }
812
813 // Allow parsing "ctrl++" / "alt+shift++" where the key is "+".
814 if keyPart == "" && strings.HasSuffix(s, "++") {
815 keyPart = "+"
816 }
817
818 if keyPart == "" {
819 return KeyBinding{}, false
820 }
821
822 // Treat uppercase single-letter keys as Shift+<letter>.
823 if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
824 modifiers |= ModShift
825 keyPart = strings.ToLower(keyPart)
826 }
827
828 keyval, ok := stringToKeyval(keyPart)
829 if !ok {
830 return KeyBinding{}, false
831 }
832
833 return KeyBinding{
834 Keyval: keyval,
835 Modifiers: modifiers,
836 }, true
837}
838
839// stringToKeyval converts a key name to its GDK keyval.
840func stringToKeyval(s string) (uint, bool) {
841 if s == "" {
842 return 0, false
843 }
844
845 if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
846 return keyval, true
847 }
848
849 // Single letter keys (a-z)
850 if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' {
851 // ASCII lowercase a=97, which matches gdk.KEY_a
852 return uint(s[0]), true
853 }
854
855 return 0, false
856}
857
858// Lookup finds an action for the given key binding in the appropriate table.
859// It first checks the mode-specific table, then falls back to global unless
860// the current mode is Vim Mode. Vim Mode intentionally stays self-contained
861// so app-global shortcuts do not interrupt held Vim-style scrolling.
862func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
863 // Normalize the binding modifiers
864 binding.Modifiers &= modifierMask
865
866 // Check mode-specific table first
867 var modeTable ShortcutTable
868 switch mode {
869 case ModeTab:
870 modeTable = s.TabMode
871 case ModePane:
872 modeTable = s.PaneMode
873 case ModeResize:
874 modeTable = s.ResizeMode
875 case ModeSession:
876 modeTable = s.SessionMode
877 case ModeVim:
878 modeTable = s.VimMode
879 }
880
881 if modeTable != nil {
882 if action, ok := modeTable[binding]; ok {
883 return action, true
884 }
885 if mode == ModeVim {
886 return "", false
887 }
888 }
889
890 // Fall back to global
891 if action, ok := s.Global[binding]; ok {
892 return action, true
893 }
894
895 return "", false
896}
897
898// ShouldAutoExitMode returns true if the action should cause modal mode to exit.
899func ShouldAutoExitMode(action Action) bool {
900 switch action {
901 case ActionNewTab, ActionCloseTab, ActionRenameTab,
902 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
903 ActionClosePane, ActionStackPane,
904 ActionMovePaneToTab, ActionMovePaneToNextTab, ActionEjectPaneToWindow,
905 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
906 ActionOpenSessionManager:
907 return true
908 default:
909 return false
910 }
911}