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}