detached 0413b8e

feat(vimkeys): parse page mode sequences

commit
0413b8ece237e18ba4808f93f8a586478549e5ce
author
brice · · 2026-07-28 17:26 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
9fa7546
signature
unsigned
4 files · +678 -0
A internal/domain/vimkeys/key.go +116 -0
@@ -0,0 +1,116 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "strings"
6+)
7+
8+// Mods holds keyboard modifier flags for a parsed key.
9+type Mods uint8
10+
11+const (
12+ ModCtrl Mods = 1 << iota
13+ ModShift
14+ ModAlt
15+)
16+
17+// Key is a single key event with symbol and modifiers.
18+type Key struct {
19+ Sym string
20+ Mods Mods
21+}
22+
23+// Sequence is an ordered list of keys forming a Vim-style binding.
24+type Sequence []Key
25+
26+var (
27+ ErrEmptyBinding = errors.New("vimkeys: empty binding")
28+ ErrBadBinding = errors.New("vimkeys: malformed binding")
29+)
30+
31+// String renders a key in canonical form.
32+func (k Key) String() string {
33+ if k.Sym == "" {
34+ return ""
35+ }
36+
37+ if k.Mods == ModShift && len(k.Sym) == 1 {
38+ if c := k.Sym[0]; c >= 'a' && c <= 'z' {
39+ return strings.ToUpper(k.Sym)
40+ }
41+ }
42+
43+ if k.Mods == 0 {
44+ if alias := angleAliasForSym(k.Sym); alias != "" {
45+ return "<" + alias + ">"
46+ }
47+ return k.Sym
48+ }
49+
50+ return formatAngleKey(k)
51+}
52+
53+// String renders the full binding sequence in canonical form.
54+func (s Sequence) String() string {
55+ var b strings.Builder
56+ for _, key := range s {
57+ b.WriteString(key.String())
58+ }
59+ return b.String()
60+}
61+
62+// Equal reports whether two sequences contain the same keys.
63+func (s Sequence) Equal(other Sequence) bool {
64+ if len(s) != len(other) {
65+ return false
66+ }
67+ for i := range s {
68+ if s[i] != other[i] {
69+ return false
70+ }
71+ }
72+ return true
73+}
74+
75+func angleAliasForSym(sym string) string {
76+ switch sym {
77+ case "CR":
78+ return "Return"
79+ case "Esc":
80+ return "Escape"
81+ case "Space":
82+ return "Space"
83+ case "<":
84+ return "lt"
85+ default:
86+ return ""
87+ }
88+}
89+
90+func formatAngleKey(k Key) string {
91+ var b strings.Builder
92+ b.WriteByte('<')
93+ if k.Mods&ModCtrl != 0 {
94+ b.WriteByte('C')
95+ }
96+ if k.Mods&ModShift != 0 {
97+ if b.Len() > 1 {
98+ b.WriteByte('-')
99+ }
100+ b.WriteByte('S')
101+ }
102+ if k.Mods&ModAlt != 0 {
103+ if b.Len() > 1 {
104+ b.WriteByte('-')
105+ }
106+ b.WriteByte('A')
107+ }
108+ b.WriteByte('-')
109+ if alias := angleAliasForSym(k.Sym); alias != "" {
110+ b.WriteString(alias)
111+ } else {
112+ b.WriteString(k.Sym)
113+ }
114+ b.WriteByte('>')
115+ return b.String()
116+}
@@ -0,0 +1,116 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "strings"
6+)
7+
8+// Mods holds keyboard modifier flags for a parsed key.
9+type Mods uint8
10+
11+const (
12+ ModCtrl Mods = 1 << iota
13+ ModShift
14+ ModAlt
15+)
16+
17+// Key is a single key event with symbol and modifiers.
18+type Key struct {
19+ Sym string
20+ Mods Mods
21+}
22+
23+// Sequence is an ordered list of keys forming a Vim-style binding.
24+type Sequence []Key
25+
26+var (
27+ ErrEmptyBinding = errors.New("vimkeys: empty binding")
28+ ErrBadBinding = errors.New("vimkeys: malformed binding")
29+)
30+
31+// String renders a key in canonical form.
32+func (k Key) String() string {
33+ if k.Sym == "" {
34+ return ""
35+ }
36+
37+ if k.Mods == ModShift && len(k.Sym) == 1 {
38+ if c := k.Sym[0]; c >= 'a' && c <= 'z' {
39+ return strings.ToUpper(k.Sym)
40+ }
41+ }
42+
43+ if k.Mods == 0 {
44+ if alias := angleAliasForSym(k.Sym); alias != "" {
45+ return "<" + alias + ">"
46+ }
47+ return k.Sym
48+ }
49+
50+ return formatAngleKey(k)
51+}
52+
53+// String renders the full binding sequence in canonical form.
54+func (s Sequence) String() string {
55+ var b strings.Builder
56+ for _, key := range s {
57+ b.WriteString(key.String())
58+ }
59+ return b.String()
60+}
61+
62+// Equal reports whether two sequences contain the same keys.
63+func (s Sequence) Equal(other Sequence) bool {
64+ if len(s) != len(other) {
65+ return false
66+ }
67+ for i := range s {
68+ if s[i] != other[i] {
69+ return false
70+ }
71+ }
72+ return true
73+}
74+
75+func angleAliasForSym(sym string) string {
76+ switch sym {
77+ case "CR":
78+ return "Return"
79+ case "Esc":
80+ return "Escape"
81+ case "Space":
82+ return "Space"
83+ case "<":
84+ return "lt"
85+ default:
86+ return ""
87+ }
88+}
89+
90+func formatAngleKey(k Key) string {
91+ var b strings.Builder
92+ b.WriteByte('<')
93+ if k.Mods&ModCtrl != 0 {
94+ b.WriteByte('C')
95+ }
96+ if k.Mods&ModShift != 0 {
97+ if b.Len() > 1 {
98+ b.WriteByte('-')
99+ }
100+ b.WriteByte('S')
101+ }
102+ if k.Mods&ModAlt != 0 {
103+ if b.Len() > 1 {
104+ b.WriteByte('-')
105+ }
106+ b.WriteByte('A')
107+ }
108+ b.WriteByte('-')
109+ if alias := angleAliasForSym(k.Sym); alias != "" {
110+ b.WriteString(alias)
111+ } else {
112+ b.WriteString(k.Sym)
113+ }
114+ b.WriteByte('>')
115+ return b.String()
116+}
A internal/domain/vimkeys/key_test.go +59 -0
@@ -0,0 +1,59 @@
1+package vimkeys
2+
3+import (
4+ "testing"
5+)
6+
7+func TestKeyString(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ key Key
11+ want string
12+ }{
13+ {name: "letter", key: Key{Sym: "j"}, want: "j"},
14+ {name: "shift letter", key: Key{Sym: "j", Mods: ModShift}, want: "J"},
15+ {name: "ctrl letter", key: Key{Sym: "d", Mods: ModCtrl}, want: "<C-d>"},
16+ {name: "ctrl plus", key: Key{Sym: "+", Mods: ModCtrl}, want: "<C-+>"},
17+ {name: "shift glyph", key: Key{Sym: "!"}, want: "!"},
18+ {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},
19+ {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
20+ {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},
21+ {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
22+ {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ if got := tt.key.String(); got != tt.want {
27+ t.Errorf("Key.String() = %q, want %q", got, tt.want)
28+ }
29+ })
30+ }
31+}
32+
33+func TestSequenceString(t *testing.T) {
34+ tests := []struct {
35+ name string
36+ seq Sequence
37+ want string
38+ }{
39+ {name: "yah", seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, want: "yah"},
40+ {name: "double bracket", seq: Sequence{{Sym: "]"}, {Sym: "]"}}, want: "]]"},
41+ {name: "gO", seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, want: "gO"},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ if got := tt.seq.String(); got != tt.want {
46+ t.Errorf("Sequence.String() = %q, want %q", got, tt.want)
47+ }
48+ })
49+ }
50+}
51+
52+func TestErrorSentinels(t *testing.T) {
53+ if ErrEmptyBinding.Error() != "vimkeys: empty binding" {
54+ t.Errorf("ErrEmptyBinding = %q, want %q", ErrEmptyBinding.Error(), "vimkeys: empty binding")
55+ }
56+ if ErrBadBinding.Error() != "vimkeys: malformed binding" {
57+ t.Errorf("ErrBadBinding = %q, want %q", ErrBadBinding.Error(), "vimkeys: malformed binding")
58+ }
59+}
@@ -0,0 +1,59 @@
1+package vimkeys
2+
3+import (
4+ "testing"
5+)
6+
7+func TestKeyString(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ key Key
11+ want string
12+ }{
13+ {name: "letter", key: Key{Sym: "j"}, want: "j"},
14+ {name: "shift letter", key: Key{Sym: "j", Mods: ModShift}, want: "J"},
15+ {name: "ctrl letter", key: Key{Sym: "d", Mods: ModCtrl}, want: "<C-d>"},
16+ {name: "ctrl plus", key: Key{Sym: "+", Mods: ModCtrl}, want: "<C-+>"},
17+ {name: "shift glyph", key: Key{Sym: "!"}, want: "!"},
18+ {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},
19+ {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
20+ {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},
21+ {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
22+ {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ if got := tt.key.String(); got != tt.want {
27+ t.Errorf("Key.String() = %q, want %q", got, tt.want)
28+ }
29+ })
30+ }
31+}
32+
33+func TestSequenceString(t *testing.T) {
34+ tests := []struct {
35+ name string
36+ seq Sequence
37+ want string
38+ }{
39+ {name: "yah", seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, want: "yah"},
40+ {name: "double bracket", seq: Sequence{{Sym: "]"}, {Sym: "]"}}, want: "]]"},
41+ {name: "gO", seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, want: "gO"},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ if got := tt.seq.String(); got != tt.want {
46+ t.Errorf("Sequence.String() = %q, want %q", got, tt.want)
47+ }
48+ })
49+ }
50+}
51+
52+func TestErrorSentinels(t *testing.T) {
53+ if ErrEmptyBinding.Error() != "vimkeys: empty binding" {
54+ t.Errorf("ErrEmptyBinding = %q, want %q", ErrEmptyBinding.Error(), "vimkeys: empty binding")
55+ }
56+ if ErrBadBinding.Error() != "vimkeys: malformed binding" {
57+ t.Errorf("ErrBadBinding = %q, want %q", ErrBadBinding.Error(), "vimkeys: malformed binding")
58+ }
59+}
A internal/domain/vimkeys/parse.go +357 -0
@@ -0,0 +1,357 @@
1+package vimkeys
2+
3+import (
4+ "strings"
5+ "unicode"
6+ "unicode/utf8"
7+)
8+
9+var specialAliases = map[string]string{
10+ "cr": "CR",
11+ "return": "CR",
12+ "esc": "Esc",
13+ "escape": "Esc",
14+ "space": "Space",
15+ "lt": "<",
16+ "plus": "+",
17+}
18+
19+func ParseBinding(s string) (Sequence, error) {
20+ s = strings.TrimSpace(s)
21+ if s == "" {
22+ return nil, ErrEmptyBinding
23+ }
24+
25+ if strings.HasPrefix(s, "<") {
26+ return parseAngleBinding(s)
27+ }
28+ if looksLikeChordAttempt(s) {
29+ if !isLegacyChord(s) {
30+ return nil, ErrBadBinding
31+ }
32+ key, err := parseLegacyChord(s)
33+ if err != nil {
34+ return nil, err
35+ }
36+ return Sequence{key}, nil
37+ }
38+ return parseRawSequence(s)
39+}
40+
41+func looksLikeChordAttempt(s string) bool {
42+ if s == "+" {
43+ return true
44+ }
45+ if strings.HasSuffix(s, "++") {
46+ return true
47+ }
48+ for _, part := range strings.Split(s, "+") {
49+ if isModifierName(part) {
50+ return true
51+ }
52+ }
53+ return false
54+}
55+
56+func isLegacyChord(s string) bool {
57+ if s == "+" {
58+ return true
59+ }
60+ if !strings.Contains(s, "+") {
61+ return false
62+ }
63+ if strings.HasSuffix(s, "++") {
64+ return true
65+ }
66+ parts := strings.Split(s, "+")
67+ for i, part := range parts {
68+ part = strings.TrimSpace(part)
69+ if part == "" {
70+ continue
71+ }
72+ if isModifierName(part) {
73+ continue
74+ }
75+ if i == len(parts)-1 {
76+ return true
77+ }
78+ return false
79+ }
80+ return false
81+}
82+
83+func isModifierName(part string) bool {
84+ switch strings.ToLower(part) {
85+ case "ctrl", "control", "shift", "alt":
86+ return true
87+ default:
88+ return false
89+ }
90+}
91+
92+func parseLegacyChord(s string) (Key, error) {
93+ if s == "+" {
94+ return Key{Sym: "+"}, nil
95+ }
96+
97+ parts := strings.Split(s, "+")
98+ var mods Mods
99+ var keyPart string
100+
101+ for _, part := range parts {
102+ part = strings.TrimSpace(part)
103+ if part == "" {
104+ continue
105+ }
106+ lower := strings.ToLower(part)
107+ switch lower {
108+ case "ctrl", "control":
109+ mods |= ModCtrl
110+ case "shift":
111+ mods |= ModShift
112+ case "alt":
113+ mods |= ModAlt
114+ default:
115+ if keyPart != "" {
116+ return Key{}, ErrBadBinding
117+ }
118+ keyPart = part
119+ }
120+ }
121+
122+ if keyPart == "" && strings.HasSuffix(s, "++") {
123+ keyPart = "+"
124+ }
125+ if keyPart == "" {
126+ return Key{}, ErrBadBinding
127+ }
128+
129+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
130+ mods |= ModShift
131+ keyPart = strings.ToLower(keyPart)
132+ }
133+
134+ sym, err := symFromToken(keyPart, false)
135+ if err != nil {
136+ return Key{}, err
137+ }
138+ return Key{Sym: sym, Mods: mods}, nil
139+}
140+
141+func parseAngleBinding(s string) (Sequence, error) {
142+ if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") || len(s) < 3 {
143+ return nil, ErrBadBinding
144+ }
145+ inner := s[1 : len(s)-1]
146+ if inner == "" {
147+ return nil, ErrBadBinding
148+ }
149+
150+ key, err := parseAngleToken(inner)
151+ if err != nil {
152+ return nil, err
153+ }
154+ return Sequence{key}, nil
155+}
156+
157+func parseAngleToken(inner string) (Key, error) {
158+ if strings.Contains(inner, "-") {
159+ parts := strings.Split(inner, "-")
160+ if len(parts) != 2 {
161+ return Key{}, ErrBadBinding
162+ }
163+ modPart := strings.TrimSpace(parts[0])
164+ symPart := strings.TrimSpace(parts[1])
165+ if modPart == "" || symPart == "" {
166+ return Key{}, ErrBadBinding
167+ }
168+
169+ var mods Mods
170+ for _, ch := range modPart {
171+ switch ch {
172+ case 'C', 'c':
173+ mods |= ModCtrl
174+ case 'S', 's':
175+ mods |= ModShift
176+ case 'A', 'a':
177+ mods |= ModAlt
178+ default:
179+ return Key{}, ErrBadBinding
180+ }
181+ }
182+
183+ sym, err := symFromToken(symPart, true)
184+ if err != nil {
185+ return Key{}, err
186+ }
187+ return Key{Sym: sym, Mods: mods}, nil
188+ }
189+
190+ sym, err := symFromAlias(inner)
191+ if err != nil {
192+ return Key{}, err
193+ }
194+ return Key{Sym: sym}, nil
195+}
196+
197+var sequenceSpecialNames = []string{"Space", "Esc", "CR", "lt", "Plus"}
198+
199+func parseRawSequence(s string) (Sequence, error) {
200+ seq := make(Sequence, 0, len(s))
201+ for i := 0; i < len(s); {
202+ if key, width, ok := tryParseSpecialAt(s, i); ok {
203+ seq = append(seq, key)
204+ i += width
205+ continue
206+ }
207+ if key, width, ok := tryParseCanonicalAt(s, i); ok {
208+ seq = append(seq, key)
209+ i += width
210+ continue
211+ }
212+
213+ r, width := utf8.DecodeRuneInString(s[i:])
214+ key, err := keyFromRune(r)
215+ if err != nil {
216+ return nil, err
217+ }
218+ seq = append(seq, key)
219+ i += width
220+ }
221+ return seq, nil
222+}
223+
224+func tryParseSpecialAt(s string, i int) (Key, int, bool) {
225+ for _, name := range sequenceSpecialNames {
226+ if !strings.HasPrefix(s[i:], name) {
227+ continue
228+ }
229+ sym, err := symFromAlias(name)
230+ if err != nil {
231+ continue
232+ }
233+ return Key{Sym: sym}, len(name), true
234+ }
235+ return Key{}, 0, false
236+}
237+
238+func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
239+ if i >= len(s) {
240+ return Key{}, 0, false
241+ }
242+ switch s[i] {
243+ case 'C', 'S', 'A':
244+ default:
245+ return Key{}, 0, false
246+ }
247+ if i+1 >= len(s) || s[i+1] != '-' {
248+ return Key{}, 0, false
249+ }
250+
251+ end := i
252+ for end < len(s) {
253+ nextDash := strings.IndexByte(s[end:], '-')
254+ if nextDash < 0 {
255+ end = len(s)
256+ break
257+ }
258+ nextDash += end
259+ if nextDash+1 >= len(s) {
260+ end = len(s)
261+ break
262+ }
263+ switch s[nextDash+1] {
264+ case 'C', 'S', 'A':
265+ end = nextDash
266+ default:
267+ end = len(s)
268+ }
269+ break
270+ }
271+
272+ token := s[i:end]
273+ key, err := parseCanonicalToken(token)
274+ if err != nil {
275+ return Key{}, 0, false
276+ }
277+ return key, end - i, true
278+}
279+
280+func parseCanonicalToken(token string) (Key, error) {
281+ parts := strings.Split(token, "-")
282+ if len(parts) < 2 {
283+ return Key{}, ErrBadBinding
284+ }
285+
286+ var mods Mods
287+ for i := 0; i < len(parts)-1; i++ {
288+ switch parts[i] {
289+ case "C":
290+ mods |= ModCtrl
291+ case "S":
292+ mods |= ModShift
293+ case "A":
294+ mods |= ModAlt
295+ default:
296+ return Key{}, ErrBadBinding
297+ }
298+ }
299+
300+ symPart := parts[len(parts)-1]
301+ sym, err := symFromToken(symPart, true)
302+ if err != nil {
303+ return Key{}, err
304+ }
305+ return Key{Sym: sym, Mods: mods}, nil
306+}
307+
308+func keyFromRune(r rune) (Key, error) {
309+ if r >= 'A' && r <= 'Z' {
310+ return Key{Sym: strings.ToLower(string(r)), Mods: ModShift}, nil
311+ }
312+ if shiftedGlyph, ok := shiftedGlyphForRune(r); ok {
313+ return Key{Sym: shiftedGlyph}, nil
314+ }
315+ if r >= 'a' && r <= 'z' {
316+ return Key{Sym: string(r)}, nil
317+ }
318+ if unicode.IsPrint(r) {
319+ return Key{Sym: string(r)}, nil
320+ }
321+ return Key{}, ErrBadBinding
322+}
323+
324+func shiftedGlyphForRune(r rune) (string, bool) {
325+ switch r {
326+ case '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '~', '<', '>', '?':
327+ return string(r), true
328+ default:
329+ return "", false
330+ }
331+}
332+
333+func symFromToken(token string, allowSingleLetter bool) (string, error) {
334+ if sym, ok := specialAliases[strings.ToLower(token)]; ok {
335+ return sym, nil
336+ }
337+ if allowSingleLetter && len(token) == 1 {
338+ ch := token[0]
339+ if ch >= 'A' && ch <= 'Z' {
340+ return strings.ToLower(token), nil
341+ }
342+ if ch >= 'a' && ch <= 'z' {
343+ return token, nil
344+ }
345+ }
346+ if len(token) == 1 && unicode.IsPrint(rune(token[0])) {
347+ return token, nil
348+ }
349+ return "", ErrBadBinding
350+}
351+
352+func symFromAlias(token string) (string, error) {
353+ if sym, ok := specialAliases[strings.ToLower(token)]; ok {
354+ return sym, nil
355+ }
356+ return "", ErrBadBinding
357+}
@@ -0,0 +1,357 @@
1+package vimkeys
2+
3+import (
4+ "strings"
5+ "unicode"
6+ "unicode/utf8"
7+)
8+
9+var specialAliases = map[string]string{
10+ "cr": "CR",
11+ "return": "CR",
12+ "esc": "Esc",
13+ "escape": "Esc",
14+ "space": "Space",
15+ "lt": "<",
16+ "plus": "+",
17+}
18+
19+func ParseBinding(s string) (Sequence, error) {
20+ s = strings.TrimSpace(s)
21+ if s == "" {
22+ return nil, ErrEmptyBinding
23+ }
24+
25+ if strings.HasPrefix(s, "<") {
26+ return parseAngleBinding(s)
27+ }
28+ if looksLikeChordAttempt(s) {
29+ if !isLegacyChord(s) {
30+ return nil, ErrBadBinding
31+ }
32+ key, err := parseLegacyChord(s)
33+ if err != nil {
34+ return nil, err
35+ }
36+ return Sequence{key}, nil
37+ }
38+ return parseRawSequence(s)
39+}
40+
41+func looksLikeChordAttempt(s string) bool {
42+ if s == "+" {
43+ return true
44+ }
45+ if strings.HasSuffix(s, "++") {
46+ return true
47+ }
48+ for _, part := range strings.Split(s, "+") {
49+ if isModifierName(part) {
50+ return true
51+ }
52+ }
53+ return false
54+}
55+
56+func isLegacyChord(s string) bool {
57+ if s == "+" {
58+ return true
59+ }
60+ if !strings.Contains(s, "+") {
61+ return false
62+ }
63+ if strings.HasSuffix(s, "++") {
64+ return true
65+ }
66+ parts := strings.Split(s, "+")
67+ for i, part := range parts {
68+ part = strings.TrimSpace(part)
69+ if part == "" {
70+ continue
71+ }
72+ if isModifierName(part) {
73+ continue
74+ }
75+ if i == len(parts)-1 {
76+ return true
77+ }
78+ return false
79+ }
80+ return false
81+}
82+
83+func isModifierName(part string) bool {
84+ switch strings.ToLower(part) {
85+ case "ctrl", "control", "shift", "alt":
86+ return true
87+ default:
88+ return false
89+ }
90+}
91+
92+func parseLegacyChord(s string) (Key, error) {
93+ if s == "+" {
94+ return Key{Sym: "+"}, nil
95+ }
96+
97+ parts := strings.Split(s, "+")
98+ var mods Mods
99+ var keyPart string
100+
101+ for _, part := range parts {
102+ part = strings.TrimSpace(part)
103+ if part == "" {
104+ continue
105+ }
106+ lower := strings.ToLower(part)
107+ switch lower {
108+ case "ctrl", "control":
109+ mods |= ModCtrl
110+ case "shift":
111+ mods |= ModShift
112+ case "alt":
113+ mods |= ModAlt
114+ default:
115+ if keyPart != "" {
116+ return Key{}, ErrBadBinding
117+ }
118+ keyPart = part
119+ }
120+ }
121+
122+ if keyPart == "" && strings.HasSuffix(s, "++") {
123+ keyPart = "+"
124+ }
125+ if keyPart == "" {
126+ return Key{}, ErrBadBinding
127+ }
128+
129+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
130+ mods |= ModShift
131+ keyPart = strings.ToLower(keyPart)
132+ }
133+
134+ sym, err := symFromToken(keyPart, false)
135+ if err != nil {
136+ return Key{}, err
137+ }
138+ return Key{Sym: sym, Mods: mods}, nil
139+}
140+
141+func parseAngleBinding(s string) (Sequence, error) {
142+ if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") || len(s) < 3 {
143+ return nil, ErrBadBinding
144+ }
145+ inner := s[1 : len(s)-1]
146+ if inner == "" {
147+ return nil, ErrBadBinding
148+ }
149+
150+ key, err := parseAngleToken(inner)
151+ if err != nil {
152+ return nil, err
153+ }
154+ return Sequence{key}, nil
155+}
156+
157+func parseAngleToken(inner string) (Key, error) {
158+ if strings.Contains(inner, "-") {
159+ parts := strings.Split(inner, "-")
160+ if len(parts) != 2 {
161+ return Key{}, ErrBadBinding
162+ }
163+ modPart := strings.TrimSpace(parts[0])
164+ symPart := strings.TrimSpace(parts[1])
165+ if modPart == "" || symPart == "" {
166+ return Key{}, ErrBadBinding
167+ }
168+
169+ var mods Mods
170+ for _, ch := range modPart {
171+ switch ch {
172+ case 'C', 'c':
173+ mods |= ModCtrl
174+ case 'S', 's':
175+ mods |= ModShift
176+ case 'A', 'a':
177+ mods |= ModAlt
178+ default:
179+ return Key{}, ErrBadBinding
180+ }
181+ }
182+
183+ sym, err := symFromToken(symPart, true)
184+ if err != nil {
185+ return Key{}, err
186+ }
187+ return Key{Sym: sym, Mods: mods}, nil
188+ }
189+
190+ sym, err := symFromAlias(inner)
191+ if err != nil {
192+ return Key{}, err
193+ }
194+ return Key{Sym: sym}, nil
195+}
196+
197+var sequenceSpecialNames = []string{"Space", "Esc", "CR", "lt", "Plus"}
198+
199+func parseRawSequence(s string) (Sequence, error) {
200+ seq := make(Sequence, 0, len(s))
201+ for i := 0; i < len(s); {
202+ if key, width, ok := tryParseSpecialAt(s, i); ok {
203+ seq = append(seq, key)
204+ i += width
205+ continue
206+ }
207+ if key, width, ok := tryParseCanonicalAt(s, i); ok {
208+ seq = append(seq, key)
209+ i += width
210+ continue
211+ }
212+
213+ r, width := utf8.DecodeRuneInString(s[i:])
214+ key, err := keyFromRune(r)
215+ if err != nil {
216+ return nil, err
217+ }
218+ seq = append(seq, key)
219+ i += width
220+ }
221+ return seq, nil
222+}
223+
224+func tryParseSpecialAt(s string, i int) (Key, int, bool) {
225+ for _, name := range sequenceSpecialNames {
226+ if !strings.HasPrefix(s[i:], name) {
227+ continue
228+ }
229+ sym, err := symFromAlias(name)
230+ if err != nil {
231+ continue
232+ }
233+ return Key{Sym: sym}, len(name), true
234+ }
235+ return Key{}, 0, false
236+}
237+
238+func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
239+ if i >= len(s) {
240+ return Key{}, 0, false
241+ }
242+ switch s[i] {
243+ case 'C', 'S', 'A':
244+ default:
245+ return Key{}, 0, false
246+ }
247+ if i+1 >= len(s) || s[i+1] != '-' {
248+ return Key{}, 0, false
249+ }
250+
251+ end := i
252+ for end < len(s) {
253+ nextDash := strings.IndexByte(s[end:], '-')
254+ if nextDash < 0 {
255+ end = len(s)
256+ break
257+ }
258+ nextDash += end
259+ if nextDash+1 >= len(s) {
260+ end = len(s)
261+ break
262+ }
263+ switch s[nextDash+1] {
264+ case 'C', 'S', 'A':
265+ end = nextDash
266+ default:
267+ end = len(s)
268+ }
269+ break
270+ }
271+
272+ token := s[i:end]
273+ key, err := parseCanonicalToken(token)
274+ if err != nil {
275+ return Key{}, 0, false
276+ }
277+ return key, end - i, true
278+}
279+
280+func parseCanonicalToken(token string) (Key, error) {
281+ parts := strings.Split(token, "-")
282+ if len(parts) < 2 {
283+ return Key{}, ErrBadBinding
284+ }
285+
286+ var mods Mods
287+ for i := 0; i < len(parts)-1; i++ {
288+ switch parts[i] {
289+ case "C":
290+ mods |= ModCtrl
291+ case "S":
292+ mods |= ModShift
293+ case "A":
294+ mods |= ModAlt
295+ default:
296+ return Key{}, ErrBadBinding
297+ }
298+ }
299+
300+ symPart := parts[len(parts)-1]
301+ sym, err := symFromToken(symPart, true)
302+ if err != nil {
303+ return Key{}, err
304+ }
305+ return Key{Sym: sym, Mods: mods}, nil
306+}
307+
308+func keyFromRune(r rune) (Key, error) {
309+ if r >= 'A' && r <= 'Z' {
310+ return Key{Sym: strings.ToLower(string(r)), Mods: ModShift}, nil
311+ }
312+ if shiftedGlyph, ok := shiftedGlyphForRune(r); ok {
313+ return Key{Sym: shiftedGlyph}, nil
314+ }
315+ if r >= 'a' && r <= 'z' {
316+ return Key{Sym: string(r)}, nil
317+ }
318+ if unicode.IsPrint(r) {
319+ return Key{Sym: string(r)}, nil
320+ }
321+ return Key{}, ErrBadBinding
322+}
323+
324+func shiftedGlyphForRune(r rune) (string, bool) {
325+ switch r {
326+ case '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '~', '<', '>', '?':
327+ return string(r), true
328+ default:
329+ return "", false
330+ }
331+}
332+
333+func symFromToken(token string, allowSingleLetter bool) (string, error) {
334+ if sym, ok := specialAliases[strings.ToLower(token)]; ok {
335+ return sym, nil
336+ }
337+ if allowSingleLetter && len(token) == 1 {
338+ ch := token[0]
339+ if ch >= 'A' && ch <= 'Z' {
340+ return strings.ToLower(token), nil
341+ }
342+ if ch >= 'a' && ch <= 'z' {
343+ return token, nil
344+ }
345+ }
346+ if len(token) == 1 && unicode.IsPrint(rune(token[0])) {
347+ return token, nil
348+ }
349+ return "", ErrBadBinding
350+}
351+
352+func symFromAlias(token string) (string, error) {
353+ if sym, ok := specialAliases[strings.ToLower(token)]; ok {
354+ return sym, nil
355+ }
356+ return "", ErrBadBinding
357+}
A internal/domain/vimkeys/parse_test.go +146 -0
@@ -0,0 +1,146 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestParseBinding_SingleKeys(t *testing.T) {
9+ tests := []struct {
10+ input string
11+ want Sequence
12+ }{
13+ {input: "j", want: Sequence{{Sym: "j"}}},
14+ {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
15+ {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}},
16+ {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
17+ {input: "<CR>", want: Sequence{{Sym: "CR"}}},
18+ {input: "<Return>", want: Sequence{{Sym: "CR"}}},
19+ {input: "<Esc>", want: Sequence{{Sym: "Esc"}}},
20+ {input: "<Escape>", want: Sequence{{Sym: "Esc"}}},
21+ {input: "<Space>", want: Sequence{{Sym: "Space"}}},
22+ {input: "<lt>", want: Sequence{{Sym: "<"}}},
23+ {input: "<Plus>", want: Sequence{{Sym: "+"}}},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.input, func(t *testing.T) {
27+ got, err := ParseBinding(tt.input)
28+ if err != nil {
29+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
30+ }
31+ if !got.Equal(tt.want) {
32+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestParseBinding_Sequences(t *testing.T) {
39+ tests := []struct {
40+ input string
41+ want Sequence
42+ }{
43+ {input: "]]", want: Sequence{{Sym: "]"}, {Sym: "]"}}},
44+ {input: "yah", want: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}},
45+ {input: "gO", want: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}},
46+ }
47+ for _, tt := range tests {
48+ t.Run(tt.input, func(t *testing.T) {
49+ got, err := ParseBinding(tt.input)
50+ if err != nil {
51+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
52+ }
53+ if !got.Equal(tt.want) {
54+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
55+ }
56+ })
57+ }
58+}
59+
60+func TestParseBinding_Malformed(t *testing.T) {
61+ tests := []struct {
62+ input string
63+ want error
64+ }{
65+ {input: "", want: ErrEmptyBinding},
66+ {input: " ", want: ErrEmptyBinding},
67+ {input: "<", want: ErrBadBinding},
68+ {input: "<>", want: ErrBadBinding},
69+ {input: "<Bad>", want: ErrBadBinding},
70+ {input: "shift+", want: ErrBadBinding},
71+ {input: "ctrl+", want: ErrBadBinding},
72+ }
73+ for _, tt := range tests {
74+ t.Run(tt.input, func(t *testing.T) {
75+ _, err := ParseBinding(tt.input)
76+ if err == nil {
77+ t.Fatalf("ParseBinding(%q) expected error", tt.input)
78+ }
79+ if !errors.Is(err, tt.want) {
80+ t.Fatalf("ParseBinding(%q) error = %v, want %v", tt.input, err, tt.want)
81+ }
82+ })
83+ }
84+}
85+
86+func TestParseBinding_CanonicalString(t *testing.T) {
87+ tests := []struct {
88+ input string
89+ want string
90+ }{
91+ {input: "j", want: "j"},
92+ {input: "shift+j", want: "J"},
93+ {input: "ctrl++", want: "<C-+>"},
94+ {input: "<C-d>", want: "<C-d>"},
95+ {input: "<CR>", want: "<Return>"},
96+ {input: "<Return>", want: "<Return>"},
97+ {input: "<Esc>", want: "<Escape>"},
98+ {input: "<Escape>", want: "<Escape>"},
99+ {input: "<Space>", want: "<Space>"},
100+ {input: "<lt>", want: "<lt>"},
101+ {input: "gO", want: "gO"},
102+ }
103+ for _, tt := range tests {
104+ t.Run(tt.input, func(t *testing.T) {
105+ seq, err := ParseBinding(tt.input)
106+ if err != nil {
107+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
108+ }
109+ if got := seq.String(); got != tt.want {
110+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.want)
111+ }
112+ })
113+ }
114+}
115+
116+func TestParseBinding_RoundTrip(t *testing.T) {
117+ inputs := []string{
118+ "j",
119+ "shift+j",
120+ "ctrl++",
121+ "]]",
122+ "yah",
123+ "gO",
124+ "<C-d>",
125+ "<CR>",
126+ "<Esc>",
127+ "<Space>",
128+ "<lt>",
129+ }
130+ for _, input := range inputs {
131+ t.Run(input, func(t *testing.T) {
132+ first, err := ParseBinding(input)
133+ if err != nil {
134+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
135+ }
136+ canonical := first.String()
137+ second, err := ParseBinding(canonical)
138+ if err != nil {
139+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", input, canonical, err)
140+ }
141+ if !first.Equal(second) {
142+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
143+ }
144+ })
145+ }
146+}
@@ -0,0 +1,146 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestParseBinding_SingleKeys(t *testing.T) {
9+ tests := []struct {
10+ input string
11+ want Sequence
12+ }{
13+ {input: "j", want: Sequence{{Sym: "j"}}},
14+ {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
15+ {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}},
16+ {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
17+ {input: "<CR>", want: Sequence{{Sym: "CR"}}},
18+ {input: "<Return>", want: Sequence{{Sym: "CR"}}},
19+ {input: "<Esc>", want: Sequence{{Sym: "Esc"}}},
20+ {input: "<Escape>", want: Sequence{{Sym: "Esc"}}},
21+ {input: "<Space>", want: Sequence{{Sym: "Space"}}},
22+ {input: "<lt>", want: Sequence{{Sym: "<"}}},
23+ {input: "<Plus>", want: Sequence{{Sym: "+"}}},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.input, func(t *testing.T) {
27+ got, err := ParseBinding(tt.input)
28+ if err != nil {
29+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
30+ }
31+ if !got.Equal(tt.want) {
32+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestParseBinding_Sequences(t *testing.T) {
39+ tests := []struct {
40+ input string
41+ want Sequence
42+ }{
43+ {input: "]]", want: Sequence{{Sym: "]"}, {Sym: "]"}}},
44+ {input: "yah", want: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}},
45+ {input: "gO", want: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}},
46+ }
47+ for _, tt := range tests {
48+ t.Run(tt.input, func(t *testing.T) {
49+ got, err := ParseBinding(tt.input)
50+ if err != nil {
51+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
52+ }
53+ if !got.Equal(tt.want) {
54+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
55+ }
56+ })
57+ }
58+}
59+
60+func TestParseBinding_Malformed(t *testing.T) {
61+ tests := []struct {
62+ input string
63+ want error
64+ }{
65+ {input: "", want: ErrEmptyBinding},
66+ {input: " ", want: ErrEmptyBinding},
67+ {input: "<", want: ErrBadBinding},
68+ {input: "<>", want: ErrBadBinding},
69+ {input: "<Bad>", want: ErrBadBinding},
70+ {input: "shift+", want: ErrBadBinding},
71+ {input: "ctrl+", want: ErrBadBinding},
72+ }
73+ for _, tt := range tests {
74+ t.Run(tt.input, func(t *testing.T) {
75+ _, err := ParseBinding(tt.input)
76+ if err == nil {
77+ t.Fatalf("ParseBinding(%q) expected error", tt.input)
78+ }
79+ if !errors.Is(err, tt.want) {
80+ t.Fatalf("ParseBinding(%q) error = %v, want %v", tt.input, err, tt.want)
81+ }
82+ })
83+ }
84+}
85+
86+func TestParseBinding_CanonicalString(t *testing.T) {
87+ tests := []struct {
88+ input string
89+ want string
90+ }{
91+ {input: "j", want: "j"},
92+ {input: "shift+j", want: "J"},
93+ {input: "ctrl++", want: "<C-+>"},
94+ {input: "<C-d>", want: "<C-d>"},
95+ {input: "<CR>", want: "<Return>"},
96+ {input: "<Return>", want: "<Return>"},
97+ {input: "<Esc>", want: "<Escape>"},
98+ {input: "<Escape>", want: "<Escape>"},
99+ {input: "<Space>", want: "<Space>"},
100+ {input: "<lt>", want: "<lt>"},
101+ {input: "gO", want: "gO"},
102+ }
103+ for _, tt := range tests {
104+ t.Run(tt.input, func(t *testing.T) {
105+ seq, err := ParseBinding(tt.input)
106+ if err != nil {
107+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
108+ }
109+ if got := seq.String(); got != tt.want {
110+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.want)
111+ }
112+ })
113+ }
114+}
115+
116+func TestParseBinding_RoundTrip(t *testing.T) {
117+ inputs := []string{
118+ "j",
119+ "shift+j",
120+ "ctrl++",
121+ "]]",
122+ "yah",
123+ "gO",
124+ "<C-d>",
125+ "<CR>",
126+ "<Esc>",
127+ "<Space>",
128+ "<lt>",
129+ }
130+ for _, input := range inputs {
131+ t.Run(input, func(t *testing.T) {
132+ first, err := ParseBinding(input)
133+ if err != nil {
134+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
135+ }
136+ canonical := first.String()
137+ second, err := ParseBinding(canonical)
138+ if err != nil {
139+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", input, canonical, err)
140+ }
141+ if !first.Equal(second) {
142+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
143+ }
144+ })
145+ }
146+}

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