detached 3f164d0

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) {
355355 if sym, ok := symFromSpecialAlias(token); ok {
356356 return sym, nil
357357 }
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
365361 }
366362 }
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
369365 }
370366 return "", ErrBadBinding
371367 }
@@ -374,16 +370,43 @@ func symFromAlias(token string) (string, error) {
374370 if sym, ok := symFromSpecialAlias(token); ok {
375371 return sym, nil
376372 }
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
387380 }
388381 return "", ErrBadBinding
389382 }
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, nil356 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, nil364+ return sym, nil
369 }365 }
370 return "", ErrBadBinding366 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, nil371 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, nil378+ 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 "", ErrBadBinding381 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) {
725725 {Sym: "-"},
726726 {Sym: "d"},
727727 },
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}},
728734 }
729735 for _, seq := range corpus {
730736 canonical := seq.String()
@@ -955,6 +961,73 @@ func TestParseBinding_OneCharAngleLiteral(t *testing.T) {
955961 }
956962 }
957963
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+
9581031 // TestParseBinding_LegacyCollisionModel systematically covers every exact
9591032 // legacy atom alias, modifier+plus chord spellings, case variants, and
9601033 // 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 exact1031 // TestParseBinding_LegacyCollisionModel systematically covers every exact
959 // legacy atom alias, modifier+plus chord spellings, case variants, and1032 // 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 {
8383 // keyvalToUnicode mirrors gdk_keyval_to_unicode for Latin-1 graphic keysyms
8484 // (identity mapping) and falls back to GDK for the remaining keyvals.
8585 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.
8787 if keyval >= 0x20 && keyval <= 0x7e {
8888 return uint32(keyval)
8989 }
90+ if keyval >= 0xa0 && keyval <= 0xff {
91+ return uint32(keyval)
92+ }
9093 return gdk.KeyvalToUnicode(keyval)
9194 }
@@ -83,9 +83,12 @@ func vimModsFromState(state gdk.ModifierType) vimkeys.Mods {
83 // keyvalToUnicode mirrors gdk_keyval_to_unicode for Latin-1 graphic keysyms83 // 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) {
155155 want: vimkeys.Key{Sym: "d", Mods: vimkeys.ModCtrl},
156156 wantOk: true,
157157 },
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+ },
158165 {
159166 name: "non-printable rejected",
160167 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,

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