Files
qode/src/keymap.rs
T
2026-10-30 00:31:49 +03:00

443 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Привязки клавиш к Lua-обработчикам.
//!
//! Запись привязки немного напоминает nvim:
//!
//! * `a`, `1` — нажатие клавиши без модификаторов;
//! * `<S-c>`, `<C-z>`, `<A-r>` — нажатие с Shift, Ctrl или Alt;
//! * `!a` — отпускание клавиши;
//! * `<A-!r>` — отпускание клавиши при зажатом 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 {
/// Все три модификатора должны совпасть точно: `<C-z>` не сработает,
/// если вместе с 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`, `<C-z>` или `<A-!r>`.
///
/// # Errors
/// Возвращает текст ошибки, если строка пуста, сочетание не закрыто,
/// модификатор или клавиша неизвестны.
pub fn parse(spec: &str) -> Result<Self, String> {
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<Self, String> {
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<String, String> {
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::<u8>().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<Binding>,
}
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<mlua::Function> {
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("<S-c>"),
KeyBinding {
key: "c".to_owned(),
modifiers: Modifiers {
shift: true,
..Default::default()
},
on_release: false,
}
);
assert_eq!(
parse("<C-z>"),
KeyBinding {
key: "z".to_owned(),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
on_release: false,
}
);
assert_eq!(
parse("<A-!r>"),
KeyBinding {
key: "r".to_owned(),
modifiers: Modifiers {
alt: true,
..Default::default()
},
on_release: true,
}
);
assert_eq!(
parse("<C-S-A-x>"),
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 ["", " ", "<", "<C-z", "<Q-z>", "!", "<S-unknown>"] {
assert!(
KeyBinding::parse(spec).is_err(),
"строка {spec:?} должна быть отклонена"
);
}
}
#[test]
fn named_keys_are_case_insensitive() {
assert_eq!(parse("<C-space>").key, "space");
assert_eq!(parse("<S-enter>").key, "enter");
assert_eq!(parse("<A-esc>").key, "escape");
assert_eq!(parse("<C-f1>").key, "f1");
assert_eq!(parse("<C-pgdn>").key, "pagedown");
assert_eq!(parse("<C-F5>").key, "f5");
}
#[test]
fn matching_requires_exact_modifiers() {
let binding = parse("<S-c>");
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("<C-q>");
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("<!A>");
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));
// А привязка вида `<A-!a>` — совпадает.
assert!(parse("<A-!a>").matches(&stroke("a", alt), false));
}
}