This commit is contained in:
2026-10-30 00:31:49 +03:00
parent dce595fbc0
commit e00f63e5da
17 changed files with 6479 additions and 3200 deletions
+194 -104
View File
@@ -7,10 +7,21 @@
//! * `!a` — отпускание клавиши;
//! * `<A-!r>` — отпускание клавиши при зажатом Alt.
//!
//! Клавиша всегда определяется по физическому коду (`physical_key`), поэтому
//! Caps Lock и текущая раскладка клавиатуры на совпадение не влияют.
//! Клавиша сравнивается с `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 eframe::egui;
use gpui_kit::Keystroke;
/// Модификаторы, которые учитываются при совпадении привязки.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -23,16 +34,23 @@ pub struct Modifiers {
impl Modifiers {
/// Все три модификатора должны совпасть точно: `<C-z>` не сработает,
/// если вместе с Ctrl зажат ещё и Shift.
fn matches(self, pressed: &egui::Modifiers) -> bool {
self.shift == pressed.shift && self.ctrl == pressed.ctrl && self.alt == pressed.alt
///
/// `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 {
/// Физическая клавиша (не зависит от раскладки и Caps Lock).
pub key: egui::Key,
/// Каноническое имя клавиши в нижнем регистре (`a`, `1`, `pageup`, `f5`).
pub key: String,
pub modifiers: Modifiers,
/// `true` — обработчик вызывается при отпускании клавиши.
pub on_release: bool,
@@ -95,9 +113,25 @@ impl KeyBinding {
})
}
/// Совпадает ли привязка с конкретным событием клавиатуры.
fn matches(&self, key: egui::Key, pressed: bool, modifiers: &egui::Modifiers) -> bool {
self.key == key && self.on_release == !pressed && self.modifiers.matches(modifiers)
/// Совпадает ли привязка с событием клавиатуры.
///
/// `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)
}
}
@@ -109,52 +143,67 @@ fn split_release(token: &str) -> (bool, &str) {
}
}
/// Превращает имя клавиши из привязки в [`egui::Key`].
fn parse_key(token: &str) -> Result<egui::Key, String> {
/// Приводит имя клавиши из привязки к каноническому виду.
fn parse_key(token: &str) -> Result<String, String> {
let token = token.trim();
if token.is_empty() {
return Err("не указана клавиша".to_owned());
}
// Односимвольные клавиши (`a`, `1`, `-`) и точные имена (`F1`, `Space`).
if let Some(key) = egui::Key::from_name(token) {
return Ok(key);
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}")),
}
// Регистронезависимые имена: `space`, `enter`, `esc`, `pgup` и т. п.
if let Some(key) = alias_key(token) {
return Ok(key);
}
// `f1` → `F1`.
if let Some(key) = egui::Key::from_name(&token.to_ascii_uppercase()) {
return Ok(key);
}
Err(format!("неизвестная клавиша: {token}"))
}
/// Имена клавиш в нижнем регистре, которых нет в [`egui::Key::from_name`].
fn alias_key(token: &str) -> Option<egui::Key> {
let name = match token.to_ascii_lowercase().as_str() {
"space" => "Space",
"tab" => "Tab",
"enter" | "return" | "cr" => "Enter",
"esc" | "escape" => "Escape",
"backspace" | "bs" => "Backspace",
"delete" | "del" => "Delete",
"insert" => "Insert",
"home" => "Home",
"end" => "End",
"pageup" | "pgup" => "PageUp",
"pagedown" | "pgdn" => "PageDown",
"up" | "arrowup" => "ArrowUp",
"down" | "arrowdown" => "ArrowDown",
"left" | "arrowleft" => "ArrowLeft",
"right" | "arrowright" => "ArrowRight",
_ => return None,
};
egui::Key::from_name(name)
/// Каноническое имя клавиши: нижний регистр, псевдонимы приведены к одному виду.
///
/// Функция используется и при разборе записи привязки, и при сравнении с
/// событием 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-функцией, которую нужно выполнить.
@@ -184,46 +233,17 @@ impl KeyMap {
}
}
/// Собирает обработчики, которые нужно вызвать для событий текущего кадра.
/// Собирает обработчики, которые нужно вызвать для одного события.
///
/// Заём `KeyMap` здесь заканчивается: вызывать колбэки нужно уже после
/// возврата из метода, потому что обработчик может сам менять привязки
/// через `app:map_key`.
pub fn callbacks_for(&self, ctx: &egui::Context) -> Vec<mlua::Function> {
let key_events: Vec<(egui::Key, bool, egui::Modifiers)> = ctx.input(|input| {
input
.events
.iter()
.filter_map(|event| {
let egui::Event::Key {
key,
physical_key,
pressed,
modifiers,
..
} = event
else {
return None;
};
// Физический код не зависит от раскладки и Caps Lock.
// Логическая клавиша — запасной вариант для платформ,
// где physical_key не заполняется.
Some((physical_key.unwrap_or(*key), *pressed, *modifiers))
})
.collect()
});
let mut callbacks = Vec::new();
for (key, pressed, modifiers) in key_events {
for binding in &self.bindings {
if binding.binding.matches(key, pressed, &modifiers) {
callbacks.push(binding.callback.clone());
}
}
}
callbacks
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()
}
}
@@ -231,6 +251,15 @@ impl KeyMap {
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("привязка должна разбираться")
}
@@ -240,7 +269,7 @@ mod tests {
assert_eq!(
parse("a"),
KeyBinding {
key: egui::Key::A,
key: "a".to_owned(),
modifiers: Modifiers::default(),
on_release: false,
}
@@ -248,7 +277,7 @@ mod tests {
assert_eq!(
parse("1"),
KeyBinding {
key: egui::Key::Num1,
key: "1".to_owned(),
modifiers: Modifiers::default(),
on_release: false,
}
@@ -256,7 +285,7 @@ mod tests {
assert_eq!(
parse("!a"),
KeyBinding {
key: egui::Key::A,
key: "a".to_owned(),
modifiers: Modifiers::default(),
on_release: true,
}
@@ -268,7 +297,7 @@ mod tests {
assert_eq!(
parse("<S-c>"),
KeyBinding {
key: egui::Key::C,
key: "c".to_owned(),
modifiers: Modifiers {
shift: true,
..Default::default()
@@ -279,7 +308,7 @@ mod tests {
assert_eq!(
parse("<C-z>"),
KeyBinding {
key: egui::Key::Z,
key: "z".to_owned(),
modifiers: Modifiers {
ctrl: true,
..Default::default()
@@ -290,7 +319,7 @@ mod tests {
assert_eq!(
parse("<A-!r>"),
KeyBinding {
key: egui::Key::R,
key: "r".to_owned(),
modifiers: Modifiers {
alt: true,
..Default::default()
@@ -301,7 +330,7 @@ mod tests {
assert_eq!(
parse("<C-S-A-x>"),
KeyBinding {
key: egui::Key::X,
key: "x".to_owned(),
modifiers: Modifiers {
shift: true,
ctrl: true,
@@ -314,7 +343,7 @@ mod tests {
#[test]
fn invalid_bindings_are_rejected() {
for spec in ["", " ", "<", "<C-z", "<Q-z>", "!"] {
for spec in ["", " ", "<", "<C-z", "<Q-z>", "!", "<S-unknown>"] {
assert!(
KeyBinding::parse(spec).is_err(),
"строка {spec:?} должна быть отклонена"
@@ -324,29 +353,90 @@ mod tests {
#[test]
fn named_keys_are_case_insensitive() {
assert_eq!(parse("<C-space>").key, egui::Key::Space);
assert_eq!(parse("<S-enter>").key, egui::Key::Enter);
assert_eq!(parse("<A-esc>").key, egui::Key::Escape);
assert_eq!(parse("<C-f1>").key, egui::Key::F1);
assert_eq!(parse("<C-pgdn>").key, egui::Key::PageDown);
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 = egui::Modifiers {
let shift = gpui_kit::Modifiers {
shift: true,
..Default::default()
};
let shift_ctrl = egui::Modifiers {
let shift_ctrl = gpui_kit::Modifiers {
shift: true,
ctrl: true,
control: true,
..Default::default()
};
assert!(binding.matches(egui::Key::C, true, &shift));
assert!(!binding.matches(egui::Key::C, true, &shift_ctrl));
assert!(!binding.matches(egui::Key::C, false, &shift));
assert!(!binding.matches(egui::Key::V, true, &shift));
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));
}
}