fix: restore app shortcuts when WebView has focus
Register all app-reserved shortcuts (Ctrl+P, Ctrl+L, Ctrl+T, etc.) as GTK global shortcuts so they intercept before WebView DOM handlers. Switch keyboard controller to capture phase during modal modes so plain keys (s, h for pane mode) reach the handler before WebView. Fixes regression from PR #187 where bubble-phase change for dead-key support caused web pages to steal app shortcuts (e.g. GitHub command palette on Ctrl+P). Also fixes thread safety: phase switching uses glib.IdleAdd for GTK main thread dispatch, and config reads are protected by RLock.
- commit
- 7dee585103f2401500ebbe5e751f62dd1c38b666
- author
- brice · · 2026-03-13 06:32 UTC
- parent
- c97e5fd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +96 -8
M internal/ui/app.go +4 -0
| @@ -711,10 +711,14 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 711 | 711 | ctx, | |
| 712 | 712 | a.mainWindow.Window(), | |
| 713 | 713 | a.deps.Config, | |
| 714 | + | a.keyboardHandler, | |
| 714 | 715 | func(ctx context.Context, action input.Action) error { | |
| 715 | 716 | return a.kbDispatcher.Dispatch(ctx, action) | |
| 716 | 717 | }, | |
| 717 | 718 | ) | |
| 719 | + | if a.globalShortcutHandler == nil { | |
| 720 | + | logging.FromContext(ctx).Warn().Msg("failed to create global shortcut handler, some shortcuts may not work when WebView has focus") | |
| 721 | + | } | |
| 718 | 722 | } | |
| 719 | 723 | ||
| 720 | 724 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | |
| @@ -711,10 +711,14 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 711 | ctx, | 711 | ctx, | ||
| 712 | a.mainWindow.Window(), | 712 | a.mainWindow.Window(), | ||
| 713 | a.deps.Config, | 713 | a.deps.Config, | ||
| 714 | + | a.keyboardHandler, | |||
| 714 | func(ctx context.Context, action input.Action) error { | 715 | func(ctx context.Context, action input.Action) error { | ||
| 715 | return a.kbDispatcher.Dispatch(ctx, action) | 716 | return a.kbDispatcher.Dispatch(ctx, action) | ||
| 716 | }, | 717 | }, | ||
| 717 | ) | 718 | ) | ||
| 719 | + | if a.globalShortcutHandler == nil { | |||
| 720 | + | logging.FromContext(ctx).Warn().Msg("failed to create global shortcut handler, some shortcuts may not work when WebView has focus") | |||
| 721 | + | } | |||
| 718 | } | 722 | } | ||
| 719 | 723 | ||||
| 720 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | 724 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | ||
M internal/ui/input/global_shortcuts.go +33 -0
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | // to intercept shortcuts before they reach the WebView. | |
| 19 | 19 | type GlobalShortcutHandler struct { | |
| 20 | 20 | controller *gtk.ShortcutController | |
| 21 | + | kbHandler *KeyboardHandler | |
| 21 | 22 | onAction ActionHandler | |
| 22 | 23 | ctx context.Context | |
| 23 | 24 | registered map[KeyBinding]Action | |
| @@ -32,6 +33,7 @@ func NewGlobalShortcutHandler( | |||
| 32 | 33 | ctx context.Context, | |
| 33 | 34 | window *gtk.ApplicationWindow, | |
| 34 | 35 | cfg *config.Config, | |
| 36 | + | kbHandler *KeyboardHandler, | |
| 35 | 37 | onAction ActionHandler, | |
| 36 | 38 | ) *GlobalShortcutHandler { | |
| 37 | 39 | log := logging.FromContext(ctx) | |
| @@ -39,6 +41,7 @@ func NewGlobalShortcutHandler( | |||
| 39 | 41 | ||
| 40 | 42 | h := &GlobalShortcutHandler{ | |
| 41 | 43 | controller: gtk.NewShortcutController(), | |
| 44 | + | kbHandler: kbHandler, | |
| 42 | 45 | onAction: onAction, | |
| 43 | 46 | ctx: ctx, | |
| 44 | 47 | callbacks: make([]gtk.ShortcutFunc, 0), | |
| @@ -140,6 +143,16 @@ func NewGlobalShortcutHandler( | |||
| 140 | 143 | Msg("registered floating profile global shortcut") | |
| 141 | 144 | } | |
| 142 | 145 | } | |
| 146 | + | ||
| 147 | + | // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.) | |
| 148 | + | // so they work even when WebView has focus. | |
| 149 | + | shortcuts := NewShortcutSet(ctx, cfg) | |
| 150 | + | for binding, action := range shortcuts.Global { | |
| 151 | + | if _, exists := h.registered[binding]; exists { | |
| 152 | + | continue | |
| 153 | + | } | |
| 154 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |
| 155 | + | } | |
| 143 | 156 | } | |
| 144 | 157 | ||
| 145 | 158 | // Attach to window | |
| @@ -182,6 +195,17 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 182 | 195 | Str("action", string(actionToDispatch)). | |
| 183 | 196 | Msg("global shortcut triggered") | |
| 184 | 197 | ||
| 198 | + | // Mode-enter/exit actions go through KeyboardHandler for modal state | |
| 199 | + | if isModeAction(actionToDispatch) { | |
| 200 | + | if h.kbHandler != nil { | |
| 201 | + | h.kbHandler.DispatchAction(actionToDispatch) | |
| 202 | + | return true | |
| 203 | + | } | |
| 204 | + | log.Warn(). | |
| 205 | + | Str("action", string(actionToDispatch)). | |
| 206 | + | Msg("mode action triggered but no keyboard handler available") | |
| 207 | + | } | |
| 208 | + | ||
| 185 | 209 | if h.onAction != nil { | |
| 186 | 210 | if err := h.onAction(h.ctx, actionToDispatch); err != nil { | |
| 187 | 211 | log.Error(). | |
| @@ -247,3 +271,12 @@ func formatBinding(binding KeyBinding) string { | |||
| 247 | 271 | parts = append(parts, strings.ToLower(keyName)) | |
| 248 | 272 | return strings.Join(parts, "+") | |
| 249 | 273 | } | |
| 274 | + | ||
| 275 | + | func isModeAction(action Action) bool { | |
| 276 | + | switch action { | |
| 277 | + | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode: | |
| 278 | + | return true | |
| 279 | + | default: | |
| 280 | + | return false | |
| 281 | + | } | |
| 282 | + | } | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | // to intercept shortcuts before they reach the WebView. | 18 | // to intercept shortcuts before they reach the WebView. | ||
| 19 | type GlobalShortcutHandler struct { | 19 | type GlobalShortcutHandler struct { | ||
| 20 | controller *gtk.ShortcutController | 20 | controller *gtk.ShortcutController | ||
| 21 | + | kbHandler *KeyboardHandler | |||
| 21 | onAction ActionHandler | 22 | onAction ActionHandler | ||
| 22 | ctx context.Context | 23 | ctx context.Context | ||
| 23 | registered map[KeyBinding]Action | 24 | registered map[KeyBinding]Action | ||
| @@ -32,6 +33,7 @@ func NewGlobalShortcutHandler( | |||||
| 32 | ctx context.Context, | 33 | ctx context.Context, | ||
| 33 | window *gtk.ApplicationWindow, | 34 | window *gtk.ApplicationWindow, | ||
| 34 | cfg *config.Config, | 35 | cfg *config.Config, | ||
| 36 | + | kbHandler *KeyboardHandler, | |||
| 35 | onAction ActionHandler, | 37 | onAction ActionHandler, | ||
| 36 | ) *GlobalShortcutHandler { | 38 | ) *GlobalShortcutHandler { | ||
| 37 | log := logging.FromContext(ctx) | 39 | log := logging.FromContext(ctx) | ||
| @@ -39,6 +41,7 @@ func NewGlobalShortcutHandler( | |||||
| 39 | 41 | ||||
| 40 | h := &GlobalShortcutHandler{ | 42 | h := &GlobalShortcutHandler{ | ||
| 41 | controller: gtk.NewShortcutController(), | 43 | controller: gtk.NewShortcutController(), | ||
| 44 | + | kbHandler: kbHandler, | |||
| 42 | onAction: onAction, | 45 | onAction: onAction, | ||
| 43 | ctx: ctx, | 46 | ctx: ctx, | ||
| 44 | callbacks: make([]gtk.ShortcutFunc, 0), | 47 | callbacks: make([]gtk.ShortcutFunc, 0), | ||
| @@ -140,6 +143,16 @@ func NewGlobalShortcutHandler( | |||||
| 140 | Msg("registered floating profile global shortcut") | 143 | Msg("registered floating profile global shortcut") | ||
| 141 | } | 144 | } | ||
| 142 | } | 145 | } | ||
| 146 | + | ||||
| 147 | + | // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.) | |||
| 148 | + | // so they work even when WebView has focus. | |||
| 149 | + | shortcuts := NewShortcutSet(ctx, cfg) | |||
| 150 | + | for binding, action := range shortcuts.Global { | |||
| 151 | + | if _, exists := h.registered[binding]; exists { | |||
| 152 | + | continue | |||
| 153 | + | } | |||
| 154 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |||
| 155 | + | } | |||
| 143 | } | 156 | } | ||
| 144 | 157 | ||||
| 145 | // Attach to window | 158 | // Attach to window | ||
| @@ -182,6 +195,17 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||||
| 182 | Str("action", string(actionToDispatch)). | 195 | Str("action", string(actionToDispatch)). | ||
| 183 | Msg("global shortcut triggered") | 196 | Msg("global shortcut triggered") | ||
| 184 | 197 | ||||
| 198 | + | // Mode-enter/exit actions go through KeyboardHandler for modal state | |||
| 199 | + | if isModeAction(actionToDispatch) { | |||
| 200 | + | if h.kbHandler != nil { | |||
| 201 | + | h.kbHandler.DispatchAction(actionToDispatch) | |||
| 202 | + | return true | |||
| 203 | + | } | |||
| 204 | + | log.Warn(). | |||
| 205 | + | Str("action", string(actionToDispatch)). | |||
| 206 | + | Msg("mode action triggered but no keyboard handler available") | |||
| 207 | + | } | |||
| 208 | + | ||||
| 185 | if h.onAction != nil { | 209 | if h.onAction != nil { | ||
| 186 | if err := h.onAction(h.ctx, actionToDispatch); err != nil { | 210 | if err := h.onAction(h.ctx, actionToDispatch); err != nil { | ||
| 187 | log.Error(). | 211 | log.Error(). | ||
| @@ -247,3 +271,12 @@ func formatBinding(binding KeyBinding) string { | |||||
| 247 | parts = append(parts, strings.ToLower(keyName)) | 271 | parts = append(parts, strings.ToLower(keyName)) | ||
| 248 | return strings.Join(parts, "+") | 272 | return strings.Join(parts, "+") | ||
| 249 | } | 273 | } | ||
| 274 | + | ||||
| 275 | + | func isModeAction(action Action) bool { | |||
| 276 | + | switch action { | |||
| 277 | + | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode: | |||
| 278 | + | return true | |||
| 279 | + | default: | |||
| 280 | + | return false | |||
| 281 | + | } | |||
| 282 | + | } | |||
M internal/ui/input/keyboard.go +59 -8
| @@ -26,6 +26,7 @@ import ( | |||
| 26 | 26 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 27 | 27 | "github.com/bnema/dumber/internal/logging" | |
| 28 | 28 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 29 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 29 | 30 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 30 | 31 | "github.com/rs/zerolog" | |
| 31 | 32 | ) | |
| @@ -116,7 +117,36 @@ func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, cfg *config.Confi | |||
| 116 | 117 | ||
| 117 | 118 | // SetOnModeChange sets the callback for mode changes (for UI updates). | |
| 118 | 119 | func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |
| 119 | - | h.modal.SetOnModeChange(fn) | |
| 120 | + | h.modal.SetOnModeChange(func(from, to Mode) { | |
| 121 | + | // Switch controller phase: capture during modal, bubble for normal. | |
| 122 | + | // This ensures plain modal keys (s, h, etc.) reach the handler | |
| 123 | + | // before WebView, while normal typing still goes through WebKit IM. | |
| 124 | + | if to == ModeNormal { | |
| 125 | + | h.setControllerPhase(gtk.PhaseBubbleValue) | |
| 126 | + | } else if from == ModeNormal { | |
| 127 | + | h.setControllerPhase(gtk.PhaseCaptureValue) | |
| 128 | + | } | |
| 129 | + | // Forward to app-level callback | |
| 130 | + | if fn != nil { | |
| 131 | + | fn(from, to) | |
| 132 | + | } | |
| 133 | + | }) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // setControllerPhase changes the propagation phase of the key controller. | |
| 137 | + | // Uses glib.IdleAdd to ensure the GTK call runs on the main thread, | |
| 138 | + | // since this may be called from modal timeout goroutines. | |
| 139 | + | func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) { | |
| 140 | + | if h.controller == nil { | |
| 141 | + | return | |
| 142 | + | } | |
| 143 | + | cb := glib.SourceFunc(func(uintptr) bool { | |
| 144 | + | if h.controller != nil { | |
| 145 | + | h.controller.SetPropagationPhase(phase) | |
| 146 | + | } | |
| 147 | + | return false | |
| 148 | + | }) | |
| 149 | + | glib.IdleAdd(&cb, 0) | |
| 120 | 150 | } | |
| 121 | 151 | ||
| 122 | 152 | // SetRouteKey sets the callback that determines how each key event should be routed. | |
| @@ -392,17 +422,21 @@ func isResizeAction(action Action) bool { | |||
| 392 | 422 | } | |
| 393 | 423 | ||
| 394 | 424 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | |
| 425 | + | h.mu.RLock() | |
| 426 | + | cfg := h.cfg | |
| 427 | + | h.mu.RUnlock() | |
| 428 | + | ||
| 395 | 429 | switch action { | |
| 396 | 430 | case ActionEnterTabMode: | |
| 397 | - | timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |
| 431 | + | timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |
| 398 | 432 | h.modal.EnterTabMode(h.ctx, timeout) | |
| 399 | 433 | return true | |
| 400 | 434 | case ActionEnterPaneMode: | |
| 401 | - | timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | |
| 435 | + | timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | |
| 402 | 436 | h.modal.EnterPaneMode(h.ctx, timeout) | |
| 403 | 437 | return true | |
| 404 | 438 | case ActionEnterSessionMode: | |
| 405 | - | timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | |
| 439 | + | timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | |
| 406 | 440 | h.modal.EnterSessionMode(h.ctx, timeout) | |
| 407 | 441 | return true | |
| 408 | 442 | case ActionEnterResizeMode: | |
| @@ -410,7 +444,7 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||
| 410 | 444 | h.modal.ExitMode(h.ctx) | |
| 411 | 445 | return true | |
| 412 | 446 | } | |
| 413 | - | timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond | |
| 447 | + | timeout := time.Duration(cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond | |
| 414 | 448 | h.modal.EnterResizeMode(h.ctx, timeout) | |
| 415 | 449 | return true | |
| 416 | 450 | case ActionExitMode: | |
| @@ -424,21 +458,30 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||
| 424 | 458 | // EnterTabMode programmatically enters tab mode. | |
| 425 | 459 | // Useful for testing or programmatic mode changes. | |
| 426 | 460 | func (h *KeyboardHandler) EnterTabMode() { | |
| 427 | - | timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |
| 461 | + | h.mu.RLock() | |
| 462 | + | cfg := h.cfg | |
| 463 | + | h.mu.RUnlock() | |
| 464 | + | timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |
| 428 | 465 | h.modal.EnterTabMode(h.ctx, timeout) | |
| 429 | 466 | } | |
| 430 | 467 | ||
| 431 | 468 | // EnterPaneMode programmatically enters pane mode. | |
| 432 | 469 | // Useful for testing or programmatic mode changes. | |
| 433 | 470 | func (h *KeyboardHandler) EnterPaneMode() { | |
| 434 | - | timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | |
| 471 | + | h.mu.RLock() | |
| 472 | + | cfg := h.cfg | |
| 473 | + | h.mu.RUnlock() | |
| 474 | + | timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | |
| 435 | 475 | h.modal.EnterPaneMode(h.ctx, timeout) | |
| 436 | 476 | } | |
| 437 | 477 | ||
| 438 | 478 | // EnterSessionMode programmatically enters session mode. | |
| 439 | 479 | // Useful for testing or programmatic mode changes. | |
| 440 | 480 | func (h *KeyboardHandler) EnterSessionMode() { | |
| 441 | - | timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | |
| 481 | + | h.mu.RLock() | |
| 482 | + | cfg := h.cfg | |
| 483 | + | h.mu.RUnlock() | |
| 484 | + | timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | |
| 442 | 485 | h.modal.EnterSessionMode(h.ctx, timeout) | |
| 443 | 486 | } | |
| 444 | 487 | ||
| @@ -448,6 +491,14 @@ func (h *KeyboardHandler) ExitMode() { | |||
| 448 | 491 | h.modal.ExitMode(h.ctx) | |
| 449 | 492 | } | |
| 450 | 493 | ||
| 494 | + | // DispatchAction processes an action externally triggered (e.g., by a global | |
| 495 | + | // shortcut). Mode-enter actions update modal state; other actions are forwarded | |
| 496 | + | // to the registered action handler. | |
| 497 | + | func (h *KeyboardHandler) DispatchAction(action Action) { | |
| 498 | + | mode := h.modal.Mode() | |
| 499 | + | h.dispatchAction(action, mode) | |
| 500 | + | } | |
| 501 | + | ||
| 451 | 502 | // handleKeyRelease processes a key release event for accent detection. | |
| 452 | 503 | func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | |
| 453 | 504 | h.mu.RLock() | |
| @@ -26,6 +26,7 @@ import ( | |||||
| 26 | "github.com/bnema/dumber/internal/infrastructure/config" | 26 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 27 | "github.com/bnema/dumber/internal/logging" | 27 | "github.com/bnema/dumber/internal/logging" | ||
| 28 | "github.com/jwijenbergh/puregotk/v4/gdk" | 28 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 29 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |||
| 29 | "github.com/jwijenbergh/puregotk/v4/gtk" | 30 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 30 | "github.com/rs/zerolog" | 31 | "github.com/rs/zerolog" | ||
| 31 | ) | 32 | ) | ||
| @@ -116,7 +117,36 @@ func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, cfg *config.Confi | |||||
| 116 | 117 | ||||
| 117 | // SetOnModeChange sets the callback for mode changes (for UI updates). | 118 | // SetOnModeChange sets the callback for mode changes (for UI updates). | ||
| 118 | func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | 119 | func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | ||
| 119 | - | h.modal.SetOnModeChange(fn) | 120 | + | h.modal.SetOnModeChange(func(from, to Mode) { |
| 121 | + | // Switch controller phase: capture during modal, bubble for normal. | |||
| 122 | + | // This ensures plain modal keys (s, h, etc.) reach the handler | |||
| 123 | + | // before WebView, while normal typing still goes through WebKit IM. | |||
| 124 | + | if to == ModeNormal { | |||
| 125 | + | h.setControllerPhase(gtk.PhaseBubbleValue) | |||
| 126 | + | } else if from == ModeNormal { | |||
| 127 | + | h.setControllerPhase(gtk.PhaseCaptureValue) | |||
| 128 | + | } | |||
| 129 | + | // Forward to app-level callback | |||
| 130 | + | if fn != nil { | |||
| 131 | + | fn(from, to) | |||
| 132 | + | } | |||
| 133 | + | }) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // setControllerPhase changes the propagation phase of the key controller. | |||
| 137 | + | // Uses glib.IdleAdd to ensure the GTK call runs on the main thread, | |||
| 138 | + | // since this may be called from modal timeout goroutines. | |||
| 139 | + | func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) { | |||
| 140 | + | if h.controller == nil { | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 143 | + | cb := glib.SourceFunc(func(uintptr) bool { | |||
| 144 | + | if h.controller != nil { | |||
| 145 | + | h.controller.SetPropagationPhase(phase) | |||
| 146 | + | } | |||
| 147 | + | return false | |||
| 148 | + | }) | |||
| 149 | + | glib.IdleAdd(&cb, 0) | |||
| 120 | } | 150 | } | ||
| 121 | 151 | ||||
| 122 | // SetRouteKey sets the callback that determines how each key event should be routed. | 152 | // SetRouteKey sets the callback that determines how each key event should be routed. | ||
| @@ -392,17 +422,21 @@ func isResizeAction(action Action) bool { | |||||
| 392 | } | 422 | } | ||
| 393 | 423 | ||||
| 394 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | 424 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | ||
| 425 | + | h.mu.RLock() | |||
| 426 | + | cfg := h.cfg | |||
| 427 | + | h.mu.RUnlock() | |||
| 428 | + | ||||
| 395 | switch action { | 429 | switch action { | ||
| 396 | case ActionEnterTabMode: | 430 | case ActionEnterTabMode: | ||
| 397 | - | timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | 431 | + | timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond |
| 398 | h.modal.EnterTabMode(h.ctx, timeout) | 432 | h.modal.EnterTabMode(h.ctx, timeout) | ||
| 399 | return true | 433 | return true | ||
| 400 | case ActionEnterPaneMode: | 434 | case ActionEnterPaneMode: | ||
| 401 | - | timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | 435 | + | timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond |
| 402 | h.modal.EnterPaneMode(h.ctx, timeout) | 436 | h.modal.EnterPaneMode(h.ctx, timeout) | ||
| 403 | return true | 437 | return true | ||
| 404 | case ActionEnterSessionMode: | 438 | case ActionEnterSessionMode: | ||
| 405 | - | timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | 439 | + | timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond |
| 406 | h.modal.EnterSessionMode(h.ctx, timeout) | 440 | h.modal.EnterSessionMode(h.ctx, timeout) | ||
| 407 | return true | 441 | return true | ||
| 408 | case ActionEnterResizeMode: | 442 | case ActionEnterResizeMode: | ||
| @@ -410,7 +444,7 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||||
| 410 | h.modal.ExitMode(h.ctx) | 444 | h.modal.ExitMode(h.ctx) | ||
| 411 | return true | 445 | return true | ||
| 412 | } | 446 | } | ||
| 413 | - | timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond | 447 | + | timeout := time.Duration(cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond |
| 414 | h.modal.EnterResizeMode(h.ctx, timeout) | 448 | h.modal.EnterResizeMode(h.ctx, timeout) | ||
| 415 | return true | 449 | return true | ||
| 416 | case ActionExitMode: | 450 | case ActionExitMode: | ||
| @@ -424,21 +458,30 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||||
| 424 | // EnterTabMode programmatically enters tab mode. | 458 | // EnterTabMode programmatically enters tab mode. | ||
| 425 | // Useful for testing or programmatic mode changes. | 459 | // Useful for testing or programmatic mode changes. | ||
| 426 | func (h *KeyboardHandler) EnterTabMode() { | 460 | func (h *KeyboardHandler) EnterTabMode() { | ||
| 427 | - | timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | 461 | + | h.mu.RLock() |
| 462 | + | cfg := h.cfg | |||
| 463 | + | h.mu.RUnlock() | |||
| 464 | + | timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |||
| 428 | h.modal.EnterTabMode(h.ctx, timeout) | 465 | h.modal.EnterTabMode(h.ctx, timeout) | ||
| 429 | } | 466 | } | ||
| 430 | 467 | ||||
| 431 | // EnterPaneMode programmatically enters pane mode. | 468 | // EnterPaneMode programmatically enters pane mode. | ||
| 432 | // Useful for testing or programmatic mode changes. | 469 | // Useful for testing or programmatic mode changes. | ||
| 433 | func (h *KeyboardHandler) EnterPaneMode() { | 470 | func (h *KeyboardHandler) EnterPaneMode() { | ||
| 434 | - | timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | 471 | + | h.mu.RLock() |
| 472 | + | cfg := h.cfg | |||
| 473 | + | h.mu.RUnlock() | |||
| 474 | + | timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | |||
| 435 | h.modal.EnterPaneMode(h.ctx, timeout) | 475 | h.modal.EnterPaneMode(h.ctx, timeout) | ||
| 436 | } | 476 | } | ||
| 437 | 477 | ||||
| 438 | // EnterSessionMode programmatically enters session mode. | 478 | // EnterSessionMode programmatically enters session mode. | ||
| 439 | // Useful for testing or programmatic mode changes. | 479 | // Useful for testing or programmatic mode changes. | ||
| 440 | func (h *KeyboardHandler) EnterSessionMode() { | 480 | func (h *KeyboardHandler) EnterSessionMode() { | ||
| 441 | - | timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | 481 | + | h.mu.RLock() |
| 482 | + | cfg := h.cfg | |||
| 483 | + | h.mu.RUnlock() | |||
| 484 | + | timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | |||
| 442 | h.modal.EnterSessionMode(h.ctx, timeout) | 485 | h.modal.EnterSessionMode(h.ctx, timeout) | ||
| 443 | } | 486 | } | ||
| 444 | 487 | ||||
| @@ -448,6 +491,14 @@ func (h *KeyboardHandler) ExitMode() { | |||||
| 448 | h.modal.ExitMode(h.ctx) | 491 | h.modal.ExitMode(h.ctx) | ||
| 449 | } | 492 | } | ||
| 450 | 493 | ||||
| 494 | + | // DispatchAction processes an action externally triggered (e.g., by a global | |||
| 495 | + | // shortcut). Mode-enter actions update modal state; other actions are forwarded | |||
| 496 | + | // to the registered action handler. | |||
| 497 | + | func (h *KeyboardHandler) DispatchAction(action Action) { | |||
| 498 | + | mode := h.modal.Mode() | |||
| 499 | + | h.dispatchAction(action, mode) | |||
| 500 | + | } | |||
| 501 | + | ||||
| 451 | // handleKeyRelease processes a key release event for accent detection. | 502 | // handleKeyRelease processes a key release event for accent detection. | ||
| 452 | func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | 503 | func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | ||
| 453 | h.mu.RLock() | 504 | h.mu.RLock() | ||