refactor(input): rewrite handleKeyPress with unified key routing
Replace the chain of boolean checks with a clean switch on KeyRoute enum. The routeKey callback makes the routing decision once, then handleKeyPress executes the appropriate path: - RoutePassToWidget: return false (WebView IM, overlays) - RouteAccentDetection: try long-press accent, then pass through - RouteHandleShortcuts: shortcut lookup + dispatch Modal mode overrides routing (keys consumed regardless of route). tryAccentDetection simplified: modal check removed (handled by caller).
- commit
- 0185904980be6a767e67e7b613d5eab1a01c302e
- author
- bnema · · 2026-03-10 22:02 UTC
- committer
- brice · · 2026-03-11 04:05 UTC
- parent
- 801a8a3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +151 -21
M internal/ui/input/keyboard.go +30 -21
| @@ -182,10 +182,6 @@ func (h *KeyboardHandler) Detach() { | |||
| 182 | 182 | ||
| 183 | 183 | // handleKeyPress processes a key press event. | |
| 184 | 184 | // Returns true if the event was handled and should not propagate further. | |
| 185 | - | // Parameters: | |
| 186 | - | // - keyval: the translated key value (depends on keyboard layout) | |
| 187 | - | // - keycode: the hardware keycode (physical key position, layout-independent) | |
| 188 | - | // - state: modifier keys state | |
| 189 | 185 | func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 190 | 186 | log := logging.FromContext(h.ctx) | |
| 191 | 187 | ||
| @@ -195,38 +191,50 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 195 | 191 | onEscape := h.onEscape | |
| 196 | 192 | h.mu.RUnlock() | |
| 197 | 193 | ||
| 198 | - | // Check if accent picker is visible - it takes priority | |
| 194 | + | // Accent picker takes absolute priority when visible -- it has its own key controller | |
| 199 | 195 | if accentHandler != nil && accentHandler.IsPickerVisible() { | |
| 200 | - | return false // Let the accent picker handle the key via its own controller | |
| 196 | + | return false | |
| 201 | 197 | } | |
| 202 | 198 | ||
| 203 | 199 | modifiers := Modifier(state) & modifierMask | |
| 200 | + | ||
| 201 | + | // Escape in normal mode: check app-level escape hook first | |
| 204 | 202 | if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { | |
| 205 | 203 | if onEscape != nil && onEscape(h.ctx) { | |
| 206 | 204 | return true | |
| 207 | 205 | } | |
| 208 | 206 | } | |
| 209 | 207 | ||
| 210 | - | // Check accent detection first - works in both normal and bypass mode | |
| 211 | - | // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView | |
| 212 | - | if h.tryAccentDetection(accentHandler, keyval, modifiers) { | |
| 213 | - | return true | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | // Temporary: routeKey replaces shouldBypass - full routing logic comes in Task 3. | |
| 217 | - | // For now, we just check if route says to pass to widget. | |
| 218 | - | if routeKey != nil { | |
| 219 | - | route := routeKey(KeyContext{ | |
| 208 | + | // Determine routing for this key event | |
| 209 | + | route := RouteHandleShortcuts // default: process through shortcut system | |
| 210 | + | if routeKey != nil && h.modal.Mode() == ModeNormal { | |
| 211 | + | route = routeKey(KeyContext{ | |
| 220 | 212 | Keyval: keyval, | |
| 221 | 213 | Keycode: keycode, | |
| 222 | 214 | Modifiers: modifiers, | |
| 223 | 215 | }) | |
| 224 | - | if route == RoutePassToWidget { | |
| 225 | - | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Msg("routing key to focused widget") | |
| 226 | - | return false | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | switch route { | |
| 219 | + | case RoutePassToWidget: | |
| 220 | + | // Let the focused widget handle this key (WebView IM, overlay, etc.) | |
| 221 | + | log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget") | |
| 222 | + | return false | |
| 223 | + | ||
| 224 | + | case RouteAccentDetection: | |
| 225 | + | // Try long-press accent detection (for GTK Entry widgets) | |
| 226 | + | if h.tryAccentDetection(accentHandler, keyval, modifiers) { | |
| 227 | + | return true | |
| 227 | 228 | } | |
| 229 | + | // If accent handler didn't consume it, let the widget handle it | |
| 230 | + | return false | |
| 231 | + | ||
| 232 | + | case RouteHandleShortcuts: | |
| 233 | + | // Fall through to shortcut processing below | |
| 228 | 234 | } | |
| 229 | 235 | ||
| 236 | + | // --- Shortcut processing path --- | |
| 237 | + | ||
| 230 | 238 | // Normalize uppercase letters for consistent binding lookup | |
| 231 | 239 | keyval = normalizeKeyval(keyval) | |
| 232 | 240 | ||
| @@ -242,9 +250,10 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 242 | 250 | } | |
| 243 | 251 | ||
| 244 | 252 | // tryAccentDetection starts long-press detection for accent-eligible keys. | |
| 245 | - | // Returns true if the key should be suppressed (blocked from reaching the WebView). | |
| 253 | + | // Only called when routeKey returns RouteAccentDetection (GTK Entry context). | |
| 254 | + | // Returns true if the key should be suppressed. | |
| 246 | 255 | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | |
| 247 | - | if h.modal.Mode() != ModeNormal || accentHandler == nil { | |
| 256 | + | if accentHandler == nil { | |
| 248 | 257 | return false | |
| 249 | 258 | } | |
| 250 | 259 | // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase) | |
| @@ -182,10 +182,6 @@ func (h *KeyboardHandler) Detach() { | |||||
| 182 | 182 | ||||
| 183 | // handleKeyPress processes a key press event. | 183 | // handleKeyPress processes a key press event. | ||
| 184 | // Returns true if the event was handled and should not propagate further. | 184 | // Returns true if the event was handled and should not propagate further. | ||
| 185 | - | // Parameters: | |||
| 186 | - | // - keyval: the translated key value (depends on keyboard layout) | |||
| 187 | - | // - keycode: the hardware keycode (physical key position, layout-independent) | |||
| 188 | - | // - state: modifier keys state | |||
| 189 | func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | 185 | func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 190 | log := logging.FromContext(h.ctx) | 186 | log := logging.FromContext(h.ctx) | ||
| 191 | 187 | ||||
| @@ -195,38 +191,50 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 195 | onEscape := h.onEscape | 191 | onEscape := h.onEscape | ||
| 196 | h.mu.RUnlock() | 192 | h.mu.RUnlock() | ||
| 197 | 193 | ||||
| 198 | - | // Check if accent picker is visible - it takes priority | 194 | + | // Accent picker takes absolute priority when visible -- it has its own key controller |
| 199 | if accentHandler != nil && accentHandler.IsPickerVisible() { | 195 | if accentHandler != nil && accentHandler.IsPickerVisible() { | ||
| 200 | - | return false // Let the accent picker handle the key via its own controller | 196 | + | return false |
| 201 | } | 197 | } | ||
| 202 | 198 | ||||
| 203 | modifiers := Modifier(state) & modifierMask | 199 | modifiers := Modifier(state) & modifierMask | ||
| 200 | + | ||||
| 201 | + | // Escape in normal mode: check app-level escape hook first | |||
| 204 | if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { | 202 | if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { | ||
| 205 | if onEscape != nil && onEscape(h.ctx) { | 203 | if onEscape != nil && onEscape(h.ctx) { | ||
| 206 | return true | 204 | return true | ||
| 207 | } | 205 | } | ||
| 208 | } | 206 | } | ||
| 209 | 207 | ||||
| 210 | - | // Check accent detection first - works in both normal and bypass mode | 208 | + | // Determine routing for this key event |
| 211 | - | // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView | 209 | + | route := RouteHandleShortcuts // default: process through shortcut system |
| 212 | - | if h.tryAccentDetection(accentHandler, keyval, modifiers) { | 210 | + | if routeKey != nil && h.modal.Mode() == ModeNormal { |
| 213 | - | return true | 211 | + | route = routeKey(KeyContext{ |
| 214 | - | } | |||
| 215 | - | ||||
| 216 | - | // Temporary: routeKey replaces shouldBypass - full routing logic comes in Task 3. | |||
| 217 | - | // For now, we just check if route says to pass to widget. | |||
| 218 | - | if routeKey != nil { | |||
| 219 | - | route := routeKey(KeyContext{ | |||
| 220 | Keyval: keyval, | 212 | Keyval: keyval, | ||
| 221 | Keycode: keycode, | 213 | Keycode: keycode, | ||
| 222 | Modifiers: modifiers, | 214 | Modifiers: modifiers, | ||
| 223 | }) | 215 | }) | ||
| 224 | - | if route == RoutePassToWidget { | 216 | + | } |
| 225 | - | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Msg("routing key to focused widget") | 217 | + | |
| 226 | - | return false | 218 | + | switch route { |
| 219 | + | case RoutePassToWidget: | |||
| 220 | + | // Let the focused widget handle this key (WebView IM, overlay, etc.) | |||
| 221 | + | log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget") | |||
| 222 | + | return false | |||
| 223 | + | ||||
| 224 | + | case RouteAccentDetection: | |||
| 225 | + | // Try long-press accent detection (for GTK Entry widgets) | |||
| 226 | + | if h.tryAccentDetection(accentHandler, keyval, modifiers) { | |||
| 227 | + | return true | |||
| 227 | } | 228 | } | ||
| 229 | + | // If accent handler didn't consume it, let the widget handle it | |||
| 230 | + | return false | |||
| 231 | + | ||||
| 232 | + | case RouteHandleShortcuts: | |||
| 233 | + | // Fall through to shortcut processing below | |||
| 228 | } | 234 | } | ||
| 229 | 235 | ||||
| 236 | + | // --- Shortcut processing path --- | |||
| 237 | + | ||||
| 230 | // Normalize uppercase letters for consistent binding lookup | 238 | // Normalize uppercase letters for consistent binding lookup | ||
| 231 | keyval = normalizeKeyval(keyval) | 239 | keyval = normalizeKeyval(keyval) | ||
| 232 | 240 | ||||
| @@ -242,9 +250,10 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 242 | } | 250 | } | ||
| 243 | 251 | ||||
| 244 | // tryAccentDetection starts long-press detection for accent-eligible keys. | 252 | // tryAccentDetection starts long-press detection for accent-eligible keys. | ||
| 245 | - | // Returns true if the key should be suppressed (blocked from reaching the WebView). | 253 | + | // Only called when routeKey returns RouteAccentDetection (GTK Entry context). |
| 254 | + | // Returns true if the key should be suppressed. | |||
| 246 | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | 255 | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | ||
| 247 | - | if h.modal.Mode() != ModeNormal || accentHandler == nil { | 256 | + | if accentHandler == nil { |
| 248 | return false | 257 | return false | ||
| 249 | } | 258 | } | ||
| 250 | // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase) | 259 | // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase) | ||
M internal/ui/input/keyboard_test.go +121 -0
| @@ -1,8 +1,11 @@ | |||
| 1 | 1 | package input | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 6 | 9 | "github.com/stretchr/testify/assert" | |
| 7 | 10 | ) | |
| 8 | 11 | ||
| @@ -73,3 +76,121 @@ func TestIsTextInputKey(t *testing.T) { | |||
| 73 | 76 | }) | |
| 74 | 77 | } | |
| 75 | 78 | } | |
| 79 | + | ||
| 80 | + | func TestHandleKeyPress_RoutePassToWidget(t *testing.T) { | |
| 81 | + | ctx := context.Background() | |
| 82 | + | cfg := &config.Config{} | |
| 83 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |
| 84 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |
| 85 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |
| 86 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |
| 87 | + | ||
| 88 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 89 | + | ||
| 90 | + | // Route all non-shortcut keys to widget (simulates WebView text input) | |
| 91 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| 92 | + | return RoutePassToWidget | |
| 93 | + | }) | |
| 94 | + | ||
| 95 | + | tests := []struct { | |
| 96 | + | name string | |
| 97 | + | keyval uint | |
| 98 | + | state gdk.ModifierType | |
| 99 | + | }{ | |
| 100 | + | {"plain 'e'", uint('e'), 0}, | |
| 101 | + | {"plain 'a'", uint('a'), 0}, | |
| 102 | + | {"space", 0x020, 0}, | |
| 103 | + | {"digit 1", uint('1'), 0}, | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | for _, tt := range tests { | |
| 107 | + | t.Run(tt.name, func(t *testing.T) { | |
| 108 | + | result := h.handleKeyPress(tt.keyval, 0, tt.state) | |
| 109 | + | assert.False(t, result, "RoutePassToWidget should let key propagate (return false)") | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) { | |
| 115 | + | ctx := context.Background() | |
| 116 | + | cfg := &config.Config{} | |
| 117 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |
| 118 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |
| 119 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |
| 120 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |
| 121 | + | ||
| 122 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 123 | + | ||
| 124 | + | // Route to accent detection | |
| 125 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| 126 | + | return RouteAccentDetection | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | // 'x' has no accents -> accent handler returns false -> key falls through | |
| 130 | + | result := h.handleKeyPress(uint('x'), 0, 0) | |
| 131 | + | assert.False(t, result, "'x' has no accents, should pass through") | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) { | |
| 135 | + | ctx := context.Background() | |
| 136 | + | cfg := &config.Config{} | |
| 137 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |
| 138 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |
| 139 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |
| 140 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |
| 141 | + | ||
| 142 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 143 | + | ||
| 144 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |
| 145 | + | return nil | |
| 146 | + | }) | |
| 147 | + | ||
| 148 | + | // Route text keys to widget, shortcuts to handler | |
| 149 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| 150 | + | if IsShortcutModified(kc.Modifiers) { | |
| 151 | + | return RouteHandleShortcuts | |
| 152 | + | } | |
| 153 | + | return RoutePassToWidget | |
| 154 | + | }) | |
| 155 | + | ||
| 156 | + | // Ctrl+L = open omnibox (registered global shortcut) | |
| 157 | + | result := h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue) | |
| 158 | + | assert.True(t, result, "Ctrl+L should be consumed by shortcut handler") | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) { | |
| 162 | + | ctx := context.Background() | |
| 163 | + | cfg := &config.Config{} | |
| 164 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |
| 165 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |
| 166 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |
| 167 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |
| 168 | + | ||
| 169 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 170 | + | h.EnterTabMode() | |
| 171 | + | ||
| 172 | + | // Even if routeKey says pass to widget, modal mode ignores it | |
| 173 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| 174 | + | return RoutePassToWidget | |
| 175 | + | }) | |
| 176 | + | ||
| 177 | + | // In modal mode, unknown keys are consumed (return true) | |
| 178 | + | result := h.handleKeyPress(uint('z'), 0, 0) | |
| 179 | + | assert.True(t, result, "modal mode should consume unknown keys regardless of route") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) { | |
| 183 | + | ctx := context.Background() | |
| 184 | + | cfg := &config.Config{} | |
| 185 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |
| 186 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |
| 187 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |
| 188 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |
| 189 | + | ||
| 190 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 191 | + | ||
| 192 | + | // No routeKey set -- should default to shortcut handling | |
| 193 | + | // 'z' is not a registered shortcut in ModeNormal, so returns false | |
| 194 | + | result := h.handleKeyPress(uint('z'), 0, 0) | |
| 195 | + | assert.False(t, result, "unregistered key in ModeNormal returns false") | |
| 196 | + | } | |
| @@ -1,8 +1,11 @@ | |||||
| 1 | package input | 1 | package input | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 6 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 7 | ) | 10 | ) | ||
| 8 | 11 | ||||
| @@ -73,3 +76,121 @@ func TestIsTextInputKey(t *testing.T) { | |||||
| 73 | }) | 76 | }) | ||
| 74 | } | 77 | } | ||
| 75 | } | 78 | } | ||
| 79 | + | ||||
| 80 | + | func TestHandleKeyPress_RoutePassToWidget(t *testing.T) { | |||
| 81 | + | ctx := context.Background() | |||
| 82 | + | cfg := &config.Config{} | |||
| 83 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |||
| 84 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |||
| 85 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |||
| 86 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |||
| 87 | + | ||||
| 88 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 89 | + | ||||
| 90 | + | // Route all non-shortcut keys to widget (simulates WebView text input) | |||
| 91 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 92 | + | return RoutePassToWidget | |||
| 93 | + | }) | |||
| 94 | + | ||||
| 95 | + | tests := []struct { | |||
| 96 | + | name string | |||
| 97 | + | keyval uint | |||
| 98 | + | state gdk.ModifierType | |||
| 99 | + | }{ | |||
| 100 | + | {"plain 'e'", uint('e'), 0}, | |||
| 101 | + | {"plain 'a'", uint('a'), 0}, | |||
| 102 | + | {"space", 0x020, 0}, | |||
| 103 | + | {"digit 1", uint('1'), 0}, | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | for _, tt := range tests { | |||
| 107 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 108 | + | result := h.handleKeyPress(tt.keyval, 0, tt.state) | |||
| 109 | + | assert.False(t, result, "RoutePassToWidget should let key propagate (return false)") | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) { | |||
| 115 | + | ctx := context.Background() | |||
| 116 | + | cfg := &config.Config{} | |||
| 117 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |||
| 118 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |||
| 119 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |||
| 120 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |||
| 121 | + | ||||
| 122 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 123 | + | ||||
| 124 | + | // Route to accent detection | |||
| 125 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 126 | + | return RouteAccentDetection | |||
| 127 | + | }) | |||
| 128 | + | ||||
| 129 | + | // 'x' has no accents -> accent handler returns false -> key falls through | |||
| 130 | + | result := h.handleKeyPress(uint('x'), 0, 0) | |||
| 131 | + | assert.False(t, result, "'x' has no accents, should pass through") | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) { | |||
| 135 | + | ctx := context.Background() | |||
| 136 | + | cfg := &config.Config{} | |||
| 137 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |||
| 138 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |||
| 139 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |||
| 140 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |||
| 141 | + | ||||
| 142 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 143 | + | ||||
| 144 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 145 | + | return nil | |||
| 146 | + | }) | |||
| 147 | + | ||||
| 148 | + | // Route text keys to widget, shortcuts to handler | |||
| 149 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 150 | + | if IsShortcutModified(kc.Modifiers) { | |||
| 151 | + | return RouteHandleShortcuts | |||
| 152 | + | } | |||
| 153 | + | return RoutePassToWidget | |||
| 154 | + | }) | |||
| 155 | + | ||||
| 156 | + | // Ctrl+L = open omnibox (registered global shortcut) | |||
| 157 | + | result := h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue) | |||
| 158 | + | assert.True(t, result, "Ctrl+L should be consumed by shortcut handler") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) { | |||
| 162 | + | ctx := context.Background() | |||
| 163 | + | cfg := &config.Config{} | |||
| 164 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |||
| 165 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |||
| 166 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |||
| 167 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |||
| 168 | + | ||||
| 169 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 170 | + | h.EnterTabMode() | |||
| 171 | + | ||||
| 172 | + | // Even if routeKey says pass to widget, modal mode ignores it | |||
| 173 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 174 | + | return RoutePassToWidget | |||
| 175 | + | }) | |||
| 176 | + | ||||
| 177 | + | // In modal mode, unknown keys are consumed (return true) | |||
| 178 | + | result := h.handleKeyPress(uint('z'), 0, 0) | |||
| 179 | + | assert.True(t, result, "modal mode should consume unknown keys regardless of route") | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) { | |||
| 183 | + | ctx := context.Background() | |||
| 184 | + | cfg := &config.Config{} | |||
| 185 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |||
| 186 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |||
| 187 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |||
| 188 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |||
| 189 | + | ||||
| 190 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 191 | + | ||||
| 192 | + | // No routeKey set -- should default to shortcut handling | |||
| 193 | + | // 'z' is not a registered shortcut in ModeNormal, so returns false | |||
| 194 | + | result := h.handleKeyPress(uint('z'), 0, 0) | |||
| 195 | + | assert.False(t, result, "unregistered key in ModeNormal returns false") | |||
| 196 | + | } | |||