fix(vimkeys): round trip modified unicode keys
- commit
- 3f164d00e66a54a586a4e276f9aeb4f097ca0768
- author
- brice · · 2026-07-29 06:13 UTC
- committer
- bnema · · 2026-08-14 02:42 UTC
- parent
- bf7a503
- signature
- unsigned
4 files · +126 -20
M internal/domain/vimkeys/parse.go +42 -19
| @@ -355,17 +355,13 @@ func symFromToken(token string, allowSingleLetter bool) (string, error) { | |||
| 355 | 355 | if sym, ok := symFromSpecialAlias(token); ok { | |
| 356 | 356 | return sym, nil | |
| 357 | 357 | } | |
| 358 | - | if allowSingleLetter && len(token) == 1 { | |
| 359 | - | ch := token[0] | |
| 360 | - | if ch >= 'A' && ch <= 'Z' { | |
| 361 | - | return strings.ToLower(token), nil | |
| 362 | - | } | |
| 363 | - | if ch >= 'a' && ch <= 'z' { | |
| 364 | - | return token, nil | |
| 358 | + | if allowSingleLetter { | |
| 359 | + | if sym, ok := asciiLetterSym(token); ok { | |
| 360 | + | return sym, nil | |
| 365 | 361 | } | |
| 366 | 362 | } | |
| 367 | - | if len(token) == 1 && unicode.IsPrint(rune(token[0])) { | |
| 368 | - | return token, nil | |
| 363 | + | if sym, ok := singlePrintableRuneSym(token); ok { | |
| 364 | + | return sym, nil | |
| 369 | 365 | } | |
| 370 | 366 | return "", ErrBadBinding | |
| 371 | 367 | } | |
| @@ -374,16 +370,43 @@ func symFromAlias(token string) (string, error) { | |||
| 374 | 370 | if sym, ok := symFromSpecialAlias(token); ok { | |
| 375 | 371 | return sym, nil | |
| 376 | 372 | } | |
| 377 | - | // One-character literal angle tokens (<e>) disambiguate legacy-atom collisions. | |
| 378 | - | // Multi-character unknowns such as <Nope> remain invalid. | |
| 379 | - | if len(token) == 1 { | |
| 380 | - | ch := token[0] | |
| 381 | - | if ch >= 'a' && ch <= 'z' { | |
| 382 | - | return token, nil | |
| 383 | - | } | |
| 384 | - | if ch >= 'A' && ch <= 'Z' { | |
| 385 | - | return strings.ToLower(token), nil | |
| 386 | - | } | |
| 373 | + | // One-character / one-rune literal angle tokens (<e>, <é>) disambiguate | |
| 374 | + | // legacy-atom collisions. Multi-rune unknowns such as <Nope> remain invalid. | |
| 375 | + | if sym, ok := asciiLetterSym(token); ok { | |
| 376 | + | return sym, nil | |
| 377 | + | } | |
| 378 | + | if sym, ok := singlePrintableRuneSym(token); ok { | |
| 379 | + | return sym, nil | |
| 387 | 380 | } | |
| 388 | 381 | return "", ErrBadBinding | |
| 389 | 382 | } | |
| 383 | + | ||
| 384 | + | // asciiLetterSym normalizes a single ASCII letter token (A-Z -> a-z). | |
| 385 | + | func asciiLetterSym(token string) (string, bool) { | |
| 386 | + | if len(token) != 1 { | |
| 387 | + | return "", false | |
| 388 | + | } | |
| 389 | + | ch := token[0] | |
| 390 | + | if ch >= 'A' && ch <= 'Z' { | |
| 391 | + | return strings.ToLower(token), true | |
| 392 | + | } | |
| 393 | + | if ch >= 'a' && ch <= 'z' { | |
| 394 | + | return token, true | |
| 395 | + | } | |
| 396 | + | return "", false | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | // singlePrintableRuneSym accepts exactly one valid UTF-8 printable rune. | |
| 400 | + | func singlePrintableRuneSym(token string) (string, bool) { | |
| 401 | + | if token == "" || !utf8.ValidString(token) { | |
| 402 | + | return "", false | |
| 403 | + | } | |
| 404 | + | r, size := utf8.DecodeRuneInString(token) | |
| 405 | + | if size != len(token) { | |
| 406 | + | return "", false | |
| 407 | + | } | |
| 408 | + | if !unicode.IsPrint(r) { | |
| 409 | + | return "", false | |
| 410 | + | } | |
| 411 | + | return token, true | |
| 412 | + | } | |
| @@ -355,17 +355,13 @@ func symFromToken(token string, allowSingleLetter bool) (string, error) { | |||||
| 355 | if sym, ok := symFromSpecialAlias(token); ok { | 355 | if sym, ok := symFromSpecialAlias(token); ok { | ||
| 356 | return sym, nil | 356 | return sym, nil | ||
| 357 | } | 357 | } | ||
| 358 | - | if allowSingleLetter && len(token) == 1 { | 358 | + | if allowSingleLetter { |
| 359 | - | ch := token[0] | 359 | + | if sym, ok := asciiLetterSym(token); ok { |
| 360 | - | if ch >= 'A' && ch <= 'Z' { | 360 | + | return sym, nil |
| 361 | - | return strings.ToLower(token), nil | |||
| 362 | - | } | |||
| 363 | - | if ch >= 'a' && ch <= 'z' { | |||
| 364 | - | return token, nil | |||
| 365 | } | 361 | } | ||
| 366 | } | 362 | } | ||
| 367 | - | if len(token) == 1 && unicode.IsPrint(rune(token[0])) { | 363 | + | if sym, ok := singlePrintableRuneSym(token); ok { |
| 368 | - | return token, nil | 364 | + | return sym, nil |
| 369 | } | 365 | } | ||
| 370 | return "", ErrBadBinding | 366 | return "", ErrBadBinding | ||
| 371 | } | 367 | } | ||
| @@ -374,16 +370,43 @@ func symFromAlias(token string) (string, error) { | |||||
| 374 | if sym, ok := symFromSpecialAlias(token); ok { | 370 | if sym, ok := symFromSpecialAlias(token); ok { | ||
| 375 | return sym, nil | 371 | return sym, nil | ||
| 376 | } | 372 | } | ||
| 377 | - | // One-character literal angle tokens (<e>) disambiguate legacy-atom collisions. | 373 | + | // One-character / one-rune literal angle tokens (<e>, <é>) disambiguate |
| 378 | - | // Multi-character unknowns such as <Nope> remain invalid. | 374 | + | // legacy-atom collisions. Multi-rune unknowns such as <Nope> remain invalid. |
| 379 | - | if len(token) == 1 { | 375 | + | if sym, ok := asciiLetterSym(token); ok { |
| 380 | - | ch := token[0] | 376 | + | return sym, nil |
| 381 | - | if ch >= 'a' && ch <= 'z' { | 377 | + | } |
| 382 | - | return token, nil | 378 | + | if sym, ok := singlePrintableRuneSym(token); ok { |
| 383 | - | } | 379 | + | return sym, nil |
| 384 | - | if ch >= 'A' && ch <= 'Z' { | |||
| 385 | - | return strings.ToLower(token), nil | |||
| 386 | - | } | |||
| 387 | } | 380 | } | ||
| 388 | return "", ErrBadBinding | 381 | return "", ErrBadBinding | ||
| 389 | } | 382 | } | ||
| 383 | + | ||||
| 384 | + | // asciiLetterSym normalizes a single ASCII letter token (A-Z -> a-z). | |||
| 385 | + | func asciiLetterSym(token string) (string, bool) { | |||
| 386 | + | if len(token) != 1 { | |||
| 387 | + | return "", false | |||
| 388 | + | } | |||
| 389 | + | ch := token[0] | |||
| 390 | + | if ch >= 'A' && ch <= 'Z' { | |||
| 391 | + | return strings.ToLower(token), true | |||
| 392 | + | } | |||
| 393 | + | if ch >= 'a' && ch <= 'z' { | |||
| 394 | + | return token, true | |||
| 395 | + | } | |||
| 396 | + | return "", false | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | // singlePrintableRuneSym accepts exactly one valid UTF-8 printable rune. | |||
| 400 | + | func singlePrintableRuneSym(token string) (string, bool) { | |||
| 401 | + | if token == "" || !utf8.ValidString(token) { | |||
| 402 | + | return "", false | |||
| 403 | + | } | |||
| 404 | + | r, size := utf8.DecodeRuneInString(token) | |||
| 405 | + | if size != len(token) { | |||
| 406 | + | return "", false | |||
| 407 | + | } | |||
| 408 | + | if !unicode.IsPrint(r) { | |||
| 409 | + | return "", false | |||
| 410 | + | } | |||
| 411 | + | return token, true | |||
| 412 | + | } | |||
M internal/domain/vimkeys/parse_test.go +73 -0
| @@ -725,6 +725,12 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) { | |||
| 725 | 725 | {Sym: "-"}, | |
| 726 | 726 | {Sym: "d"}, | |
| 727 | 727 | }, | |
| 728 | + | // Representative multi-byte printable runes (accented BMP + emoji). | |
| 729 | + | {{Sym: "é"}}, | |
| 730 | + | {{Sym: "é", Mods: ModCtrl}}, | |
| 731 | + | {{Sym: "ñ", Mods: ModAlt}}, | |
| 732 | + | {{Sym: "😀"}}, | |
| 733 | + | {{Sym: "😀", Mods: ModCtrl}}, | |
| 728 | 734 | } | |
| 729 | 735 | for _, seq := range corpus { | |
| 730 | 736 | canonical := seq.String() | |
| @@ -955,6 +961,73 @@ func TestParseBinding_OneCharAngleLiteral(t *testing.T) { | |||
| 955 | 961 | } | |
| 956 | 962 | } | |
| 957 | 963 | ||
| 964 | + | // TestParseBinding_CtrlEAcuteRoundTrip covers the runtime Unicode canonical | |
| 965 | + | // invariant: ctrl+é / <C-é> parse to Key{Sym:"é", ModCtrl} and String | |
| 966 | + | // re-parses identically. | |
| 967 | + | func TestParseBinding_CtrlEAcuteRoundTrip(t *testing.T) { | |
| 968 | + | want := Sequence{{Sym: "é", Mods: ModCtrl}} | |
| 969 | + | for _, input := range []string{"ctrl+é", "<C-é>"} { | |
| 970 | + | t.Run(input, func(t *testing.T) { | |
| 971 | + | got, err := ParseBinding(input) | |
| 972 | + | if err != nil { | |
| 973 | + | t.Fatalf("ParseBinding(%q) error = %v", input, err) | |
| 974 | + | } | |
| 975 | + | if !got.Equal(want) { | |
| 976 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want) | |
| 977 | + | } | |
| 978 | + | canonical := got.String() | |
| 979 | + | if canonical != "<C-é>" { | |
| 980 | + | t.Fatalf("String() = %q, want %q", canonical, "<C-é>") | |
| 981 | + | } | |
| 982 | + | again, err := ParseBinding(canonical) | |
| 983 | + | if err != nil { | |
| 984 | + | t.Fatalf("ParseBinding(%q) error = %v", canonical, err) | |
| 985 | + | } | |
| 986 | + | if !again.Equal(want) { | |
| 987 | + | t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want) | |
| 988 | + | } | |
| 989 | + | }) | |
| 990 | + | } | |
| 991 | + | } | |
| 992 | + | ||
| 993 | + | // TestParseBinding_ReplacementCharRoundTrip ensures valid printable U+FFFD is not | |
| 994 | + | // rejected by the single-rune token path (r == utf8.RuneError is U+FFFD itself). | |
| 995 | + | func TestParseBinding_ReplacementCharRoundTrip(t *testing.T) { | |
| 996 | + | const repl = "\uFFFD" | |
| 997 | + | want := Sequence{{Sym: repl, Mods: ModCtrl}} | |
| 998 | + | for _, input := range []string{"ctrl+" + repl, "<C-" + repl + ">"} { | |
| 999 | + | t.Run(input, func(t *testing.T) { | |
| 1000 | + | got, err := ParseBinding(input) | |
| 1001 | + | if err != nil { | |
| 1002 | + | t.Fatalf("ParseBinding(%q) error = %v", input, err) | |
| 1003 | + | } | |
| 1004 | + | if !got.Equal(want) { | |
| 1005 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want) | |
| 1006 | + | } | |
| 1007 | + | canonical := got.String() | |
| 1008 | + | wantCanonical := "<C-" + repl + ">" | |
| 1009 | + | if canonical != wantCanonical { | |
| 1010 | + | t.Fatalf("String() = %q, want %q", canonical, wantCanonical) | |
| 1011 | + | } | |
| 1012 | + | again, err := ParseBinding(canonical) | |
| 1013 | + | if err != nil { | |
| 1014 | + | t.Fatalf("ParseBinding(%q) error = %v", canonical, err) | |
| 1015 | + | } | |
| 1016 | + | if !again.Equal(want) { | |
| 1017 | + | t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want) | |
| 1018 | + | } | |
| 1019 | + | }) | |
| 1020 | + | } | |
| 1021 | + | modifiedWant := Sequence{{Sym: repl}} | |
| 1022 | + | got, err := ParseBinding("<" + repl + ">") | |
| 1023 | + | if err != nil { | |
| 1024 | + | t.Fatalf("ParseBinding angle literal error = %v", err) | |
| 1025 | + | } | |
| 1026 | + | if !got.Equal(modifiedWant) { | |
| 1027 | + | t.Fatalf("ParseBinding(<U+FFFD>) = %v, want %v", got, modifiedWant) | |
| 1028 | + | } | |
| 1029 | + | } | |
| 1030 | + | ||
| 958 | 1031 | // TestParseBinding_LegacyCollisionModel systematically covers every exact | |
| 959 | 1032 | // legacy atom alias, modifier+plus chord spellings, case variants, and | |
| 960 | 1033 | // adjacent non-colliding sequences while retaining round-trip. | |
| @@ -725,6 +725,12 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) { | |||||
| 725 | {Sym: "-"}, | 725 | {Sym: "-"}, | ||
| 726 | {Sym: "d"}, | 726 | {Sym: "d"}, | ||
| 727 | }, | 727 | }, | ||
| 728 | + | // Representative multi-byte printable runes (accented BMP + emoji). | |||
| 729 | + | {{Sym: "é"}}, | |||
| 730 | + | {{Sym: "é", Mods: ModCtrl}}, | |||
| 731 | + | {{Sym: "ñ", Mods: ModAlt}}, | |||
| 732 | + | {{Sym: "😀"}}, | |||
| 733 | + | {{Sym: "😀", Mods: ModCtrl}}, | |||
| 728 | } | 734 | } | ||
| 729 | for _, seq := range corpus { | 735 | for _, seq := range corpus { | ||
| 730 | canonical := seq.String() | 736 | canonical := seq.String() | ||
| @@ -955,6 +961,73 @@ func TestParseBinding_OneCharAngleLiteral(t *testing.T) { | |||||
| 955 | } | 961 | } | ||
| 956 | } | 962 | } | ||
| 957 | 963 | ||||
| 964 | + | // TestParseBinding_CtrlEAcuteRoundTrip covers the runtime Unicode canonical | |||
| 965 | + | // invariant: ctrl+é / <C-é> parse to Key{Sym:"é", ModCtrl} and String | |||
| 966 | + | // re-parses identically. | |||
| 967 | + | func TestParseBinding_CtrlEAcuteRoundTrip(t *testing.T) { | |||
| 968 | + | want := Sequence{{Sym: "é", Mods: ModCtrl}} | |||
| 969 | + | for _, input := range []string{"ctrl+é", "<C-é>"} { | |||
| 970 | + | t.Run(input, func(t *testing.T) { | |||
| 971 | + | got, err := ParseBinding(input) | |||
| 972 | + | if err != nil { | |||
| 973 | + | t.Fatalf("ParseBinding(%q) error = %v", input, err) | |||
| 974 | + | } | |||
| 975 | + | if !got.Equal(want) { | |||
| 976 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want) | |||
| 977 | + | } | |||
| 978 | + | canonical := got.String() | |||
| 979 | + | if canonical != "<C-é>" { | |||
| 980 | + | t.Fatalf("String() = %q, want %q", canonical, "<C-é>") | |||
| 981 | + | } | |||
| 982 | + | again, err := ParseBinding(canonical) | |||
| 983 | + | if err != nil { | |||
| 984 | + | t.Fatalf("ParseBinding(%q) error = %v", canonical, err) | |||
| 985 | + | } | |||
| 986 | + | if !again.Equal(want) { | |||
| 987 | + | t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want) | |||
| 988 | + | } | |||
| 989 | + | }) | |||
| 990 | + | } | |||
| 991 | + | } | |||
| 992 | + | ||||
| 993 | + | // TestParseBinding_ReplacementCharRoundTrip ensures valid printable U+FFFD is not | |||
| 994 | + | // rejected by the single-rune token path (r == utf8.RuneError is U+FFFD itself). | |||
| 995 | + | func TestParseBinding_ReplacementCharRoundTrip(t *testing.T) { | |||
| 996 | + | const repl = "\uFFFD" | |||
| 997 | + | want := Sequence{{Sym: repl, Mods: ModCtrl}} | |||
| 998 | + | for _, input := range []string{"ctrl+" + repl, "<C-" + repl + ">"} { | |||
| 999 | + | t.Run(input, func(t *testing.T) { | |||
| 1000 | + | got, err := ParseBinding(input) | |||
| 1001 | + | if err != nil { | |||
| 1002 | + | t.Fatalf("ParseBinding(%q) error = %v", input, err) | |||
| 1003 | + | } | |||
| 1004 | + | if !got.Equal(want) { | |||
| 1005 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want) | |||
| 1006 | + | } | |||
| 1007 | + | canonical := got.String() | |||
| 1008 | + | wantCanonical := "<C-" + repl + ">" | |||
| 1009 | + | if canonical != wantCanonical { | |||
| 1010 | + | t.Fatalf("String() = %q, want %q", canonical, wantCanonical) | |||
| 1011 | + | } | |||
| 1012 | + | again, err := ParseBinding(canonical) | |||
| 1013 | + | if err != nil { | |||
| 1014 | + | t.Fatalf("ParseBinding(%q) error = %v", canonical, err) | |||
| 1015 | + | } | |||
| 1016 | + | if !again.Equal(want) { | |||
| 1017 | + | t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want) | |||
| 1018 | + | } | |||
| 1019 | + | }) | |||
| 1020 | + | } | |||
| 1021 | + | modifiedWant := Sequence{{Sym: repl}} | |||
| 1022 | + | got, err := ParseBinding("<" + repl + ">") | |||
| 1023 | + | if err != nil { | |||
| 1024 | + | t.Fatalf("ParseBinding angle literal error = %v", err) | |||
| 1025 | + | } | |||
| 1026 | + | if !got.Equal(modifiedWant) { | |||
| 1027 | + | t.Fatalf("ParseBinding(<U+FFFD>) = %v, want %v", got, modifiedWant) | |||
| 1028 | + | } | |||
| 1029 | + | } | |||
| 1030 | + | ||||
| 958 | // TestParseBinding_LegacyCollisionModel systematically covers every exact | 1031 | // TestParseBinding_LegacyCollisionModel systematically covers every exact | ||
| 959 | // legacy atom alias, modifier+plus chord spellings, case variants, and | 1032 | // legacy atom alias, modifier+plus chord spellings, case variants, and | ||
| 960 | // adjacent non-colliding sequences while retaining round-trip. | 1033 | // adjacent non-colliding sequences while retaining round-trip. | ||
M internal/ui/input/vimkeys_bridge.go +4 -1
| @@ -83,9 +83,12 @@ func vimModsFromState(state gdk.ModifierType) vimkeys.Mods { | |||
| 83 | 83 | // keyvalToUnicode mirrors gdk_keyval_to_unicode for Latin-1 graphic keysyms | |
| 84 | 84 | // (identity mapping) and falls back to GDK for the remaining keyvals. | |
| 85 | 85 | func keyvalToUnicode(keyval uint) uint32 { | |
| 86 | - | // ASCII graphic Latin-1 keysyms share their Unicode code points. | |
| 86 | + | // ASCII graphic and Latin-1 supplement keysyms share Unicode code points. | |
| 87 | 87 | if keyval >= 0x20 && keyval <= 0x7e { | |
| 88 | 88 | return uint32(keyval) | |
| 89 | 89 | } | |
| 90 | + | if keyval >= 0xa0 && keyval <= 0xff { | |
| 91 | + | return uint32(keyval) | |
| 92 | + | } | |
| 90 | 93 | return gdk.KeyvalToUnicode(keyval) | |
| 91 | 94 | } | |
| @@ -83,9 +83,12 @@ func vimModsFromState(state gdk.ModifierType) vimkeys.Mods { | |||||
| 83 | // keyvalToUnicode mirrors gdk_keyval_to_unicode for Latin-1 graphic keysyms | 83 | // keyvalToUnicode mirrors gdk_keyval_to_unicode for Latin-1 graphic keysyms | ||
| 84 | // (identity mapping) and falls back to GDK for the remaining keyvals. | 84 | // (identity mapping) and falls back to GDK for the remaining keyvals. | ||
| 85 | func keyvalToUnicode(keyval uint) uint32 { | 85 | func keyvalToUnicode(keyval uint) uint32 { | ||
| 86 | - | // ASCII graphic Latin-1 keysyms share their Unicode code points. | 86 | + | // ASCII graphic and Latin-1 supplement keysyms share Unicode code points. |
| 87 | if keyval >= 0x20 && keyval <= 0x7e { | 87 | if keyval >= 0x20 && keyval <= 0x7e { | ||
| 88 | return uint32(keyval) | 88 | return uint32(keyval) | ||
| 89 | } | 89 | } | ||
| 90 | + | if keyval >= 0xa0 && keyval <= 0xff { | |||
| 91 | + | return uint32(keyval) | |||
| 92 | + | } | |||
| 90 | return gdk.KeyvalToUnicode(keyval) | 93 | return gdk.KeyvalToUnicode(keyval) | ||
| 91 | } | 94 | } | ||
M internal/ui/input/vimkeys_bridge_test.go +7 -0
| @@ -155,6 +155,13 @@ func TestKeyvalToVimKey(t *testing.T) { | |||
| 155 | 155 | want: vimkeys.Key{Sym: "d", Mods: vimkeys.ModCtrl}, | |
| 156 | 156 | wantOk: true, | |
| 157 | 157 | }, | |
| 158 | + | { | |
| 159 | + | name: "ctrl+eacute Latin-1 keyval round-trips through parser", | |
| 160 | + | keyval: uint(gdk.KEY_eacute), | |
| 161 | + | state: gdk.ControlMaskValue, | |
| 162 | + | want: vimkeys.Key{Sym: "é", Mods: vimkeys.ModCtrl}, | |
| 163 | + | wantOk: true, | |
| 164 | + | }, | |
| 158 | 165 | { | |
| 159 | 166 | name: "non-printable rejected", | |
| 160 | 167 | keyval: 0, | |
| @@ -155,6 +155,13 @@ func TestKeyvalToVimKey(t *testing.T) { | |||||
| 155 | want: vimkeys.Key{Sym: "d", Mods: vimkeys.ModCtrl}, | 155 | want: vimkeys.Key{Sym: "d", Mods: vimkeys.ModCtrl}, | ||
| 156 | wantOk: true, | 156 | wantOk: true, | ||
| 157 | }, | 157 | }, | ||
| 158 | + | { | |||
| 159 | + | name: "ctrl+eacute Latin-1 keyval round-trips through parser", | |||
| 160 | + | keyval: uint(gdk.KEY_eacute), | |||
| 161 | + | state: gdk.ControlMaskValue, | |||
| 162 | + | want: vimkeys.Key{Sym: "é", Mods: vimkeys.ModCtrl}, | |||
| 163 | + | wantOk: true, | |||
| 164 | + | }, | |||
| 158 | { | 165 | { | ||
| 159 | name: "non-printable rejected", | 166 | name: "non-printable rejected", | ||
| 160 | keyval: 0, | 167 | keyval: 0, | ||