detached 6050ce9

fix(input): support Alt/Ctrl+number shortcuts on non-QWERTY keyboards

commit
6050ce983be2bc531ed728568a6a614720512b36
author
brice · · 2025-12-25 17:21 UTC
committer
brice · · 2025-12-25 17:25 UTC
parent
8cdf4a4
signature
unsigned
5 files · +424 -22
M CHANGELOG.md +1 -0
@@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file.
2121 - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_listed_sessions`, plus session mode shortcuts and styling.
2222
2323 ### Fixed
24+- **Alt/Ctrl+number shortcuts on non-QWERTY keyboards**: Fixed Alt+1-9/0 tab switching and Ctrl+1-9/0 omnibox shortcuts not working on AZERTY, QWERTZ, Dvorak, and other non-QWERTY keyboard layouts. Uses hardware keycodes as fallback when keyval lookup fails, enabling shortcuts to work based on physical key position regardless of layout.
2425 - **Idle inhibition leak on pane close**: Fixed D-Bus idle inhibit request not being released when closing a pane/tab while in fullscreen or playing audio. The inhibition would remain active until the app exited. Now properly tracks fullscreen and audio state and releases inhibition before destroying WebView.
2526 - **Audio-based idle inhibition**: Extended idle inhibition to also activate when a page is playing audio (e.g., video/music playback), not just in fullscreen mode. Uses WebKit's `notify::is-playing-audio` signal. The inhibitor uses refcounting, so both fullscreen and audio can be active simultaneously.
2627
@@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file.
21 - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_listed_sessions`, plus session mode shortcuts and styling.21 - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_listed_sessions`, plus session mode shortcuts and styling.
22 22
23 ### Fixed23 ### Fixed
24+- **Alt/Ctrl+number shortcuts on non-QWERTY keyboards**: Fixed Alt+1-9/0 tab switching and Ctrl+1-9/0 omnibox shortcuts not working on AZERTY, QWERTZ, Dvorak, and other non-QWERTY keyboard layouts. Uses hardware keycodes as fallback when keyval lookup fails, enabling shortcuts to work based on physical key position regardless of layout.
24 - **Idle inhibition leak on pane close**: Fixed D-Bus idle inhibit request not being released when closing a pane/tab while in fullscreen or playing audio. The inhibition would remain active until the app exited. Now properly tracks fullscreen and audio state and releases inhibition before destroying WebView.25 - **Idle inhibition leak on pane close**: Fixed D-Bus idle inhibit request not being released when closing a pane/tab while in fullscreen or playing audio. The inhibition would remain active until the app exited. Now properly tracks fullscreen and audio state and releases inhibition before destroying WebView.
25 - **Audio-based idle inhibition**: Extended idle inhibition to also activate when a page is playing audio (e.g., video/music playback), not just in fullscreen mode. Uses WebKit's `notify::is-playing-audio` signal. The inhibitor uses refcounting, so both fullscreen and audio can be active simultaneously.26 - **Audio-based idle inhibition**: Extended idle inhibition to also activate when a page is playing audio (e.g., video/music playback), not just in fullscreen mode. Uses WebKit's `notify::is-playing-audio` signal. The inhibitor uses refcounting, so both fullscreen and audio can be active simultaneously.
26 27
M internal/ui/component/omnibox.go +34 -17
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/infrastructure/config"
1414 "github.com/bnema/dumber/internal/logging"
1515 "github.com/bnema/dumber/internal/ui/adapter"
16+ "github.com/bnema/dumber/internal/ui/input"
1617 "github.com/bnema/dumber/internal/ui/layout"
1718 "github.com/jwijenbergh/puregotk/v4/gdk"
1819 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -284,8 +285,8 @@ func (o *Omnibox) setupKeyboardHandling() {
284285 // Set capture phase to intercept before entry
285286 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
286287
287- keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
288- return o.handleKeyPress(keyval, state)
288+ keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
289+ return o.handleKeyPress(keyval, keycode, state)
289290 }
290291 o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)
291292 controller.ConnectKeyPressed(&keyPressedCb)
@@ -434,7 +435,7 @@ func (o *Omnibox) setupEntryChanged() {
434435
435436 // handleKeyPress processes keyboard events.
436437 // Returns true if the event was handled.
437-func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
438+func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
438439 ctrl := state&gdk.ControlMaskValue != 0
439440
440441 switch keyval {
@@ -494,29 +495,45 @@ func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
494495 return false // Let entry handle 'y' for typing
495496
496497 default:
497- return o.handleCtrlNumberShortcut(keyval, ctrl)
498+ return o.handleCtrlNumberShortcut(keyval, keycode, ctrl)
498499 }
499500
500501 return false // Let entry handle the key
501502 }
502503
503504 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
504-func (o *Omnibox) handleCtrlNumberShortcut(keyval uint, ctrl bool) bool {
505+// Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
506+func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
505507 if !ctrl {
506508 return false
507509 }
508510
509511 const tenthItemIndex = 9
512+
513+ // First try keyval (works for QWERTY layouts)
510514 switch keyval {
511515 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
512516 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
513- index := int(keyval - uint(gdk.KEY_1))
514- o.selectAndNavigate(index)
517+ idx := int(keyval - uint(gdk.KEY_1))
518+ o.selectAndNavigate(idx)
515519 return true
516520 case uint(gdk.KEY_0):
517521 o.selectAndNavigate(tenthItemIndex)
518522 return true
519523 }
524+
525+ // Fallback: use hardware keycode for non-QWERTY layouts
526+ // This enables Ctrl+1-9/0 to work on AZERTY, QWERTZ, etc.
527+ if index, ok := input.KeycodeToDigitIndex(keycode); ok {
528+ log := logging.FromContext(o.ctx)
529+ log.Debug().
530+ Uint("keycode", keycode).
531+ Int("index", index).
532+ Msg("omnibox shortcut via hardware keycode fallback (non-QWERTY layout)")
533+ o.selectAndNavigate(index)
534+ return true
535+ }
536+
520537 return false
521538 }
522539
@@ -579,11 +596,11 @@ func (o *Omnibox) performSearch() {
579596 return
580597 }
581598
582- input := usecase.SearchInput{
599+ searchInput := usecase.SearchInput{
583600 Query: query,
584601 Limit: OmniboxListDefaults.MaxResults,
585602 }
586- output, err := o.historyUC.Search(ctx, input)
603+ output, err := o.historyUC.Search(ctx, searchInput)
587604 if err != nil {
588605 log.Error().Err(err).Msg("history search failed")
589606 return
@@ -997,10 +1014,10 @@ func (o *Omnibox) navigateToSelected() {
9971014 }
9981015 }
9991016
1000-// looksLikeURL checks if the input appears to be a URL (not a search query).
1017+// looksLikeURL checks if the text appears to be a URL (not a search query).
10011018 // Returns true for strings like "github.com", "google.com/search", etc.
1002-func (o *Omnibox) looksLikeURL(input string) bool {
1003- return url.LooksLikeURL(input)
1019+func (o *Omnibox) looksLikeURL(text string) bool {
1020+ return url.LooksLikeURL(text)
10041021 }
10051022
10061023 // toggleFavorite adds or removes the selected item from favorites.
@@ -1040,12 +1057,12 @@ func (o *Omnibox) toggleFavorite() {
10401057
10411058 goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites")
10421059
1043- input := usecase.AddFavoriteInput{
1060+ addInput := usecase.AddFavoriteInput{
10441061 URL: s.URL,
10451062 Title: s.Title,
10461063 }
10471064
1048- fav, err := o.favoritesUC.Add(ctx, input)
1065+ fav, err := o.favoritesUC.Add(ctx, addInput)
10491066 if err != nil {
10501067 goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite")
10511068 return
@@ -1139,9 +1156,9 @@ func (o *Omnibox) yankSelectedURL() {
11391156 }()
11401157 }
11411158
1142-// buildURL constructs a URL from input, handling search shortcuts.
1143-func (o *Omnibox) buildURL(input string) string {
1144- return url.BuildSearchURL(input, o.shortcutURLs(), o.defaultSearch)
1159+// buildURL constructs a URL from text, handling search shortcuts.
1160+func (o *Omnibox) buildURL(text string) string {
1161+ return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch)
11451162 }
11461163
11471164 // shortcutURLs returns a map of shortcut keys to URL templates.
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
15 "github.com/bnema/dumber/internal/ui/adapter"15 "github.com/bnema/dumber/internal/ui/adapter"
16+ "github.com/bnema/dumber/internal/ui/input"
16 "github.com/bnema/dumber/internal/ui/layout"17 "github.com/bnema/dumber/internal/ui/layout"
17 "github.com/jwijenbergh/puregotk/v4/gdk"18 "github.com/jwijenbergh/puregotk/v4/gdk"
18 "github.com/jwijenbergh/puregotk/v4/glib"19 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -284,8 +285,8 @@ func (o *Omnibox) setupKeyboardHandling() {
284 // Set capture phase to intercept before entry285 // Set capture phase to intercept before entry
285 controller.SetPropagationPhase(gtk.PhaseCaptureValue)286 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
286 287
287- keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {288+ keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
288- return o.handleKeyPress(keyval, state)289+ return o.handleKeyPress(keyval, keycode, state)
289 }290 }
290 o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)291 o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)
291 controller.ConnectKeyPressed(&keyPressedCb)292 controller.ConnectKeyPressed(&keyPressedCb)
@@ -434,7 +435,7 @@ func (o *Omnibox) setupEntryChanged() {
434 435
435 // handleKeyPress processes keyboard events.436 // handleKeyPress processes keyboard events.
436 // Returns true if the event was handled.437 // Returns true if the event was handled.
437-func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {438+func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
438 ctrl := state&gdk.ControlMaskValue != 0439 ctrl := state&gdk.ControlMaskValue != 0
439 440
440 switch keyval {441 switch keyval {
@@ -494,29 +495,45 @@ func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
494 return false // Let entry handle 'y' for typing495 return false // Let entry handle 'y' for typing
495 496
496 default:497 default:
497- return o.handleCtrlNumberShortcut(keyval, ctrl)498+ return o.handleCtrlNumberShortcut(keyval, keycode, ctrl)
498 }499 }
499 500
500 return false // Let entry handle the key501 return false // Let entry handle the key
501 }502 }
502 503
503 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.504 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
504-func (o *Omnibox) handleCtrlNumberShortcut(keyval uint, ctrl bool) bool {505+// Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
506+func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
505 if !ctrl {507 if !ctrl {
506 return false508 return false
507 }509 }
508 510
509 const tenthItemIndex = 9511 const tenthItemIndex = 9
512+
513+ // First try keyval (works for QWERTY layouts)
510 switch keyval {514 switch keyval {
511 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),515 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
512 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):516 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
513- index := int(keyval - uint(gdk.KEY_1))517+ idx := int(keyval - uint(gdk.KEY_1))
514- o.selectAndNavigate(index)518+ o.selectAndNavigate(idx)
515 return true519 return true
516 case uint(gdk.KEY_0):520 case uint(gdk.KEY_0):
517 o.selectAndNavigate(tenthItemIndex)521 o.selectAndNavigate(tenthItemIndex)
518 return true522 return true
519 }523 }
524+
525+ // Fallback: use hardware keycode for non-QWERTY layouts
526+ // This enables Ctrl+1-9/0 to work on AZERTY, QWERTZ, etc.
527+ if index, ok := input.KeycodeToDigitIndex(keycode); ok {
528+ log := logging.FromContext(o.ctx)
529+ log.Debug().
530+ Uint("keycode", keycode).
531+ Int("index", index).
532+ Msg("omnibox shortcut via hardware keycode fallback (non-QWERTY layout)")
533+ o.selectAndNavigate(index)
534+ return true
535+ }
536+
520 return false537 return false
521 }538 }
522 539
@@ -579,11 +596,11 @@ func (o *Omnibox) performSearch() {
579 return596 return
580 }597 }
581 598
582- input := usecase.SearchInput{599+ searchInput := usecase.SearchInput{
583 Query: query,600 Query: query,
584 Limit: OmniboxListDefaults.MaxResults,601 Limit: OmniboxListDefaults.MaxResults,
585 }602 }
586- output, err := o.historyUC.Search(ctx, input)603+ output, err := o.historyUC.Search(ctx, searchInput)
587 if err != nil {604 if err != nil {
588 log.Error().Err(err).Msg("history search failed")605 log.Error().Err(err).Msg("history search failed")
589 return606 return
@@ -997,10 +1014,10 @@ func (o *Omnibox) navigateToSelected() {
997 }1014 }
998 }1015 }
999 1016
1000-// looksLikeURL checks if the input appears to be a URL (not a search query).1017+// looksLikeURL checks if the text appears to be a URL (not a search query).
1001 // Returns true for strings like "github.com", "google.com/search", etc.1018 // Returns true for strings like "github.com", "google.com/search", etc.
1002-func (o *Omnibox) looksLikeURL(input string) bool {1019+func (o *Omnibox) looksLikeURL(text string) bool {
1003- return url.LooksLikeURL(input)1020+ return url.LooksLikeURL(text)
1004 }1021 }
1005 1022
1006 // toggleFavorite adds or removes the selected item from favorites.1023 // toggleFavorite adds or removes the selected item from favorites.
@@ -1040,12 +1057,12 @@ func (o *Omnibox) toggleFavorite() {
1040 1057
1041 goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites")1058 goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites")
1042 1059
1043- input := usecase.AddFavoriteInput{1060+ addInput := usecase.AddFavoriteInput{
1044 URL: s.URL,1061 URL: s.URL,
1045 Title: s.Title,1062 Title: s.Title,
1046 }1063 }
1047 1064
1048- fav, err := o.favoritesUC.Add(ctx, input)1065+ fav, err := o.favoritesUC.Add(ctx, addInput)
1049 if err != nil {1066 if err != nil {
1050 goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite")1067 goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite")
1051 return1068 return
@@ -1139,9 +1156,9 @@ func (o *Omnibox) yankSelectedURL() {
1139 }()1156 }()
1140 }1157 }
1141 1158
1142-// buildURL constructs a URL from input, handling search shortcuts.1159+// buildURL constructs a URL from text, handling search shortcuts.
1143-func (o *Omnibox) buildURL(input string) string {1160+func (o *Omnibox) buildURL(text string) string {
1144- return url.BuildSearchURL(input, o.shortcutURLs(), o.defaultSearch)1161+ return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch)
1145 }1162 }
1146 1163
1147 // shortcutURLs returns a map of shortcut keys to URL templates.1164 // shortcutURLs returns a map of shortcut keys to URL templates.
M internal/ui/input/keyboard.go +25 -5
@@ -99,8 +99,9 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
9999 h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
100100
101101 // Connect key pressed handler (retain callback to prevent GC).
102- h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
103- return h.handleKeyPress(keyval, state)
102+ // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
103+ h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
104+ return h.handleKeyPress(keyval, keycode, state)
104105 }
105106 h.controller.ConnectKeyPressed(&h.keyPressedCb)
106107
@@ -120,13 +121,18 @@ func (h *KeyboardHandler) Detach() {
120121
121122 // handleKeyPress processes a key press event.
122123 // Returns true if the event was handled and should not propagate further.
123-func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
124+// Parameters:
125+// - keyval: the translated key value (depends on keyboard layout)
126+// - keycode: the hardware keycode (physical key position, layout-independent)
127+// - state: modifier keys state
128+func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
129+ log := logging.FromContext(h.ctx)
130+
124131 h.mu.RLock()
125132 shouldBypass := h.shouldBypass
126133 h.mu.RUnlock()
127134 if shouldBypass != nil && shouldBypass() {
128- log := logging.FromContext(h.ctx)
129- log.Debug().Uint("keyval", keyval).Uint("state", uint(state)).Msg("keyboard handler bypassed")
135+ log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
130136 return false
131137 }
132138
@@ -142,6 +148,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
142148 // Look up the action for this key binding
143149 action, found := h.shortcuts.Lookup(binding, mode)
144150
151+ // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts
152+ // This enables Alt+1-9/0 to work on AZERTY, QWERTZ, and other Latin layouts
153+ // where number keys produce different characters without Shift.
154+ if !found && mode == ModeNormal && modifiers == ModAlt {
155+ if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
156+ action = keycodeAction
157+ found = true
158+ log.Debug().
159+ Uint("keycode", keycode).
160+ Str("action", string(action)).
161+ Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
162+ }
163+ }
164+
145165 if !found {
146166 if mode != ModeNormal {
147167 // In modal mode, consume unrecognized keys to prevent WebView handling
@@ -99,8 +99,9 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
99 h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)99 h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
100 100
101 // Connect key pressed handler (retain callback to prevent GC).101 // Connect key pressed handler (retain callback to prevent GC).
102- h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {102+ // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
103- return h.handleKeyPress(keyval, state)103+ h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
104+ return h.handleKeyPress(keyval, keycode, state)
104 }105 }
105 h.controller.ConnectKeyPressed(&h.keyPressedCb)106 h.controller.ConnectKeyPressed(&h.keyPressedCb)
106 107
@@ -120,13 +121,18 @@ func (h *KeyboardHandler) Detach() {
120 121
121 // handleKeyPress processes a key press event.122 // handleKeyPress processes a key press event.
122 // Returns true if the event was handled and should not propagate further.123 // Returns true if the event was handled and should not propagate further.
123-func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bool {124+// Parameters:
125+// - keyval: the translated key value (depends on keyboard layout)
126+// - keycode: the hardware keycode (physical key position, layout-independent)
127+// - state: modifier keys state
128+func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
129+ log := logging.FromContext(h.ctx)
130+
124 h.mu.RLock()131 h.mu.RLock()
125 shouldBypass := h.shouldBypass132 shouldBypass := h.shouldBypass
126 h.mu.RUnlock()133 h.mu.RUnlock()
127 if shouldBypass != nil && shouldBypass() {134 if shouldBypass != nil && shouldBypass() {
128- log := logging.FromContext(h.ctx)135+ log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
129- log.Debug().Uint("keyval", keyval).Uint("state", uint(state)).Msg("keyboard handler bypassed")
130 return false136 return false
131 }137 }
132 138
@@ -142,6 +148,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
142 // Look up the action for this key binding148 // Look up the action for this key binding
143 action, found := h.shortcuts.Lookup(binding, mode)149 action, found := h.shortcuts.Lookup(binding, mode)
144 150
151+ // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts
152+ // This enables Alt+1-9/0 to work on AZERTY, QWERTZ, and other Latin layouts
153+ // where number keys produce different characters without Shift.
154+ if !found && mode == ModeNormal && modifiers == ModAlt {
155+ if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
156+ action = keycodeAction
157+ found = true
158+ log.Debug().
159+ Uint("keycode", keycode).
160+ Str("action", string(action)).
161+ Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
162+ }
163+ }
164+
145 if !found {165 if !found {
146 if mode != ModeNormal {166 if mode != ModeNormal {
147 // In modal mode, consume unrecognized keys to prevent WebView handling167 // In modal mode, consume unrecognized keys to prevent WebView handling
A internal/ui/input/keycodes.go +58 -0
@@ -0,0 +1,58 @@
1+// Package input provides keyboard event handling and modal input mode management.
2+package input
3+
4+// Hardware keycodes for the number row keys.
5+// These are XKB keycodes (evdev + 8) representing physical key positions,
6+// independent of keyboard layout. This enables shortcuts like Alt+1 to work
7+// on non-QWERTY layouts (AZERTY, QWERTZ, etc.) where the number keys produce
8+// different characters without Shift.
9+//
10+// For example, on French AZERTY:
11+// - Physical "1" key produces "&" (keyval=ampersand)
12+// - But the hardware keycode is still 10
13+// - So Alt + physical "1" can be detected via keycode even though keyval differs
14+const (
15+ KeycodeDigit1 uint = 10 // Physical '1' key position
16+ KeycodeDigit2 uint = 11 // Physical '2' key position
17+ KeycodeDigit3 uint = 12 // Physical '3' key position
18+ KeycodeDigit4 uint = 13 // Physical '4' key position
19+ KeycodeDigit5 uint = 14 // Physical '5' key position
20+ KeycodeDigit6 uint = 15 // Physical '6' key position
21+ KeycodeDigit7 uint = 16 // Physical '7' key position
22+ KeycodeDigit8 uint = 17 // Physical '8' key position
23+ KeycodeDigit9 uint = 18 // Physical '9' key position
24+ KeycodeDigit0 uint = 19 // Physical '0' key position
25+)
26+
27+// KeycodeToTabAction maps hardware keycodes for the number row to tab switch actions.
28+// This enables Alt+1-9/0 shortcuts to work on non-QWERTY keyboards by matching
29+// the physical key position rather than the translated keyval.
30+var KeycodeToTabAction = map[uint]Action{
31+ KeycodeDigit1: ActionSwitchTabIndex1,
32+ KeycodeDigit2: ActionSwitchTabIndex2,
33+ KeycodeDigit3: ActionSwitchTabIndex3,
34+ KeycodeDigit4: ActionSwitchTabIndex4,
35+ KeycodeDigit5: ActionSwitchTabIndex5,
36+ KeycodeDigit6: ActionSwitchTabIndex6,
37+ KeycodeDigit7: ActionSwitchTabIndex7,
38+ KeycodeDigit8: ActionSwitchTabIndex8,
39+ KeycodeDigit9: ActionSwitchTabIndex9,
40+ KeycodeDigit0: ActionSwitchTabIndex10,
41+}
42+
43+// KeycodeToDigitIndex converts a hardware keycode to a digit index (0-9).
44+// Returns the index and true if the keycode is a number row key, or -1 and false otherwise.
45+// This is useful for components like omnibox that need Ctrl+1-9/0 shortcuts.
46+//
47+// Mapping:
48+// - Keycode 10 (physical '1') -> index 0
49+// - Keycode 11 (physical '2') -> index 1
50+// - ...
51+// - Keycode 18 (physical '9') -> index 8
52+// - Keycode 19 (physical '0') -> index 9
53+func KeycodeToDigitIndex(keycode uint) (index int, ok bool) {
54+ if keycode >= KeycodeDigit1 && keycode <= KeycodeDigit0 {
55+ return int(keycode - KeycodeDigit1), true
56+ }
57+ return -1, false
58+}
@@ -0,0 +1,58 @@
1+// Package input provides keyboard event handling and modal input mode management.
2+package input
3+
4+// Hardware keycodes for the number row keys.
5+// These are XKB keycodes (evdev + 8) representing physical key positions,
6+// independent of keyboard layout. This enables shortcuts like Alt+1 to work
7+// on non-QWERTY layouts (AZERTY, QWERTZ, etc.) where the number keys produce
8+// different characters without Shift.
9+//
10+// For example, on French AZERTY:
11+// - Physical "1" key produces "&" (keyval=ampersand)
12+// - But the hardware keycode is still 10
13+// - So Alt + physical "1" can be detected via keycode even though keyval differs
14+const (
15+ KeycodeDigit1 uint = 10 // Physical '1' key position
16+ KeycodeDigit2 uint = 11 // Physical '2' key position
17+ KeycodeDigit3 uint = 12 // Physical '3' key position
18+ KeycodeDigit4 uint = 13 // Physical '4' key position
19+ KeycodeDigit5 uint = 14 // Physical '5' key position
20+ KeycodeDigit6 uint = 15 // Physical '6' key position
21+ KeycodeDigit7 uint = 16 // Physical '7' key position
22+ KeycodeDigit8 uint = 17 // Physical '8' key position
23+ KeycodeDigit9 uint = 18 // Physical '9' key position
24+ KeycodeDigit0 uint = 19 // Physical '0' key position
25+)
26+
27+// KeycodeToTabAction maps hardware keycodes for the number row to tab switch actions.
28+// This enables Alt+1-9/0 shortcuts to work on non-QWERTY keyboards by matching
29+// the physical key position rather than the translated keyval.
30+var KeycodeToTabAction = map[uint]Action{
31+ KeycodeDigit1: ActionSwitchTabIndex1,
32+ KeycodeDigit2: ActionSwitchTabIndex2,
33+ KeycodeDigit3: ActionSwitchTabIndex3,
34+ KeycodeDigit4: ActionSwitchTabIndex4,
35+ KeycodeDigit5: ActionSwitchTabIndex5,
36+ KeycodeDigit6: ActionSwitchTabIndex6,
37+ KeycodeDigit7: ActionSwitchTabIndex7,
38+ KeycodeDigit8: ActionSwitchTabIndex8,
39+ KeycodeDigit9: ActionSwitchTabIndex9,
40+ KeycodeDigit0: ActionSwitchTabIndex10,
41+}
42+
43+// KeycodeToDigitIndex converts a hardware keycode to a digit index (0-9).
44+// Returns the index and true if the keycode is a number row key, or -1 and false otherwise.
45+// This is useful for components like omnibox that need Ctrl+1-9/0 shortcuts.
46+//
47+// Mapping:
48+// - Keycode 10 (physical '1') -> index 0
49+// - Keycode 11 (physical '2') -> index 1
50+// - ...
51+// - Keycode 18 (physical '9') -> index 8
52+// - Keycode 19 (physical '0') -> index 9
53+func KeycodeToDigitIndex(keycode uint) (index int, ok bool) {
54+ if keycode >= KeycodeDigit1 && keycode <= KeycodeDigit0 {
55+ return int(keycode - KeycodeDigit1), true
56+ }
57+ return -1, false
58+}
A internal/ui/input/keycodes_test.go +306 -0
@@ -0,0 +1,306 @@
1+package input
2+
3+import (
4+ "fmt"
5+ "testing"
6+)
7+
8+// TestKeycodeConstants verifies the hardware keycode constants are correct.
9+// These are XKB keycodes (evdev + 8) for the number row keys.
10+func TestKeycodeConstants(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ keycode uint
14+ expected uint
15+ }{
16+ {"KeycodeDigit1", KeycodeDigit1, 10},
17+ {"KeycodeDigit2", KeycodeDigit2, 11},
18+ {"KeycodeDigit3", KeycodeDigit3, 12},
19+ {"KeycodeDigit4", KeycodeDigit4, 13},
20+ {"KeycodeDigit5", KeycodeDigit5, 14},
21+ {"KeycodeDigit6", KeycodeDigit6, 15},
22+ {"KeycodeDigit7", KeycodeDigit7, 16},
23+ {"KeycodeDigit8", KeycodeDigit8, 17},
24+ {"KeycodeDigit9", KeycodeDigit9, 18},
25+ {"KeycodeDigit0", KeycodeDigit0, 19},
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ if tt.keycode != tt.expected {
31+ t.Errorf("%s = %d, want %d", tt.name, tt.keycode, tt.expected)
32+ }
33+ })
34+ }
35+}
36+
37+// TestKeycodeToTabActionMapping verifies all number row keycodes map to the correct tab actions.
38+func TestKeycodeToTabActionMapping(t *testing.T) {
39+ tests := []struct {
40+ keycode uint
41+ expectedAction Action
42+ }{
43+ {KeycodeDigit1, ActionSwitchTabIndex1},
44+ {KeycodeDigit2, ActionSwitchTabIndex2},
45+ {KeycodeDigit3, ActionSwitchTabIndex3},
46+ {KeycodeDigit4, ActionSwitchTabIndex4},
47+ {KeycodeDigit5, ActionSwitchTabIndex5},
48+ {KeycodeDigit6, ActionSwitchTabIndex6},
49+ {KeycodeDigit7, ActionSwitchTabIndex7},
50+ {KeycodeDigit8, ActionSwitchTabIndex8},
51+ {KeycodeDigit9, ActionSwitchTabIndex9},
52+ {KeycodeDigit0, ActionSwitchTabIndex10},
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(string(tt.expectedAction), func(t *testing.T) {
57+ action, ok := KeycodeToTabAction[tt.keycode]
58+ if !ok {
59+ t.Errorf("keycode %d not found in KeycodeToTabAction map", tt.keycode)
60+ return
61+ }
62+ if action != tt.expectedAction {
63+ t.Errorf("KeycodeToTabAction[%d] = %s, want %s", tt.keycode, action, tt.expectedAction)
64+ }
65+ })
66+ }
67+}
68+
69+// TestKeycodeToTabActionCompleteness verifies the map contains exactly 10 entries.
70+func TestKeycodeToTabActionCompleteness(t *testing.T) {
71+ expectedCount := 10
72+ if len(KeycodeToTabAction) != expectedCount {
73+ t.Errorf("KeycodeToTabAction has %d entries, want %d", len(KeycodeToTabAction), expectedCount)
74+ }
75+}
76+
77+// TestKeycodeToTabActionNoUnexpectedKeycodes verifies only valid keycodes are in the map.
78+func TestKeycodeToTabActionNoUnexpectedKeycodes(t *testing.T) {
79+ validKeycodes := map[uint]bool{
80+ KeycodeDigit1: true,
81+ KeycodeDigit2: true,
82+ KeycodeDigit3: true,
83+ KeycodeDigit4: true,
84+ KeycodeDigit5: true,
85+ KeycodeDigit6: true,
86+ KeycodeDigit7: true,
87+ KeycodeDigit8: true,
88+ KeycodeDigit9: true,
89+ KeycodeDigit0: true,
90+ }
91+
92+ for keycode := range KeycodeToTabAction {
93+ if !validKeycodes[keycode] {
94+ t.Errorf("unexpected keycode %d in KeycodeToTabAction map", keycode)
95+ }
96+ }
97+}
98+
99+// European keyboard layout test scenarios.
100+// These test the concept that hardware keycodes are layout-independent.
101+//
102+// On all these layouts, the physical "1" key has hardware keycode 10,
103+// regardless of what character it produces:
104+//
105+// | Layout | Physical "1" key produces | Keycode |
106+// |-------------|---------------------------|---------|
107+// | QWERTY (US) | 1 | 10 |
108+// | QWERTY (UK) | 1 | 10 |
109+// | AZERTY (FR) | & | 10 |
110+// | AZERTY (BE) | & | 10 |
111+// | QWERTZ (DE) | 1 | 10 |
112+// | QWERTZ (CH) | 1 | 10 |
113+// | Dvorak | 1 | 10 |
114+
115+// TestEuropeanLayoutKeycodes documents and verifies the keycode behavior
116+// expected for various European keyboard layouts.
117+func TestEuropeanLayoutKeycodes(t *testing.T) {
118+ // This test documents that our solution works for all these layouts
119+ // because we use hardware keycodes, not keyvals.
120+ //
121+ // The keycode for the physical number row is always 10-19,
122+ // regardless of what character the key produces.
123+
124+ layouts := []struct {
125+ name string
126+ description string
127+ // Physical key position (1-9, 0) always maps to keycode 10-19
128+ // The keyval differs but we don't care - we use keycode
129+ }{
130+ {
131+ name: "QWERTY_US",
132+ description: "US English - 1234567890 produce digits directly",
133+ },
134+ {
135+ name: "QWERTY_UK",
136+ description: "UK English - 1234567890 produce digits directly",
137+ },
138+ {
139+ name: "AZERTY_FR",
140+ description: "French - &é\"'(-è_çà produce symbols, Shift for digits",
141+ },
142+ {
143+ name: "AZERTY_BE",
144+ description: "Belgian - &é\"'(§è!çà produce symbols, Shift for digits",
145+ },
146+ {
147+ name: "QWERTZ_DE",
148+ description: "German - 1234567890 produce digits directly (symbols on Shift)",
149+ },
150+ {
151+ name: "QWERTZ_CH",
152+ description: "Swiss German - 1234567890 produce digits directly",
153+ },
154+ {
155+ name: "QWERTZ_AT",
156+ description: "Austrian - 1234567890 produce digits directly",
157+ },
158+ {
159+ name: "Dvorak",
160+ description: "Dvorak - number row same as QWERTY",
161+ },
162+ {
163+ name: "Colemak",
164+ description: "Colemak - number row same as QWERTY",
165+ },
166+ {
167+ name: "Portuguese_PT",
168+ description: "Portuguese - 1234567890 produce digits directly",
169+ },
170+ {
171+ name: "Spanish_ES",
172+ description: "Spanish - 1234567890 produce digits directly",
173+ },
174+ {
175+ name: "Italian_IT",
176+ description: "Italian - 1234567890 produce digits directly",
177+ },
178+ {
179+ name: "Nordic",
180+ description: "Nordic layouts (SE/NO/DK/FI) - 1234567890 produce digits directly",
181+ },
182+ }
183+
184+ for _, layout := range layouts {
185+ t.Run(layout.name, func(t *testing.T) {
186+ // For all layouts, pressing Alt + physical "1" key should:
187+ // 1. Have hardware keycode 10 (KeycodeDigit1)
188+ // 2. Map to ActionSwitchTabIndex1
189+
190+ action, ok := KeycodeToTabAction[KeycodeDigit1]
191+ if !ok {
192+ t.Errorf("[%s] keycode 10 (physical '1') not mapped", layout.name)
193+ return
194+ }
195+ if action != ActionSwitchTabIndex1 {
196+ t.Errorf("[%s] keycode 10 maps to %s, want %s",
197+ layout.name, action, ActionSwitchTabIndex1)
198+ }
199+
200+ // Document the layout for clarity
201+ t.Logf("Layout %s: %s - keycode fallback will work", layout.name, layout.description)
202+ })
203+ }
204+}
205+
206+// TestAZERTYSpecificScenario tests the specific scenario from issue #25.
207+// On French AZERTY, pressing Alt+1 produces Alt+& (ampersand) because
208+// the digit 1 requires Shift. Our keycode fallback handles this.
209+func TestAZERTYSpecificScenario(t *testing.T) {
210+ // Scenario: User on French AZERTY keyboard presses Alt + physical "1" key
211+ //
212+ // What happens:
213+ // 1. GTK receives: keyval=ampersand (0x26), keycode=10, modifiers=Alt
214+ // 2. keyval lookup for "Alt+&" fails (no such shortcut defined)
215+ // 3. Keycode fallback: keycode 10 with Alt modifier
216+ // 4. KeycodeToTabAction[10] = ActionSwitchTabIndex1
217+ // 5. Tab 1 is activated
218+
219+ // The keycode for physical "1" on ALL layouts
220+ physicalKey1Keycode := uint(10)
221+
222+ // Verify our mapping handles this
223+ action, ok := KeycodeToTabAction[physicalKey1Keycode]
224+ if !ok {
225+ t.Fatal("keycode 10 should be mapped for AZERTY support")
226+ }
227+ if action != ActionSwitchTabIndex1 {
228+ t.Errorf("keycode 10 should map to ActionSwitchTabIndex1, got %s", action)
229+ }
230+}
231+
232+// TestNonNumberRowKeycodes verifies that non-number-row keycodes don't accidentally match.
233+func TestNonNumberRowKeycodes(t *testing.T) {
234+ // Some keycodes that should NOT be in our map
235+ invalidKeycodes := []uint{
236+ 0, // Invalid
237+ 1, // Escape
238+ 9, // Before number row
239+ 20, // After number row (minus key)
240+ 24, // Q key
241+ 38, // A key
242+ 50, // Shift
243+ 64, // Alt
244+ }
245+
246+ for _, keycode := range invalidKeycodes {
247+ if _, ok := KeycodeToTabAction[keycode]; ok {
248+ t.Errorf("keycode %d should not be in KeycodeToTabAction map", keycode)
249+ }
250+ }
251+}
252+
253+// TestKeycodeToDigitIndex verifies the keycode to digit index conversion.
254+func TestKeycodeToDigitIndex(t *testing.T) {
255+ tests := []struct {
256+ keycode uint
257+ expectedIndex int
258+ expectedOk bool
259+ }{
260+ {KeycodeDigit1, 0, true},
261+ {KeycodeDigit2, 1, true},
262+ {KeycodeDigit3, 2, true},
263+ {KeycodeDigit4, 3, true},
264+ {KeycodeDigit5, 4, true},
265+ {KeycodeDigit6, 5, true},
266+ {KeycodeDigit7, 6, true},
267+ {KeycodeDigit8, 7, true},
268+ {KeycodeDigit9, 8, true},
269+ {KeycodeDigit0, 9, true},
270+ // Invalid keycodes
271+ {0, -1, false},
272+ {9, -1, false}, // Before number row
273+ {20, -1, false}, // After number row
274+ {64, -1, false}, // Alt key
275+ }
276+
277+ for _, tt := range tests {
278+ t.Run(fmt.Sprintf("keycode_%d", tt.keycode), func(t *testing.T) {
279+ index, ok := KeycodeToDigitIndex(tt.keycode)
280+ if ok != tt.expectedOk {
281+ t.Errorf("KeycodeToDigitIndex(%d) ok = %v, want %v", tt.keycode, ok, tt.expectedOk)
282+ }
283+ if index != tt.expectedIndex {
284+ t.Errorf("KeycodeToDigitIndex(%d) index = %d, want %d", tt.keycode, index, tt.expectedIndex)
285+ }
286+ })
287+ }
288+}
289+
290+// TestKeycodeToDigitIndexForOmnibox verifies the function works for omnibox Ctrl+1-9/0 shortcuts.
291+func TestKeycodeToDigitIndexForOmnibox(t *testing.T) {
292+ // Simulate omnibox usage: Ctrl+1 should select first item (index 0)
293+ // Ctrl+0 should select 10th item (index 9)
294+
295+ // Physical "1" key -> index 0 (first omnibox result)
296+ index, ok := KeycodeToDigitIndex(KeycodeDigit1)
297+ if !ok || index != 0 {
298+ t.Errorf("Ctrl+1 should map to index 0, got index=%d ok=%v", index, ok)
299+ }
300+
301+ // Physical "0" key -> index 9 (10th omnibox result)
302+ index, ok = KeycodeToDigitIndex(KeycodeDigit0)
303+ if !ok || index != 9 {
304+ t.Errorf("Ctrl+0 should map to index 9, got index=%d ok=%v", index, ok)
305+ }
306+}
@@ -0,0 +1,306 @@
1+package input
2+
3+import (
4+ "fmt"
5+ "testing"
6+)
7+
8+// TestKeycodeConstants verifies the hardware keycode constants are correct.
9+// These are XKB keycodes (evdev + 8) for the number row keys.
10+func TestKeycodeConstants(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ keycode uint
14+ expected uint
15+ }{
16+ {"KeycodeDigit1", KeycodeDigit1, 10},
17+ {"KeycodeDigit2", KeycodeDigit2, 11},
18+ {"KeycodeDigit3", KeycodeDigit3, 12},
19+ {"KeycodeDigit4", KeycodeDigit4, 13},
20+ {"KeycodeDigit5", KeycodeDigit5, 14},
21+ {"KeycodeDigit6", KeycodeDigit6, 15},
22+ {"KeycodeDigit7", KeycodeDigit7, 16},
23+ {"KeycodeDigit8", KeycodeDigit8, 17},
24+ {"KeycodeDigit9", KeycodeDigit9, 18},
25+ {"KeycodeDigit0", KeycodeDigit0, 19},
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ if tt.keycode != tt.expected {
31+ t.Errorf("%s = %d, want %d", tt.name, tt.keycode, tt.expected)
32+ }
33+ })
34+ }
35+}
36+
37+// TestKeycodeToTabActionMapping verifies all number row keycodes map to the correct tab actions.
38+func TestKeycodeToTabActionMapping(t *testing.T) {
39+ tests := []struct {
40+ keycode uint
41+ expectedAction Action
42+ }{
43+ {KeycodeDigit1, ActionSwitchTabIndex1},
44+ {KeycodeDigit2, ActionSwitchTabIndex2},
45+ {KeycodeDigit3, ActionSwitchTabIndex3},
46+ {KeycodeDigit4, ActionSwitchTabIndex4},
47+ {KeycodeDigit5, ActionSwitchTabIndex5},
48+ {KeycodeDigit6, ActionSwitchTabIndex6},
49+ {KeycodeDigit7, ActionSwitchTabIndex7},
50+ {KeycodeDigit8, ActionSwitchTabIndex8},
51+ {KeycodeDigit9, ActionSwitchTabIndex9},
52+ {KeycodeDigit0, ActionSwitchTabIndex10},
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(string(tt.expectedAction), func(t *testing.T) {
57+ action, ok := KeycodeToTabAction[tt.keycode]
58+ if !ok {
59+ t.Errorf("keycode %d not found in KeycodeToTabAction map", tt.keycode)
60+ return
61+ }
62+ if action != tt.expectedAction {
63+ t.Errorf("KeycodeToTabAction[%d] = %s, want %s", tt.keycode, action, tt.expectedAction)
64+ }
65+ })
66+ }
67+}
68+
69+// TestKeycodeToTabActionCompleteness verifies the map contains exactly 10 entries.
70+func TestKeycodeToTabActionCompleteness(t *testing.T) {
71+ expectedCount := 10
72+ if len(KeycodeToTabAction) != expectedCount {
73+ t.Errorf("KeycodeToTabAction has %d entries, want %d", len(KeycodeToTabAction), expectedCount)
74+ }
75+}
76+
77+// TestKeycodeToTabActionNoUnexpectedKeycodes verifies only valid keycodes are in the map.
78+func TestKeycodeToTabActionNoUnexpectedKeycodes(t *testing.T) {
79+ validKeycodes := map[uint]bool{
80+ KeycodeDigit1: true,
81+ KeycodeDigit2: true,
82+ KeycodeDigit3: true,
83+ KeycodeDigit4: true,
84+ KeycodeDigit5: true,
85+ KeycodeDigit6: true,
86+ KeycodeDigit7: true,
87+ KeycodeDigit8: true,
88+ KeycodeDigit9: true,
89+ KeycodeDigit0: true,
90+ }
91+
92+ for keycode := range KeycodeToTabAction {
93+ if !validKeycodes[keycode] {
94+ t.Errorf("unexpected keycode %d in KeycodeToTabAction map", keycode)
95+ }
96+ }
97+}
98+
99+// European keyboard layout test scenarios.
100+// These test the concept that hardware keycodes are layout-independent.
101+//
102+// On all these layouts, the physical "1" key has hardware keycode 10,
103+// regardless of what character it produces:
104+//
105+// | Layout | Physical "1" key produces | Keycode |
106+// |-------------|---------------------------|---------|
107+// | QWERTY (US) | 1 | 10 |
108+// | QWERTY (UK) | 1 | 10 |
109+// | AZERTY (FR) | & | 10 |
110+// | AZERTY (BE) | & | 10 |
111+// | QWERTZ (DE) | 1 | 10 |
112+// | QWERTZ (CH) | 1 | 10 |
113+// | Dvorak | 1 | 10 |
114+
115+// TestEuropeanLayoutKeycodes documents and verifies the keycode behavior
116+// expected for various European keyboard layouts.
117+func TestEuropeanLayoutKeycodes(t *testing.T) {
118+ // This test documents that our solution works for all these layouts
119+ // because we use hardware keycodes, not keyvals.
120+ //
121+ // The keycode for the physical number row is always 10-19,
122+ // regardless of what character the key produces.
123+
124+ layouts := []struct {
125+ name string
126+ description string
127+ // Physical key position (1-9, 0) always maps to keycode 10-19
128+ // The keyval differs but we don't care - we use keycode
129+ }{
130+ {
131+ name: "QWERTY_US",
132+ description: "US English - 1234567890 produce digits directly",
133+ },
134+ {
135+ name: "QWERTY_UK",
136+ description: "UK English - 1234567890 produce digits directly",
137+ },
138+ {
139+ name: "AZERTY_FR",
140+ description: "French - &é\"'(-è_çà produce symbols, Shift for digits",
141+ },
142+ {
143+ name: "AZERTY_BE",
144+ description: "Belgian - &é\"'(§è!çà produce symbols, Shift for digits",
145+ },
146+ {
147+ name: "QWERTZ_DE",
148+ description: "German - 1234567890 produce digits directly (symbols on Shift)",
149+ },
150+ {
151+ name: "QWERTZ_CH",
152+ description: "Swiss German - 1234567890 produce digits directly",
153+ },
154+ {
155+ name: "QWERTZ_AT",
156+ description: "Austrian - 1234567890 produce digits directly",
157+ },
158+ {
159+ name: "Dvorak",
160+ description: "Dvorak - number row same as QWERTY",
161+ },
162+ {
163+ name: "Colemak",
164+ description: "Colemak - number row same as QWERTY",
165+ },
166+ {
167+ name: "Portuguese_PT",
168+ description: "Portuguese - 1234567890 produce digits directly",
169+ },
170+ {
171+ name: "Spanish_ES",
172+ description: "Spanish - 1234567890 produce digits directly",
173+ },
174+ {
175+ name: "Italian_IT",
176+ description: "Italian - 1234567890 produce digits directly",
177+ },
178+ {
179+ name: "Nordic",
180+ description: "Nordic layouts (SE/NO/DK/FI) - 1234567890 produce digits directly",
181+ },
182+ }
183+
184+ for _, layout := range layouts {
185+ t.Run(layout.name, func(t *testing.T) {
186+ // For all layouts, pressing Alt + physical "1" key should:
187+ // 1. Have hardware keycode 10 (KeycodeDigit1)
188+ // 2. Map to ActionSwitchTabIndex1
189+
190+ action, ok := KeycodeToTabAction[KeycodeDigit1]
191+ if !ok {
192+ t.Errorf("[%s] keycode 10 (physical '1') not mapped", layout.name)
193+ return
194+ }
195+ if action != ActionSwitchTabIndex1 {
196+ t.Errorf("[%s] keycode 10 maps to %s, want %s",
197+ layout.name, action, ActionSwitchTabIndex1)
198+ }
199+
200+ // Document the layout for clarity
201+ t.Logf("Layout %s: %s - keycode fallback will work", layout.name, layout.description)
202+ })
203+ }
204+}
205+
206+// TestAZERTYSpecificScenario tests the specific scenario from issue #25.
207+// On French AZERTY, pressing Alt+1 produces Alt+& (ampersand) because
208+// the digit 1 requires Shift. Our keycode fallback handles this.
209+func TestAZERTYSpecificScenario(t *testing.T) {
210+ // Scenario: User on French AZERTY keyboard presses Alt + physical "1" key
211+ //
212+ // What happens:
213+ // 1. GTK receives: keyval=ampersand (0x26), keycode=10, modifiers=Alt
214+ // 2. keyval lookup for "Alt+&" fails (no such shortcut defined)
215+ // 3. Keycode fallback: keycode 10 with Alt modifier
216+ // 4. KeycodeToTabAction[10] = ActionSwitchTabIndex1
217+ // 5. Tab 1 is activated
218+
219+ // The keycode for physical "1" on ALL layouts
220+ physicalKey1Keycode := uint(10)
221+
222+ // Verify our mapping handles this
223+ action, ok := KeycodeToTabAction[physicalKey1Keycode]
224+ if !ok {
225+ t.Fatal("keycode 10 should be mapped for AZERTY support")
226+ }
227+ if action != ActionSwitchTabIndex1 {
228+ t.Errorf("keycode 10 should map to ActionSwitchTabIndex1, got %s", action)
229+ }
230+}
231+
232+// TestNonNumberRowKeycodes verifies that non-number-row keycodes don't accidentally match.
233+func TestNonNumberRowKeycodes(t *testing.T) {
234+ // Some keycodes that should NOT be in our map
235+ invalidKeycodes := []uint{
236+ 0, // Invalid
237+ 1, // Escape
238+ 9, // Before number row
239+ 20, // After number row (minus key)
240+ 24, // Q key
241+ 38, // A key
242+ 50, // Shift
243+ 64, // Alt
244+ }
245+
246+ for _, keycode := range invalidKeycodes {
247+ if _, ok := KeycodeToTabAction[keycode]; ok {
248+ t.Errorf("keycode %d should not be in KeycodeToTabAction map", keycode)
249+ }
250+ }
251+}
252+
253+// TestKeycodeToDigitIndex verifies the keycode to digit index conversion.
254+func TestKeycodeToDigitIndex(t *testing.T) {
255+ tests := []struct {
256+ keycode uint
257+ expectedIndex int
258+ expectedOk bool
259+ }{
260+ {KeycodeDigit1, 0, true},
261+ {KeycodeDigit2, 1, true},
262+ {KeycodeDigit3, 2, true},
263+ {KeycodeDigit4, 3, true},
264+ {KeycodeDigit5, 4, true},
265+ {KeycodeDigit6, 5, true},
266+ {KeycodeDigit7, 6, true},
267+ {KeycodeDigit8, 7, true},
268+ {KeycodeDigit9, 8, true},
269+ {KeycodeDigit0, 9, true},
270+ // Invalid keycodes
271+ {0, -1, false},
272+ {9, -1, false}, // Before number row
273+ {20, -1, false}, // After number row
274+ {64, -1, false}, // Alt key
275+ }
276+
277+ for _, tt := range tests {
278+ t.Run(fmt.Sprintf("keycode_%d", tt.keycode), func(t *testing.T) {
279+ index, ok := KeycodeToDigitIndex(tt.keycode)
280+ if ok != tt.expectedOk {
281+ t.Errorf("KeycodeToDigitIndex(%d) ok = %v, want %v", tt.keycode, ok, tt.expectedOk)
282+ }
283+ if index != tt.expectedIndex {
284+ t.Errorf("KeycodeToDigitIndex(%d) index = %d, want %d", tt.keycode, index, tt.expectedIndex)
285+ }
286+ })
287+ }
288+}
289+
290+// TestKeycodeToDigitIndexForOmnibox verifies the function works for omnibox Ctrl+1-9/0 shortcuts.
291+func TestKeycodeToDigitIndexForOmnibox(t *testing.T) {
292+ // Simulate omnibox usage: Ctrl+1 should select first item (index 0)
293+ // Ctrl+0 should select 10th item (index 9)
294+
295+ // Physical "1" key -> index 0 (first omnibox result)
296+ index, ok := KeycodeToDigitIndex(KeycodeDigit1)
297+ if !ok || index != 0 {
298+ t.Errorf("Ctrl+1 should map to index 0, got index=%d ok=%v", index, ok)
299+ }
300+
301+ // Physical "0" key -> index 9 (10th omnibox result)
302+ index, ok = KeycodeToDigitIndex(KeycodeDigit0)
303+ if !ok || index != 9 {
304+ t.Errorf("Ctrl+0 should map to index 9, got index=%d ok=%v", index, ok)
305+ }
306+}

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