use gpui
This commit is contained in:
+194
-104
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user