This commit is contained in:
2026-10-29 22:40:40 +03:00
commit dce595fbc0
18 changed files with 7983 additions and 0 deletions
+352
View File
@@ -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));
}
}