commit
This commit is contained in:
+352
@@ -0,0 +1,352 @@
|
||||
//! Привязки клавиш к Lua-обработчикам.
|
||||
//!
|
||||
//! Запись привязки немного напоминает nvim:
|
||||
//!
|
||||
//! * `a`, `1` — нажатие клавиши без модификаторов;
|
||||
//! * `<S-c>`, `<C-z>`, `<A-r>` — нажатие с Shift, Ctrl или Alt;
|
||||
//! * `!a` — отпускание клавиши;
|
||||
//! * `<A-!r>` — отпускание клавиши при зажатом Alt.
|
||||
//!
|
||||
//! Клавиша всегда определяется по физическому коду (`physical_key`), поэтому
|
||||
//! Caps Lock и текущая раскладка клавиатуры на совпадение не влияют.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
/// Модификаторы, которые учитываются при совпадении привязки.
|
||||
#[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.
|
||||
fn matches(self, pressed: &egui::Modifiers) -> bool {
|
||||
self.shift == pressed.shift && self.ctrl == pressed.ctrl && self.alt == pressed.alt
|
||||
}
|
||||
}
|
||||
|
||||
/// Разобранная привязка клавиши.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct KeyBinding {
|
||||
/// Физическая клавиша (не зависит от раскладки и Caps Lock).
|
||||
pub key: egui::Key,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Совпадает ли привязка с конкретным событием клавиатуры.
|
||||
fn matches(&self, key: egui::Key, pressed: bool, modifiers: &egui::Modifiers) -> bool {
|
||||
self.key == key && self.on_release == !pressed && self.modifiers.matches(modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
/// Отделяет `!` (событие отпускания) от имени клавиши.
|
||||
fn split_release(token: &str) -> (bool, &str) {
|
||||
match token.strip_prefix('!') {
|
||||
Some(rest) => (true, rest.trim()),
|
||||
None => (false, token),
|
||||
}
|
||||
}
|
||||
|
||||
/// Превращает имя клавиши из привязки в [`egui::Key`].
|
||||
fn parse_key(token: &str) -> Result<egui::Key, 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);
|
||||
}
|
||||
|
||||
// Регистронезависимые имена: `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)
|
||||
}
|
||||
|
||||
/// Привязка вместе с 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, 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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(spec: &str) -> KeyBinding {
|
||||
KeyBinding::parse(spec).expect("привязка должна разбираться")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_keys_and_release() {
|
||||
assert_eq!(
|
||||
parse("a"),
|
||||
KeyBinding {
|
||||
key: egui::Key::A,
|
||||
modifiers: Modifiers::default(),
|
||||
on_release: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse("1"),
|
||||
KeyBinding {
|
||||
key: egui::Key::Num1,
|
||||
modifiers: Modifiers::default(),
|
||||
on_release: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse("!a"),
|
||||
KeyBinding {
|
||||
key: egui::Key::A,
|
||||
modifiers: Modifiers::default(),
|
||||
on_release: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chords_with_modifiers() {
|
||||
assert_eq!(
|
||||
parse("<S-c>"),
|
||||
KeyBinding {
|
||||
key: egui::Key::C,
|
||||
modifiers: Modifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
on_release: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse("<C-z>"),
|
||||
KeyBinding {
|
||||
key: egui::Key::Z,
|
||||
modifiers: Modifiers {
|
||||
ctrl: true,
|
||||
..Default::default()
|
||||
},
|
||||
on_release: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse("<A-!r>"),
|
||||
KeyBinding {
|
||||
key: egui::Key::R,
|
||||
modifiers: Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
on_release: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse("<C-S-A-x>"),
|
||||
KeyBinding {
|
||||
key: egui::Key::X,
|
||||
modifiers: Modifiers {
|
||||
shift: true,
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
},
|
||||
on_release: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bindings_are_rejected() {
|
||||
for spec in ["", " ", "<", "<C-z", "<Q-z>", "!"] {
|
||||
assert!(
|
||||
KeyBinding::parse(spec).is_err(),
|
||||
"строка {spec:?} должна быть отклонена"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_requires_exact_modifiers() {
|
||||
let binding = parse("<S-c>");
|
||||
let shift = egui::Modifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
};
|
||||
let shift_ctrl = egui::Modifiers {
|
||||
shift: true,
|
||||
ctrl: 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user