parse.go RawHistoryFolder

1package vimkeys
2
3import (
4	"strings"
5	"unicode"
6	"unicode/utf8"
7)
8
9func ParseBinding(s string) (Sequence, error) {
10	if !utf8.ValidString(s) {
11		return nil, ErrBadBinding
12	}
13	s = strings.TrimSpace(s)
14	if s == "" {
15		return nil, ErrEmptyBinding
16	}
17
18	if key, ok := tryParseLegacyAtom(s); ok {
19		return Sequence{key}, nil
20	}
21
22	if key, ok, err := tryParseAsLegacyChord(s); ok {
23		if err != nil {
24			return nil, err
25		}
26		return Sequence{key}, nil
27	}
28	return parseRawSequence(s)
29}
30
31// tryParseLegacyAtom treats raw UI key names enter/escape (and aliases) as single
32// keys so they remain compatible with the GTK shortcut table contract.
33func tryParseLegacyAtom(s string) (Key, bool) {
34	switch strings.ToLower(s) {
35	case "enter", "return":
36		return Key{Sym: symCR}, true
37	case "escape", "esc":
38		return Key{Sym: symEsc}, true
39	default:
40		return Key{}, false
41	}
42}
43
44func legacyChordParts(s string) []string {
45	parts := strings.Split(s, "+")
46	tokens := make([]string, 0, len(parts))
47	for _, part := range parts {
48		if part = strings.TrimSpace(part); part != "" {
49			tokens = append(tokens, part)
50		}
51	}
52	return tokens
53}
54
55func isModifierName(part string) bool {
56	switch strings.ToLower(part) {
57	case "ctrl", "control", "shift", "alt":
58		return true
59	default:
60		return false
61	}
62}
63
64func tryParseAsLegacyChord(s string) (Key, bool, error) {
65	if s == "+" {
66		return Key{Sym: "+"}, true, nil
67	}
68
69	parts := legacyChordParts(s)
70	if !legacyChordLooksLikeAttempt(s, parts) {
71		return Key{}, false, nil
72	}
73
74	key, err := buildLegacyChordKey(s, parts)
75	return key, true, err
76}
77
78func legacyChordLooksLikeAttempt(s string, parts []string) bool {
79	if strings.HasSuffix(s, "++") {
80		return true
81	}
82	for _, part := range parts {
83		if isModifierName(part) {
84			return true
85		}
86	}
87	return false
88}
89
90func validateLegacyChordLayout(s string, parts []string) error {
91	if !strings.Contains(s, "+") {
92		return ErrBadBinding
93	}
94	if strings.HasSuffix(s, "++") {
95		return nil
96	}
97	for i, part := range parts {
98		if isModifierName(part) {
99			continue
100		}
101		if i != len(parts)-1 {
102			return ErrBadBinding
103		}
104	}
105	return nil
106}
107
108func modsFromLegacyPart(part string) (Mods, bool) {
109	switch strings.ToLower(part) {
110	case "ctrl", "control":
111		return ModCtrl, true
112	case "shift":
113		return ModShift, true
114	case "alt":
115		return ModAlt, true
116	default:
117		return 0, false
118	}
119}
120
121func collectLegacyChordModsAndKey(s string, parts []string) (Mods, string, error) {
122	if err := validateLegacyChordLayout(s, parts); err != nil {
123		return 0, "", err
124	}
125
126	var mods Mods
127	keyPart := ""
128	for _, part := range parts {
129		if mod, ok := modsFromLegacyPart(part); ok {
130			mods |= mod
131			continue
132		}
133		if keyPart != "" {
134			return 0, "", ErrBadBinding
135		}
136		keyPart = part
137	}
138
139	if keyPart == "" && strings.HasSuffix(s, "++") {
140		keyPart = "+"
141	}
142	if keyPart == "" {
143		return 0, "", ErrBadBinding
144	}
145	return mods, keyPart, nil
146}
147
148func legacyKeyPartWithShift(keyPart string, mods Mods) (string, Mods) {
149	if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
150		return strings.ToLower(keyPart), mods | ModShift
151	}
152	return keyPart, mods
153}
154
155func buildLegacyChordKey(s string, parts []string) (Key, error) {
156	mods, keyPart, err := collectLegacyChordModsAndKey(s, parts)
157	if err != nil {
158		return Key{}, err
159	}
160	keyPart, mods = legacyKeyPartWithShift(keyPart, mods)
161	sym, err := symFromToken(keyPart, false)
162	if err != nil {
163		return Key{}, err
164	}
165	return Key{Sym: sym, Mods: mods}, nil
166}
167
168func parseAngleToken(inner string) (Key, error) {
169	if !strings.Contains(inner, "-") {
170		sym, err := symFromAlias(inner)
171		if err != nil {
172			return Key{}, err
173		}
174		return Key{Sym: sym}, nil
175	}
176
177	mods, symPart, err := splitAngleModsAndSym(inner)
178	if err != nil {
179		return Key{}, err
180	}
181
182	sym, err := symFromToken(symPart, true)
183	if err != nil {
184		return Key{}, err
185	}
186	return Key{Sym: sym, Mods: mods}, nil
187}
188
189func splitAngleModsAndSym(inner string) (Mods, string, error) {
190	var modInner, symPart string
191	if strings.HasSuffix(inner, "--") {
192		modInner = inner[:len(inner)-2]
193		symPart = "-"
194	} else {
195		lastDash := strings.LastIndexByte(inner, '-')
196		if lastDash < 0 {
197			return 0, "", ErrBadBinding
198		}
199		modInner = inner[:lastDash]
200		symPart = inner[lastDash+1:]
201	}
202	if symPart == "" || modInner == "" {
203		return 0, "", ErrBadBinding
204	}
205
206	var mods Mods
207	for _, modPart := range strings.Split(modInner, "-") {
208		modPart = strings.TrimSpace(modPart)
209		if modPart == "" {
210			return 0, "", ErrBadBinding
211		}
212		partMods, err := modsFromCompactModString(modPart)
213		if err != nil {
214			return 0, "", err
215		}
216		mods |= partMods
217	}
218	return mods, symPart, nil
219}
220
221func modsFromCompactModString(modPart string) (Mods, error) {
222	var mods Mods
223	for _, ch := range modPart {
224		switch ch {
225		case 'C', 'c':
226			mods |= ModCtrl
227		case 'S', 's':
228			mods |= ModShift
229		case 'A', 'a':
230			mods |= ModAlt
231		default:
232			return 0, ErrBadBinding
233		}
234	}
235	return mods, nil
236}
237
238func parseRawSequence(s string) (Sequence, error) {
239	seq := make(Sequence, 0, len(s))
240	for i := 0; i < len(s); {
241		if s[i] == '<' {
242			key, width, err := parseAngleAt(s, i)
243			if err != nil {
244				return nil, err
245			}
246			seq = append(seq, key)
247			i += width
248			continue
249		}
250		// Raw text is runes plus <...> tokens (and whole-string legacy atoms/chords
251		// handled above). Bare C-/S-/A- must not steal printable runs like "C-d".
252		r, width := utf8.DecodeRuneInString(s[i:])
253		key, err := keyFromRune(r)
254		if err != nil {
255			return nil, err
256		}
257		seq = append(seq, key)
258		i += width
259	}
260	return seq, nil
261}
262
263func parseAngleAt(s string, i int) (Key, int, error) {
264	if i >= len(s) || s[i] != '<' {
265		return Key{}, 0, ErrBadBinding
266	}
267	closeIdx := strings.IndexByte(s[i+1:], '>')
268	if closeIdx < 0 {
269		return Key{}, 0, ErrBadBinding
270	}
271	closeIdx += i + 1
272	inner := s[i+1 : closeIdx]
273	if inner == "" {
274		return Key{}, 0, ErrBadBinding
275	}
276	key, err := parseAngleToken(inner)
277	if err != nil {
278		return Key{}, 0, err
279	}
280	return key, closeIdx - i + 1, nil
281}
282
283func keyFromRune(r rune) (Key, error) {
284	if r >= 'A' && r <= 'Z' {
285		return Key{Sym: strings.ToLower(string(r)), Mods: ModShift}, nil
286	}
287	if shiftedGlyph, ok := shiftedGlyphForRune(r); ok {
288		return Key{Sym: shiftedGlyph}, nil
289	}
290	if r >= 'a' && r <= 'z' {
291		return Key{Sym: string(r)}, nil
292	}
293	if unicode.IsPrint(r) {
294		return Key{Sym: string(r)}, nil
295	}
296	return Key{}, ErrBadBinding
297}
298
299func shiftedGlyphForRune(r rune) (string, bool) {
300	switch r {
301	case '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '~', '<', '>', '?':
302		return string(r), true
303	default:
304		return "", false
305	}
306}
307
308func symFromSpecialAlias(token string) (string, bool) {
309	lower := strings.ToLower(token)
310	switch lower {
311	case "cr", "return":
312		return symCR, true
313	case "esc", "escape":
314		return symEsc, true
315	case "space":
316		return "Space", true
317	case "lt":
318		return "<", true
319	case "gt":
320		return ">", true
321	case "plus":
322		return "+", true
323	default:
324		return namedSymFromAlias(lower)
325	}
326}
327
328func namedSymFromAlias(lower string) (string, bool) {
329	switch lower {
330	case "tab":
331		return "Tab", true
332	case "backspace":
333		return "BackSpace", true
334	case "delete":
335		return "Delete", true
336	case "left":
337		return "Left", true
338	case "right":
339		return "Right", true
340	case "up":
341		return "Up", true
342	case "down":
343		return "Down", true
344	case "home":
345		return "Home", true
346	case "end":
347		return "End", true
348	case "pageup":
349		return "PageUp", true
350	case "pagedown":
351		return "PageDown", true
352	default:
353		return "", false
354	}
355}
356
357func symFromToken(token string, allowSingleLetter bool) (string, error) {
358	if sym, ok := symFromSpecialAlias(token); ok {
359		return sym, nil
360	}
361	if allowSingleLetter {
362		if sym, ok := asciiLetterSym(token); ok {
363			return sym, nil
364		}
365	}
366	if sym, ok := singlePrintableRuneSym(token); ok {
367		return sym, nil
368	}
369	return "", ErrBadBinding
370}
371
372func symFromAlias(token string) (string, error) {
373	if sym, ok := symFromSpecialAlias(token); ok {
374		return sym, nil
375	}
376	// One-character / one-rune literal angle tokens (<e>, <é>) disambiguate
377	// legacy-atom collisions. Multi-rune unknowns such as <Nope> remain invalid.
378	if sym, ok := asciiLetterSym(token); ok {
379		return sym, nil
380	}
381	if sym, ok := singlePrintableRuneSym(token); ok {
382		return sym, nil
383	}
384	return "", ErrBadBinding
385}
386
387// asciiLetterSym normalizes a single ASCII letter token (A-Z -> a-z).
388func asciiLetterSym(token string) (string, bool) {
389	if len(token) != 1 {
390		return "", false
391	}
392	ch := token[0]
393	if ch >= 'A' && ch <= 'Z' {
394		return strings.ToLower(token), true
395	}
396	if ch >= 'a' && ch <= 'z' {
397		return token, true
398	}
399	return "", false
400}
401
402// singlePrintableRuneSym accepts exactly one valid UTF-8 printable rune.
403func singlePrintableRuneSym(token string) (string, bool) {
404	if token == "" || !utf8.ValidString(token) {
405		return "", false
406	}
407	r, size := utf8.DecodeRuneInString(token)
408	if size != len(token) {
409		return "", false
410	}
411	if !unicode.IsPrint(r) {
412		return "", false
413	}
414	return token, true
415}

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