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. | |||
| 21 | 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 | 23 | ### 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 | 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 | 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 | ||
| @@ -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 | ### Fixed | 23 | ### 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 ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 15 | 15 | "github.com/bnema/dumber/internal/ui/adapter" | |
| 16 | + | "github.com/bnema/dumber/internal/ui/input" | |
| 16 | 17 | "github.com/bnema/dumber/internal/ui/layout" | |
| 17 | 18 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 18 | 19 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -284,8 +285,8 @@ func (o *Omnibox) setupKeyboardHandling() { | |||
| 284 | 285 | // Set capture phase to intercept before entry | |
| 285 | 286 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 286 | 287 | ||
| 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) | |
| 289 | 290 | } | |
| 290 | 291 | o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb) | |
| 291 | 292 | controller.ConnectKeyPressed(&keyPressedCb) | |
| @@ -434,7 +435,7 @@ func (o *Omnibox) setupEntryChanged() { | |||
| 434 | 435 | ||
| 435 | 436 | // handleKeyPress processes keyboard events. | |
| 436 | 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 | 439 | ctrl := state&gdk.ControlMaskValue != 0 | |
| 439 | 440 | ||
| 440 | 441 | switch keyval { | |
| @@ -494,29 +495,45 @@ func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool { | |||
| 494 | 495 | return false // Let entry handle 'y' for typing | |
| 495 | 496 | ||
| 496 | 497 | default: | |
| 497 | - | return o.handleCtrlNumberShortcut(keyval, ctrl) | |
| 498 | + | return o.handleCtrlNumberShortcut(keyval, keycode, ctrl) | |
| 498 | 499 | } | |
| 499 | 500 | ||
| 500 | 501 | return false // Let entry handle the key | |
| 501 | 502 | } | |
| 502 | 503 | ||
| 503 | 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 | 507 | if !ctrl { | |
| 506 | 508 | return false | |
| 507 | 509 | } | |
| 508 | 510 | ||
| 509 | 511 | const tenthItemIndex = 9 | |
| 512 | + | ||
| 513 | + | // First try keyval (works for QWERTY layouts) | |
| 510 | 514 | switch keyval { | |
| 511 | 515 | case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5), | |
| 512 | 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)) | |
| 514 | - | o.selectAndNavigate(index) | |
| 517 | + | idx := int(keyval - uint(gdk.KEY_1)) | |
| 518 | + | o.selectAndNavigate(idx) | |
| 515 | 519 | return true | |
| 516 | 520 | case uint(gdk.KEY_0): | |
| 517 | 521 | o.selectAndNavigate(tenthItemIndex) | |
| 518 | 522 | 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 | 537 | return false | |
| 521 | 538 | } | |
| 522 | 539 | ||
| @@ -579,11 +596,11 @@ func (o *Omnibox) performSearch() { | |||
| 579 | 596 | return | |
| 580 | 597 | } | |
| 581 | 598 | ||
| 582 | - | input := usecase.SearchInput{ | |
| 599 | + | searchInput := usecase.SearchInput{ | |
| 583 | 600 | Query: query, | |
| 584 | 601 | Limit: OmniboxListDefaults.MaxResults, | |
| 585 | 602 | } | |
| 586 | - | output, err := o.historyUC.Search(ctx, input) | |
| 603 | + | output, err := o.historyUC.Search(ctx, searchInput) | |
| 587 | 604 | if err != nil { | |
| 588 | 605 | log.Error().Err(err).Msg("history search failed") | |
| 589 | 606 | 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 | 1018 | // 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) | |
| 1004 | 1021 | } | |
| 1005 | 1022 | ||
| 1006 | 1023 | // toggleFavorite adds or removes the selected item from favorites. | |
| @@ -1040,12 +1057,12 @@ func (o *Omnibox) toggleFavorite() { | |||
| 1040 | 1057 | ||
| 1041 | 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 | 1061 | URL: s.URL, | |
| 1045 | 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 | 1066 | if err != nil { | |
| 1050 | 1067 | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite") | |
| 1051 | 1068 | 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. | |
| 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) | |
| 1145 | 1162 | } | |
| 1146 | 1163 | ||
| 1147 | 1164 | // 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 entry | 285 | // 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 != 0 | 439 | 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 typing | 495 | 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 key | 501 | 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 false | 508 | return false | ||
| 507 | } | 509 | } | ||
| 508 | 510 | ||||
| 509 | const tenthItemIndex = 9 | 511 | 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 true | 519 | return true | ||
| 516 | case uint(gdk.KEY_0): | 520 | case uint(gdk.KEY_0): | ||
| 517 | o.selectAndNavigate(tenthItemIndex) | 521 | o.selectAndNavigate(tenthItemIndex) | ||
| 518 | return true | 522 | 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 false | 537 | return false | ||
| 521 | } | 538 | } | ||
| 522 | 539 | ||||
| @@ -579,11 +596,11 @@ func (o *Omnibox) performSearch() { | |||||
| 579 | return | 596 | 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 | return | 606 | 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 | return | 1068 | 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) { | |||
| 99 | 99 | h.controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 100 | 100 | ||
| 101 | 101 | // 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) | |
| 104 | 105 | } | |
| 105 | 106 | h.controller.ConnectKeyPressed(&h.keyPressedCb) | |
| 106 | 107 | ||
| @@ -120,13 +121,18 @@ func (h *KeyboardHandler) Detach() { | |||
| 120 | 121 | ||
| 121 | 122 | // handleKeyPress processes a key press event. | |
| 122 | 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 | 131 | h.mu.RLock() | |
| 125 | 132 | shouldBypass := h.shouldBypass | |
| 126 | 133 | h.mu.RUnlock() | |
| 127 | 134 | 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") | |
| 130 | 136 | return false | |
| 131 | 137 | } | |
| 132 | 138 | ||
| @@ -142,6 +148,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo | |||
| 142 | 148 | // Look up the action for this key binding | |
| 143 | 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 | 165 | if !found { | |
| 146 | 166 | if mode != ModeNormal { | |
| 147 | 167 | // 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.shouldBypass | 132 | 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 false | 136 | 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 binding | 148 | // 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 handling | 167 | // 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 | + | } | |||