supercode_frontend_tui/input/
key_hint.rs1use crossterm::event::KeyCode;
24use crossterm::event::KeyEvent;
25use crossterm::event::KeyEventKind;
26use crossterm::event::KeyModifiers;
27use ratatui::style::Style;
28use ratatui::text::Span;
29
30#[cfg(test)]
31const ALT_PREFIX: &str = "⌥ + ";
32#[cfg(all(not(test), target_os = "macos"))]
33const ALT_PREFIX: &str = "⌥ + ";
34#[cfg(all(not(test), not(target_os = "macos")))]
35const ALT_PREFIX: &str = "alt + ";
36const CTRL_PREFIX: &str = "ctrl + ";
37const SHIFT_PREFIX: &str = "shift + ";
38
39#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
49pub struct KeyBinding {
50 key: KeyCode,
51 modifiers: KeyModifiers,
52}
53
54impl KeyBinding {
55 pub const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
56 Self { key, modifiers }
57 }
58
59 pub fn from_event(event: KeyEvent) -> Self {
60 let (key, modifiers) = normalize_key_parts(event.code, event.modifiers);
61 Self { key, modifiers }
62 }
63
64 pub fn is_press(&self, event: KeyEvent) -> bool {
65 normalize_key_parts(self.key, self.modifiers)
66 == normalize_key_parts(event.code, event.modifiers)
67 && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
68 }
69
70 pub const fn parts(&self) -> (KeyCode, KeyModifiers) {
71 (self.key, self.modifiers)
72 }
73
74 pub fn display_label(&self) -> String {
75 let modifiers = modifiers_to_string(self.modifiers);
76 let key = match self.key {
77 KeyCode::Enter => "enter".to_string(),
78 KeyCode::Char(' ') => "space".to_string(),
79 KeyCode::Up => "↑".to_string(),
80 KeyCode::Down => "↓".to_string(),
81 KeyCode::Left => "←".to_string(),
82 KeyCode::Right => "→".to_string(),
83 KeyCode::PageUp => "pgup".to_string(),
84 KeyCode::PageDown => "pgdn".to_string(),
85 _ => self.key.to_string().to_ascii_lowercase(),
86 };
87 format!("{modifiers}{key}")
88 }
89}
90
91pub fn normalize_key_parts(key: KeyCode, mut modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
92 let KeyCode::Char(ch) = key else {
93 return (key, modifiers);
94 };
95 if modifiers.is_empty() {
96 if let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch) {
97 return (KeyCode::Char(ctrl_char), KeyModifiers::CONTROL | modifiers);
98 }
99 }
100 if ch.is_ascii_uppercase() {
101 modifiers.insert(KeyModifiers::SHIFT);
102 return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers);
103 }
104 (key, modifiers)
105}
106
107fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {
108 let code = u32::from(ch);
109 match code {
110 0x00 => Some(' '),
111 0x01..=0x1a => char::from_u32(code - 0x01 + u32::from('a')),
112 0x1c..=0x1f => char::from_u32(code - 0x1c + u32::from('4')),
113 _ => None,
114 }
115}
116
117pub trait KeyBindingListExt {
123 fn is_pressed(&self, event: KeyEvent) -> bool;
125}
126
127impl KeyBindingListExt for [KeyBinding] {
128 fn is_pressed(&self, event: KeyEvent) -> bool {
129 self.iter().any(|binding| binding.is_press(event))
130 }
131}
132
133pub fn is_plain_text_key_event(event: KeyEvent) -> bool {
142 matches!(
143 event,
144 KeyEvent {
145 code: KeyCode::Char(ch),
146 modifiers,
147 ..
148 } if !ch.is_ascii_control()
149 && !modifiers.contains(KeyModifiers::CONTROL)
150 && !modifiers.contains(KeyModifiers::ALT)
151 )
152}
153
154pub const fn plain(key: KeyCode) -> KeyBinding {
155 KeyBinding::new(key, KeyModifiers::NONE)
156}
157
158pub const fn alt(key: KeyCode) -> KeyBinding {
159 KeyBinding::new(key, KeyModifiers::ALT)
160}
161
162pub const fn shift(key: KeyCode) -> KeyBinding {
163 KeyBinding::new(key, KeyModifiers::SHIFT)
164}
165
166pub const fn ctrl(key: KeyCode) -> KeyBinding {
167 KeyBinding::new(key, KeyModifiers::CONTROL)
168}
169
170pub const fn ctrl_alt(key: KeyCode) -> KeyBinding {
171 KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT))
172}
173
174fn modifiers_to_string(modifiers: KeyModifiers) -> String {
175 let mut result = String::new();
176 if modifiers.contains(KeyModifiers::CONTROL) {
177 result.push_str(CTRL_PREFIX);
178 }
179 if modifiers.contains(KeyModifiers::SHIFT) {
180 result.push_str(SHIFT_PREFIX);
181 }
182 if modifiers.contains(KeyModifiers::ALT) {
183 result.push_str(ALT_PREFIX);
184 }
185 result
186}
187
188impl From<KeyBinding> for Span<'static> {
189 fn from(binding: KeyBinding) -> Self {
190 (&binding).into()
191 }
192}
193impl From<&KeyBinding> for Span<'static> {
194 fn from(binding: &KeyBinding) -> Self {
195 Span::styled(binding.display_label(), key_hint_style())
196 }
197}
198
199fn key_hint_style() -> Style {
200 Style::default().dim()
201}
202
203pub fn has_ctrl_or_alt(mods: KeyModifiers) -> bool {
204 (mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods)
205}
206
207#[cfg(windows)]
208#[inline]
209pub fn is_altgr(mods: KeyModifiers) -> bool {
210 mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL)
211}
212
213#[cfg(not(windows))]
214#[inline]
215pub fn is_altgr(_mods: KeyModifiers) -> bool {
216 false
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn is_press_accepts_press_and_repeat_but_rejects_release() {
225 let binding = ctrl(KeyCode::Char('k'));
226 let press = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
227 let repeat = KeyEvent {
228 kind: KeyEventKind::Repeat,
229 ..press
230 };
231 let release = KeyEvent {
232 kind: KeyEventKind::Release,
233 ..press
234 };
235 let wrong_modifiers = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
236
237 assert!(binding.is_press(press));
238 assert!(binding.is_press(repeat));
239 assert!(!binding.is_press(release));
240 assert!(!binding.is_press(wrong_modifiers));
241 }
242
243 #[test]
244 fn keybinding_list_ext_matches_any_binding() {
245 let bindings = [plain(KeyCode::Char('a')), ctrl(KeyCode::Char('b'))];
246
247 assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
248 assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)));
249 assert!(!bindings.is_pressed(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
250 }
251
252 #[test]
253 fn shifted_letter_binding_matches_uppercase_char_events() {
254 let binding = shift(KeyCode::Char('a'));
255
256 assert!(binding.is_press(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)));
257 assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)));
258 assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT)));
259 }
260
261 #[test]
262 fn shift_letter_binding_preserves_other_modifiers_with_uppercase_compat() {
263 let binding = KeyBinding::new(
264 KeyCode::Char('i'),
265 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
266 );
267
268 assert!(binding.is_press(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::CONTROL)));
269 }
270
271 #[test]
272 fn shift_letter_binding_does_not_match_plain_lowercase_or_other_uppercase() {
273 let binding = shift(KeyCode::Char('o'));
274
275 assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)));
276 assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE)));
277 }
278
279 #[test]
280 fn ctrl_letter_binding_matches_c0_control_char_events() {
281 let binding = ctrl(KeyCode::Char('p'));
282
283 assert!(binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)));
284 assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::ALT)));
285 }
286
287 #[test]
288 fn ctrl_bindings_match_all_supported_c0_control_char_events() {
289 let cases = [
290 (' ', '\u{0000}'),
291 ('a', '\u{0001}'),
292 ('b', '\u{0002}'),
293 ('c', '\u{0003}'),
294 ('d', '\u{0004}'),
295 ('e', '\u{0005}'),
296 ('f', '\u{0006}'),
297 ('g', '\u{0007}'),
298 ('h', '\u{0008}'),
299 ('i', '\u{0009}'),
300 ('j', '\u{000a}'),
301 ('k', '\u{000b}'),
302 ('l', '\u{000c}'),
303 ('m', '\u{000d}'),
304 ('n', '\u{000e}'),
305 ('o', '\u{000f}'),
306 ('p', '\u{0010}'),
307 ('q', '\u{0011}'),
308 ('r', '\u{0012}'),
309 ('s', '\u{0013}'),
310 ('t', '\u{0014}'),
311 ('u', '\u{0015}'),
312 ('v', '\u{0016}'),
313 ('w', '\u{0017}'),
314 ('x', '\u{0018}'),
315 ('y', '\u{0019}'),
316 ('z', '\u{001a}'),
317 ('4', '\u{001c}'),
318 ('5', '\u{001d}'),
319 ('6', '\u{001e}'),
320 ('7', '\u{001f}'),
321 ];
322
323 for (ctrl_char, c0_char) in cases {
324 assert!(
325 ctrl(KeyCode::Char(ctrl_char))
326 .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::NONE)),
327 "expected raw C0 {c0_char:?} to match ctrl-{ctrl_char}"
328 );
329 assert!(
330 !ctrl(KeyCode::Char(ctrl_char))
331 .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::ALT)),
332 "expected modified raw C0 {c0_char:?} not to match ctrl-{ctrl_char}"
333 );
334 }
335 }
336
337 #[test]
338 fn ctrl_binding_does_not_match_ambiguous_c0_escape_or_delete() {
339 assert!(!ctrl(KeyCode::Char('['))
340 .is_press(KeyEvent::new(KeyCode::Char('\u{001b}'), KeyModifiers::NONE,)));
341 assert!(!ctrl(KeyCode::Char('?'))
342 .is_press(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE,)));
343 }
344
345 #[test]
346 fn history_search_ctrl_bindings_match_c0_control_char_events() {
347 assert!(ctrl(KeyCode::Char('r'))
348 .is_press(KeyEvent::new(KeyCode::Char('\u{0012}'), KeyModifiers::NONE)));
349 assert!(ctrl(KeyCode::Char('s'))
350 .is_press(KeyEvent::new(KeyCode::Char('\u{0013}'), KeyModifiers::NONE)));
351 }
352
353 #[test]
354 fn ctrl_alt_sets_both_modifiers() {
355 assert_eq!(
356 ctrl_alt(KeyCode::Char('v')).parts(),
357 (
358 KeyCode::Char('v'),
359 KeyModifiers::CONTROL | KeyModifiers::ALT
360 )
361 );
362 }
363
364 #[test]
365 fn has_ctrl_or_alt_checks_supported_modifier_combinations() {
366 assert!(!has_ctrl_or_alt(KeyModifiers::NONE));
367 assert!(has_ctrl_or_alt(KeyModifiers::CONTROL));
368 assert!(has_ctrl_or_alt(KeyModifiers::ALT));
369
370 #[cfg(windows)]
371 assert!(!has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
372 #[cfg(not(windows))]
373 assert!(has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
374 }
375}