209 lines
7.5 KiB
Rust
209 lines
7.5 KiB
Rust
//! Keyboard shortcuts: command shortcuts from the registry, single-key tool shortcuts, arrows,
|
||
//! and tool keys (Enter/Esc/↑/↓ while drawing).
|
||
|
||
use drawcraft_tools::{Mods, ToolKey};
|
||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||
use serde_json::json;
|
||
|
||
use crate::DrawcraftApp;
|
||
|
||
/// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere.
|
||
pub fn parse(s: &str) -> Option<KeyboardShortcut> {
|
||
if s.is_empty() {
|
||
return None;
|
||
}
|
||
let (mods_part, key_part) = if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').map(|(m, k)| (m, k)).unwrap_or(("", s)) };
|
||
let mut m = Modifiers::NONE;
|
||
for part in mods_part.split('+').filter(|p| !p.is_empty()) {
|
||
match part {
|
||
"Cmd" => m = m | Modifiers::COMMAND,
|
||
"Shift" => m = m | Modifiers::SHIFT,
|
||
"Alt" => m = m | Modifiers::ALT,
|
||
"Ctrl" => m = m | Modifiers::CTRL,
|
||
_ => return None,
|
||
}
|
||
}
|
||
let key = match key_part {
|
||
"]" => Key::CloseBracket,
|
||
"[" => Key::OpenBracket,
|
||
";" => Key::Semicolon,
|
||
"'" => Key::Quote,
|
||
"/" => Key::Slash,
|
||
"\\" => Key::Backslash,
|
||
"=" => Key::Equals,
|
||
"-" => Key::Minus,
|
||
"+" => Key::Plus,
|
||
"Delete" => Key::Delete,
|
||
"Backspace" => Key::Backspace,
|
||
"Tab" => Key::Tab,
|
||
"~" => Key::Backtick,
|
||
k => Key::from_name(k)?,
|
||
};
|
||
Some(KeyboardShortcut::new(m, key))
|
||
}
|
||
|
||
fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> {
|
||
let mut v: Vec<(KeyboardShortcut, &'static str)> = vec![];
|
||
for c in drawcraft_engine::command_specs() {
|
||
if let Some(sc) = c.shortcut.and_then(parse) {
|
||
v.push((sc, c.id));
|
||
}
|
||
}
|
||
for c in crate::menus::UI_COMMANDS {
|
||
if let Some(sc) = parse(c.2) {
|
||
v.push((sc, c.0));
|
||
}
|
||
}
|
||
// Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z.
|
||
v.sort_by_key(|(sc, _)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8));
|
||
v
|
||
}
|
||
|
||
pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||
if app.ui.dialog.is_some() || app.ui.palette_open {
|
||
if ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||
app.ui.dialog = None;
|
||
app.ui.palette_open = false;
|
||
}
|
||
return;
|
||
}
|
||
let typing = ctx.egui_wants_keyboard_input();
|
||
let view = app.view_info();
|
||
// Tool keys first (Enter/Escape end paths; arrows change polygon sides while dragging).
|
||
let busy = app.session.tool_busy();
|
||
for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)] {
|
||
if !typing && ctx.input(|i| i.key_pressed(k)) {
|
||
let _ = app.session.tool_key(tk, Mods::default(), view);
|
||
if k == Key::Escape && !busy {
|
||
if app.session.active().is_some_and(|d| d.isolation.is_some()) {
|
||
let _ = app.run("object.exitIsolation", json!({}));
|
||
} else if app.ui.flyout.is_some() {
|
||
app.ui.flyout = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if typing {
|
||
return;
|
||
}
|
||
if busy {
|
||
for (k, tk) in [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)] {
|
||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
|
||
let _ = app.session.tool_key(tk, Mods::default(), view);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
// Command shortcuts.
|
||
let mut fire: Option<&'static str> = None;
|
||
for (sc, id) in all_shortcuts() {
|
||
// Letter / slash keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts, X, D, /).
|
||
let plain = !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl);
|
||
if plain && (sc.logical_key.name().len() == 1 || sc.logical_key == Key::Slash) {
|
||
continue;
|
||
}
|
||
if ctx.input_mut(|i| i.consume_shortcut(&sc)) {
|
||
fire = Some(id);
|
||
break;
|
||
}
|
||
}
|
||
if let Some(id) = fire {
|
||
crate::menus::invoke(app, id, json!({}));
|
||
return;
|
||
}
|
||
// Arrow nudges (Shift = ×10, Alt = copy).
|
||
let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
|
||
for (k, dx, dy) in arrows {
|
||
let m = ctx.input(|i| i.modifiers);
|
||
if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
|
||
let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt}));
|
||
}
|
||
}
|
||
// Delete / Backspace clear the selection.
|
||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
|
||
let _ = app.run("edit.clear", json!({}));
|
||
}
|
||
// Single-key tool shortcuts (no Cmd/Ctrl/Alt).
|
||
let events: Vec<(String, Modifiers)> = ctx.input(|i| {
|
||
i.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Text(t) => Some((t.clone(), i.modifiers)),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
for (text, m) in events {
|
||
if m.command || m.alt || m.ctrl {
|
||
continue;
|
||
}
|
||
let upper = text.to_uppercase();
|
||
let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() };
|
||
match key.as_str() {
|
||
"X" => {
|
||
let _ = app.run("paint.toggleActive", json!({}));
|
||
continue;
|
||
}
|
||
"Shift+X" => {
|
||
let _ = app.run("paint.swap", json!({}));
|
||
continue;
|
||
}
|
||
"D" => {
|
||
let _ = app.run("paint.default", json!({}));
|
||
continue;
|
||
}
|
||
"/" => {
|
||
let _ = app.run("paint.none", json!({}));
|
||
continue;
|
||
}
|
||
"F" => {
|
||
let _ = app.run("view.screenMode", json!({}));
|
||
continue;
|
||
}
|
||
"Shift+F" => {
|
||
let _ = app.run("view.presentation", json!({}));
|
||
continue;
|
||
}
|
||
_ => {}
|
||
}
|
||
if let Some(t) = drawcraft_tools::catalog::tool_for_shortcut(&key).or_else(|| drawcraft_tools::catalog::tool_for_shortcut(&text)) {
|
||
app.select_tool(t.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn parses() {
|
||
let s = parse("Cmd+Shift+]").unwrap();
|
||
assert_eq!(s.logical_key, Key::CloseBracket);
|
||
assert!(s.modifiers.shift && s.modifiers.command);
|
||
assert_eq!(parse("Cmd+=").unwrap().logical_key, Key::Equals);
|
||
assert_eq!(parse("Cmd+Alt+2").unwrap().logical_key, Key::Num2);
|
||
assert_eq!(parse("F12").unwrap().logical_key, Key::F12);
|
||
assert!(parse("").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn all_registered_shortcuts_parse() {
|
||
for c in drawcraft_engine::command_specs() {
|
||
if let Some(s) = c.shortcut
|
||
&& s != "D"
|
||
&& s != "X"
|
||
&& s != "Shift+X"
|
||
&& s != "/"
|
||
{
|
||
assert!(parse(s).is_some(), "{} has unparsable shortcut {s}", c.id);
|
||
}
|
||
}
|
||
for c in crate::menus::UI_COMMANDS {
|
||
if !c.2.is_empty() && c.2 != "F" && c.2 != "Shift+F" {
|
||
assert!(parse(c.2).is_some(), "{} has unparsable shortcut {}", c.0, c.2);
|
||
}
|
||
}
|
||
}
|
||
}
|