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