443 lines
16 KiB
Rust
443 lines
16 KiB
Rust
//! Привязки клавиш к 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));
|
||
}
|
||
}
|