//! Привязки клавиш к Lua-обработчикам. //! //! Запись привязки немного напоминает nvim: //! //! * `a`, `1` — нажатие клавиши без модификаторов; //! * ``, ``, `` — нажатие с Shift, Ctrl или Alt; //! * `!a` — отпускание клавиши; //! * `` — отпускание клавиши при зажатом Alt. //! //! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а //! имя клавиши, которое платформа уже нормализует: //! //! * Caps Lock не превращает `a` в `A`; //! * на Linux и Windows буквы берутся по аппаратному keycode, поэтому на //! русской раскладке физическая `q` всё равно приходит как `"q"`, а //! напечатанная `й` — только в `key_char`; //! * на macOS GPUI отдаёт символ раскладки, поэтому там привязки следуют //! раскладке (ограничение самого GPUI, не этого модуля). //! //! Отдельного поля с физическим кодом в публичном `Keystroke`/`KeyDownEvent` //! у gpui-kit 0.8.1 нет, но для Linux/Windows он и не нужен: ASCII-имя клавиши //! уже восстановлено по keycode. use gpui_kit::Keystroke; /// Модификаторы, которые учитываются при совпадении привязки. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Modifiers { pub shift: bool, pub ctrl: bool, pub alt: bool, } impl Modifiers { /// Все три модификатора должны совпасть точно: `` не сработает, /// если вместе с Ctrl зажат ещё и Shift. /// /// `platform` (Cmd/Super) и `function` в записи привязки не выражаются, /// поэтому события с ними не совпадают ни с одной привязкой. fn matches(self, pressed: &gpui_kit::Modifiers) -> bool { self.shift == pressed.shift && self.ctrl == pressed.control && self.alt == pressed.alt && !pressed.platform && !pressed.function } } /// Разобранная привязка клавиши. #[derive(Clone, Debug, PartialEq, Eq)] pub struct KeyBinding { /// Каноническое имя клавиши в нижнем регистре (`a`, `1`, `pageup`, `f5`). pub key: String, pub modifiers: Modifiers, /// `true` — обработчик вызывается при отпускании клавиши. pub on_release: bool, } impl KeyBinding { /// Разбирает строку вида `a`, `!1`, `` или ``. /// /// # Errors /// Возвращает текст ошибки, если строка пуста, сочетание не закрыто, /// модификатор или клавиша неизвестны. pub fn parse(spec: &str) -> Result { let spec = spec.trim(); if spec.is_empty() { return Err("пустая привязка клавиши".to_owned()); } if spec.starts_with('<') || spec.ends_with('>') { if !(spec.starts_with('<') && spec.ends_with('>')) { return Err(format!("незакрытое сочетание клавиш: {spec}")); } return Self::parse_chord(&spec[1..spec.len() - 1]); } let (on_release, key) = split_release(spec); Ok(Self { key: parse_key(key)?, modifiers: Modifiers::default(), on_release, }) } /// Разбирает содержимое угловых скобок: модификаторы через `-`, последний /// элемент — клавиша. Например, `S-c` или `A-!r`. fn parse_chord(inner: &str) -> Result { let tokens: Vec<&str> = inner.split('-').collect(); if tokens.iter().any(|token| token.trim().is_empty()) { return Err(format!("пустое сочетание клавиш: <{inner}>")); } let (key_token, modifier_tokens) = tokens .split_last() .expect("split('-') всегда возвращает хотя бы один элемент"); let mut modifiers = Modifiers::default(); for token in modifier_tokens { match token.trim() { "S" | "s" => modifiers.shift = true, "C" | "c" => modifiers.ctrl = true, "A" | "a" => modifiers.alt = true, other => return Err(format!("неизвестный модификатор: {other}")), } } let (on_release, key) = split_release(key_token.trim()); Ok(Self { key: parse_key(key)?, modifiers, on_release, }) } /// Совпадает ли привязка с событием клавиатуры. /// /// `pressed` — `true` для нажатия и `false` для отпускания. pub fn matches(&self, keystroke: &Keystroke, pressed: bool) -> bool { self.on_release == !pressed && self.modifiers.matches(&keystroke.modifiers) && self.key_matches(keystroke) } /// Клавиша события совпадает с привязкой. /// /// Помимо `key` проверяется `key_char`: на части раскладок GPUI кладёт /// в `key` не-ASCII символ, а печатаемый символ — в `key_char`. fn key_matches(&self, keystroke: &Keystroke) -> bool { canonical_key(&keystroke.key) == self.key || keystroke .key_char .as_deref() .is_some_and(|key_char| canonical_key(key_char) == self.key) } } /// Отделяет `!` (событие отпускания) от имени клавиши. fn split_release(token: &str) -> (bool, &str) { match token.strip_prefix('!') { Some(rest) => (true, rest.trim()), None => (false, token), } } /// Приводит имя клавиши из привязки к каноническому виду. fn parse_key(token: &str) -> Result { let token = token.trim(); if token.is_empty() { return Err("не указана клавиша".to_owned()); } match canonical_key(token).as_str() { // Одиночный символ уже канонизирован (`A` → `a`). canonical if canonical.chars().count() == 1 => Ok(canonical.to_owned()), canonical if canonical.starts_with('f') && canonical[1..].parse::().is_ok() => { Ok(canonical.to_owned()) } canonical if matches!( canonical, "space" | "tab" | "enter" | "escape" | "backspace" | "delete" | "insert" | "home" | "end" | "pageup" | "pagedown" | "up" | "down" | "left" | "right" ) => { Ok(canonical.to_owned()) } _ => Err(format!("неизвестная клавиша: {token}")), } } /// Каноническое имя клавиши: нижний регистр, псевдонимы приведены к одному виду. /// /// Функция используется и при разборе записи привязки, и при сравнении с /// событием GPUI, поэтому обе стороны всегда говорят на одном языке. fn canonical_key(key: &str) -> String { let lower = key.trim().to_lowercase(); match lower.as_str() { " " => return "space".to_owned(), "return" | "cr" => return "enter".to_owned(), "esc" => return "escape".to_owned(), "bs" => return "backspace".to_owned(), "del" => return "delete".to_owned(), "ins" => return "insert".to_owned(), "pgup" | "page_up" => return "pageup".to_owned(), "pgdn" | "page_down" => return "pagedown".to_owned(), "arrowup" => return "up".to_owned(), "arrowdown" => return "down".to_owned(), "arrowleft" => return "left".to_owned(), "arrowright" => return "right".to_owned(), _ => {} } lower } /// Привязка вместе с Lua-функцией, которую нужно выполнить. struct Binding { binding: KeyBinding, callback: mlua::Function, } /// Все назначенные привязки клавиш. #[derive(Default)] pub struct KeyMap { bindings: Vec, } impl KeyMap { /// Добавляет привязку. Повторное назначение той же клавиши заменяет /// предыдущий обработчик. pub fn bind(&mut self, binding: KeyBinding, callback: mlua::Function) { if let Some(existing) = self .bindings .iter_mut() .find(|existing| existing.binding == binding) { existing.callback = callback; } else { self.bindings.push(Binding { binding, callback }); } } /// Собирает обработчики, которые нужно вызвать для одного события. /// /// Заём `KeyMap` здесь заканчивается: вызывать колбэки нужно уже после /// возврата из метода, потому что обработчик может сам менять привязки /// через `app:map_key`. pub fn callbacks_for(&self, keystroke: &Keystroke, pressed: bool) -> Vec { self.bindings .iter() .filter(|binding| binding.binding.matches(keystroke, pressed)) .map(|binding| binding.callback.clone()) .collect() } } #[cfg(test)] mod tests { use super::*; /// Событие без символа ввода: для проверки совпадения этого достаточно. fn stroke(key: &str, modifiers: gpui_kit::Modifiers) -> Keystroke { Keystroke { modifiers, key: key.to_owned(), key_char: None, } } fn parse(spec: &str) -> KeyBinding { KeyBinding::parse(spec).expect("привязка должна разбираться") } #[test] fn plain_keys_and_release() { assert_eq!( parse("a"), KeyBinding { key: "a".to_owned(), modifiers: Modifiers::default(), on_release: false, } ); assert_eq!( parse("1"), KeyBinding { key: "1".to_owned(), modifiers: Modifiers::default(), on_release: false, } ); assert_eq!( parse("!a"), KeyBinding { key: "a".to_owned(), modifiers: Modifiers::default(), on_release: true, } ); } #[test] fn chords_with_modifiers() { assert_eq!( parse(""), KeyBinding { key: "c".to_owned(), modifiers: Modifiers { shift: true, ..Default::default() }, on_release: false, } ); assert_eq!( parse(""), KeyBinding { key: "z".to_owned(), modifiers: Modifiers { ctrl: true, ..Default::default() }, on_release: false, } ); assert_eq!( parse(""), KeyBinding { key: "r".to_owned(), modifiers: Modifiers { alt: true, ..Default::default() }, on_release: true, } ); assert_eq!( parse(""), KeyBinding { key: "x".to_owned(), modifiers: Modifiers { shift: true, ctrl: true, alt: true, }, on_release: false, } ); } #[test] fn invalid_bindings_are_rejected() { for spec in ["", " ", "<", "", "!", ""] { assert!( KeyBinding::parse(spec).is_err(), "строка {spec:?} должна быть отклонена" ); } } #[test] fn named_keys_are_case_insensitive() { assert_eq!(parse("").key, "space"); assert_eq!(parse("").key, "enter"); assert_eq!(parse("").key, "escape"); assert_eq!(parse("").key, "f1"); assert_eq!(parse("").key, "pagedown"); assert_eq!(parse("").key, "f5"); } #[test] fn matching_requires_exact_modifiers() { let binding = parse(""); let shift = gpui_kit::Modifiers { shift: true, ..Default::default() }; let shift_ctrl = gpui_kit::Modifiers { shift: true, control: true, ..Default::default() }; assert!(binding.matches(&stroke("c", shift), true)); assert!(!binding.matches(&stroke("c", shift_ctrl), true)); assert!(!binding.matches(&stroke("c", shift), false)); assert!(!binding.matches(&stroke("v", shift), true)); } #[test] fn caps_lock_and_key_char_do_not_break_plain_keys() { let binding = parse("a"); // Caps Lock: GPUI сообщает напечатанный символ `A`, привязка — `a`. assert!(binding.matches(&stroke("A", gpui_kit::Modifiers::default()), true)); // Символ, который напечатала бы клавиша, может отличаться от имени // клавиши (композиция, Option на macOS): GPUI кладёт его в key_char. let mut event = stroke("x", gpui_kit::Modifiers::default()); event.key_char = Some("a".to_owned()); assert!(binding.matches(&event, true)); // А вот Shift+A — уже другое событие. let shift = gpui_kit::Modifiers { shift: true, ..Default::default() }; assert!(!binding.matches(&stroke("a", shift), true)); } #[test] fn cyrillic_layout_uses_the_key_name_gpui_reports() { let binding = parse(""); let ctrl = gpui_kit::Modifiers { control: true, ..Default::default() }; // Linux/Windows: GPUI восстанавливает ASCII-клавишу по аппаратному // keycode, поэтому при русской раскладке key уже "q", а напечатанная // "й" лежит только в key_char. Таблица соответствий не нужна. let mut event = stroke("q", ctrl); event.key_char = Some("й".to_owned()); assert!(binding.matches(&event, true)); // Соседняя физическая клавиша по-прежнему не подходит. event.key = "w".to_owned(); event.key_char = Some("ц".to_owned()); assert!(!binding.matches(&event, true)); } #[test] fn release_bindings_match_only_key_up() { let binding = parse(""); let plain = gpui_kit::Modifiers::default(); assert!(!binding.matches(&stroke("a", plain), true)); assert!(binding.matches(&stroke("a", plain), false)); // Alt зажат: обычная привязка отпускания больше не совпадает. let alt = gpui_kit::Modifiers { alt: true, ..Default::default() }; assert!(!binding.matches(&stroke("a", alt), false)); // А привязка вида `` — совпадает. assert!(parse("").matches(&stroke("a", alt), false)); } }