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