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