v0.4.8 de90baf

keymap.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"sort"
6	"strings"
7	"unicode"
8
9	"github.com/patriceckhart/zot/packages/tui"
10)
11
12// keymapBinding is one compiled config.json keymap entry.
13type keymapBinding struct {
14	name    string // normalized chord name, for example "ctrl+shift+g"
15	spec    configuredKey
16	command string
17}
18
19// compileKeymap parses a config.json keymap into bindings sorted by
20// chord name. Invalid entries are dropped and reported in issues so the
21// interactive mode can surface them instead of silently ignoring them.
22func compileKeymap(keymap map[string]string) (bindings []keymapBinding, issues []string) {
23	for _, name := range sortedCustomKeys(keymap) {
24		spec, ok := parseConfiguredKey(name)
25		if !ok {
26			issues = append(issues, fmt.Sprintf("keymap: invalid key %q (use modifier+key, for example ctrl+shift+g)", name))
27			continue
28		}
29		if spec.reserved() {
30			issues = append(issues, fmt.Sprintf("keymap: %q is reserved and cannot be rebound", name))
31			continue
32		}
33		command := strings.TrimSpace(keymap[name])
34		if command == "" || !strings.HasPrefix(command, "/") {
35			issues = append(issues, fmt.Sprintf("keymap: %q must map to a slash command, got %q", name, command))
36			continue
37		}
38		bindings = append(bindings, keymapBinding{name: strings.ToLower(strings.TrimSpace(name)), spec: spec, command: command})
39	}
40	return bindings, issues
41}
42
43// lookupKeymap resolves key against compiled bindings. An exact chord
44// match wins over the legacy Ctrl+letter fallback so enhanced-protocol
45// terminals can bind ctrl+g and ctrl+shift+g independently.
46func lookupKeymap(bindings []keymapBinding, key tui.Key) string {
47	for _, b := range bindings {
48		if b.spec.matches(key, false) {
49			return b.command
50		}
51	}
52	for _, b := range bindings {
53		if b.spec.matches(key, true) {
54			return b.command
55		}
56	}
57	return ""
58}
59
60// configuredKeyCommand resolves a configured key chord straight from a
61// config.json keymap. Key names are case-insensitive and use '+'
62// separated modifiers, for example "ctrl+shift+g", "alt+enter", or
63// "cmd+k". Invalid entries are ignored. Interactive mode compiles the
64// map once; this helper exists for callers that only have the map.
65func configuredKeyCommand(keymap map[string]string, key tui.Key) string {
66	if len(keymap) == 0 {
67		return ""
68	}
69	bindings, _ := compileKeymap(keymap)
70	return lookupKeymap(bindings, key)
71}
72
73// sortedCustomKeys returns keymap names in a stable, case-insensitive
74// order for help output and deterministic lookup precedence.
75func sortedCustomKeys(keymap map[string]string) []string {
76	keys := make([]string, 0, len(keymap))
77	for key := range keymap {
78		keys = append(keys, key)
79	}
80	sort.Slice(keys, func(i, j int) bool {
81		left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j])
82		if left == right {
83			return keys[i] < keys[j]
84		}
85		return left < right
86	})
87	return keys
88}
89
90type configuredKey struct {
91	kind      tui.KeyKind
92	rune      rune
93	ctrl, alt bool
94	shift     bool
95	super     bool
96}
97
98func (k configuredKey) modified() bool {
99	return k.ctrl || k.alt || k.shift || k.super
100}
101
102// reserved reports whether the chord belongs to the emergency/exit
103// controls that config.json must never override.
104func (k configuredKey) reserved() bool {
105	switch {
106	case k.kind == tui.KeyRune && k.ctrl && !k.alt && !k.super && (k.rune == 'c' || k.rune == 'd'):
107		return true
108	case k.kind == tui.KeyEsc && !k.modified():
109		return true
110	}
111	return false
112}
113
114var specialConfiguredKeys = map[string]tui.KeyKind{
115	"enter": tui.KeyEnter, "return": tui.KeyEnter, "tab": tui.KeyTab,
116	"shift-tab": tui.KeyShiftTab, "esc": tui.KeyEsc, "escape": tui.KeyEsc,
117	"backspace": tui.KeyBackspace, "delete": tui.KeyDelete,
118	"up": tui.KeyUp, "down": tui.KeyDown, "left": tui.KeyLeft, "right": tui.KeyRight,
119	"home": tui.KeyHome, "end": tui.KeyEnd, "pageup": tui.KeyPageUp, "pagedown": tui.KeyPageDown,
120	"space": tui.KeyRune,
121}
122
123func parseConfiguredKey(value string) (configuredKey, bool) {
124	var out configuredKey
125	parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+")
126	if len(parts) == 0 {
127		return out, false
128	}
129	base := ""
130	for _, part := range parts {
131		part = strings.TrimSpace(part)
132		switch part {
133		case "ctrl", "control":
134			out.ctrl = true
135		case "alt", "option", "opt":
136			out.alt = true
137		case "shift":
138			out.shift = true
139		case "cmd", "command", "super", "meta", "win":
140			out.super = true
141		case "":
142			return out, false
143		default:
144			if base != "" {
145				return out, false
146			}
147			base = part
148		}
149	}
150	if base == "" {
151		return out, false
152	}
153	out.kind = tui.KeyRune
154	if len([]rune(base)) == 1 {
155		out.rune = []rune(base)[0]
156	} else {
157		kind, ok := specialConfiguredKeys[base]
158		if !ok {
159			return out, false
160		}
161		// Shift+Tab is represented as a dedicated kind by some terminals
162		// and as Tab+Shift by enhanced keyboard protocols; normalize it
163		// to the latter so both forms match.
164		if kind == tui.KeyShiftTab {
165			out.kind = tui.KeyTab
166			out.shift = true
167		} else {
168			out.kind = kind
169		}
170		if base == "space" {
171			out.rune = ' '
172		}
173	}
174	// Unmodified keys belong to normal text entry and navigation. A
175	// typo in config.json must not be able to capture Enter, Space,
176	// arrows, or a plain letter.
177	if !out.modified() {
178		return out, false
179	}
180	return out, true
181}
182
183// ctrlKindRunes maps the reader's dedicated control-key kinds back to
184// the letter they encode so "ctrl+l" style bindings can match them.
185var ctrlKindRunes = map[tui.KeyKind]rune{
186	tui.KeyCtrlC: 'c', tui.KeyCtrlD: 'd', tui.KeyCtrlL: 'l', tui.KeyCtrlU: 'u',
187	tui.KeyCtrlK: 'k', tui.KeyCtrlA: 'a', tui.KeyCtrlE: 'e', tui.KeyCtrlW: 'w', tui.KeyCtrlO: 'o',
188}
189
190// matches reports whether actual is the configured chord. With lenient
191// set, a Ctrl+Shift+letter binding also accepts the plain Ctrl+letter
192// event because legacy terminal encodings cannot carry Shift on control
193// letters. Callers should try exact matching first.
194func (k configuredKey) matches(actual tui.Key, lenient bool) bool {
195	kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl
196	shift := actual.Shift
197	if kind == tui.KeyShiftTab {
198		kind = tui.KeyTab
199		shift = true
200	}
201	// Raw control bytes are exposed as dedicated KeyKinds by the reader.
202	if letter, ok := ctrlKindRunes[kind]; ok {
203		ctrl = true
204		r = letter
205		kind = tui.KeyRune
206	}
207	// Some enhanced keyboard protocols report the shifted glyph rather
208	// than the physical number key (Ctrl+Shift+7 becomes '&'). Normalize
209	// those common US-layout glyphs so number chords remain bindable.
210	if ctrl && shift {
211		if digit, ok := shiftedDigit(r); ok {
212			r = digit
213		}
214	}
215	// Enhanced terminal protocols preserve the uppercase glyph for shifted
216	// letters, while configured key names are case-insensitive.
217	r = unicode.ToLower(r)
218	wantRune := unicode.ToLower(k.rune)
219	shiftMatches := shift == k.shift
220	if !shiftMatches && lenient {
221		shiftMatches = k.ctrl && k.shift && !shift && ctrl && r >= 'a' && r <= 'z'
222	}
223	return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt &&
224		shiftMatches && actual.Super == k.super
225}
226
227func shiftedDigit(r rune) (rune, bool) {
228	const glyphs = ")!@#$%^&*("
229	if i := strings.IndexRune(glyphs, r); i >= 0 {
230		return rune('0' + i), true
231	}
232	return 0, false
233}
234
235// keymapReservedKey reports whether k is one of the emergency/exit
236// controls. They stay authoritative at runtime even if a binding somehow
237// resolves to them.
238func keymapReservedKey(k tui.Key) bool {
239	switch k.Kind {
240	case tui.KeyCtrlC, tui.KeyCtrlD:
241		return true
242	case tui.KeyEsc:
243		return !k.Ctrl && !k.Alt && !k.Shift && !k.Super
244	}
245	return false
246}
247
248// isChordRune reports whether k is a Ctrl or Super modified rune. The
249// reader surfaces unbound chords this way so keymaps can match them;
250// they are never typed text and dialogs must not treat them as bare
251// letters ('r' renames, 'q' closes, ...).
252func isChordRune(k tui.Key) bool {
253	return k.Kind == tui.KeyRune && (k.Ctrl || k.Super)
254}

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 1

Tags 476

↑↓ moveenter openesc close