detached 0185904

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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() {
182182
183183 // handleKeyPress processes a key press event.
184184 // 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
189185 func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
190186 log := logging.FromContext(h.ctx)
191187
@@ -195,38 +191,50 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
195191 onEscape := h.onEscape
196192 h.mu.RUnlock()
197193
198- // Check if accent picker is visible - it takes priority
194+ // Accent picker takes absolute priority when visible -- it has its own key controller
199195 if accentHandler != nil && accentHandler.IsPickerVisible() {
200- return false // Let the accent picker handle the key via its own controller
196+ return false
201197 }
202198
203199 modifiers := Modifier(state) & modifierMask
200+
201+ // Escape in normal mode: check app-level escape hook first
204202 if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
205203 if onEscape != nil && onEscape(h.ctx) {
206204 return true
207205 }
208206 }
209207
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{
220212 Keyval: keyval,
221213 Keycode: keycode,
222214 Modifiers: modifiers,
223215 })
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
227228 }
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
228234 }
229235
236+ // --- Shortcut processing path ---
237+
230238 // Normalize uppercase letters for consistent binding lookup
231239 keyval = normalizeKeyval(keyval)
232240
@@ -242,9 +250,10 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
242250 }
243251
244252 // 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.
246255 func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
247- if h.modal.Mode() != ModeNormal || accentHandler == nil {
256+ if accentHandler == nil {
248257 return false
249258 }
250259 // 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.onEscape191 onEscape := h.onEscape
196 h.mu.RUnlock()192 h.mu.RUnlock()
197 193
198- // Check if accent picker is visible - it takes priority194+ // 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 controller196+ return false
201 }197 }
202 198
203 modifiers := Modifier(state) & modifierMask199 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 true204 return true
207 }205 }
208 }206 }
209 207
210- // Check accent detection first - works in both normal and bypass mode208+ // Determine routing for this key event
211- // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView209+ route := RouteHandleShortcuts // default: process through shortcut system
212- if h.tryAccentDetection(accentHandler, keyval, modifiers) {210+ if routeKey != nil && h.modal.Mode() == ModeNormal {
213- return true211+ 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 false218+ 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 lookup238 // 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 false257 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 @@
11 package input
22
33 import (
4+ "context"
45 "testing"
56
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
69 "github.com/stretchr/testify/assert"
710 )
811
@@ -73,3 +76,121 @@ func TestIsTextInputKey(t *testing.T) {
7376 })
7477 }
7578 }
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 input1 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close