Files
vectorcraft/crates/ui-egui/src/shortcuts.rs
T
bflatasticandClaude Opus 5.5 ab65474044 macOS: the menus live in the system menu bar, settings in the VectorCraft menu
On macOS the menus are in the system menu bar, laid out as a Mac app's (Illustrator on the Mac as
the reference), and the app settings sit in the VectorCraft menu: About, Settings ▸ (one item per
Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide
VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own
quit, so unsaved documents are asked about); they leave Edit and Help. Window gets Minimize ⌃⌘M,
Zoom and Bring All to Front; Help is the system's Help menu. Windows, Linux and the web keep the
in-window menu bar unchanged; --in-window-menus or VECTORCRAFT_IN_WINDOW_MENUS keeps it on macOS
too, and so does a menu bar AppKit refuses to build.

One menu model: the in-window menus and the native bar draw the same rows (menus::entry), and the
platform-free native_menu module lays them out, decides when to update (a cheap state hash; labels,
enabled and checked in place, a rebuild only when the structure changes) and hands key equivalents
back to egui as the key presses they were, so shortcuts, user overrides and focused text fields
behave as in the window and nothing fires twice. The muda backend (pinned =0.21.1, macOS only, no
unsafe) is apps/vectorcraft/src/mac_menu.rs, mirroring PhotoCraft's. ui.inspect reports
nativeMenuBar. Translations for the new labels in ja, es, zh-hans, zh-hant, cs and pt-br.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:45:31 +07:00

720 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Keyboard shortcuts: command shortcuts from the registry, single-key tool shortcuts, arrows,
//! and tool keys (Enter/Esc/↑/↓ while drawing).
use egui::{Key, KeyboardShortcut, Modifiers};
use serde_json::json;
use vectorcraft_engine::cmd::clipboard::{Flavour, TEXT};
use vectorcraft_tools::{Mods, ToolKey};
use crate::VectorcraftApp;
/// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere.
/// `+` and `=` name one chord key ([`Key::Equals`]): `+` is Shift+`=` on many layouts, so a
/// `Cmd+=` chord also answers `+` however it is typed (see [`consume`]).
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('+').unwrap_or(("", s)) };
let mut m = Modifiers::NONE;
for part in mods_part.split('+').filter(|p| !p.is_empty()) {
match part {
"Cmd" => m |= Modifiers::COMMAND,
"Shift" => m |= Modifiers::SHIFT,
"Alt" => m |= Modifiers::ALT,
"Ctrl" => 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,
"Delete" => Key::Delete,
"Backspace" => Key::Backspace,
"Tab" => Key::Tab,
"~" => Key::Backtick,
// A modifier alone is never a chord's key: it couldn't fire (#487).
k => Key::from_name(k).filter(|k| !is_modifier(*k))?,
};
Some(KeyboardShortcut::new(m, key))
}
/// The modifier keys, which egui also reports as key presses of their own.
pub(crate) fn is_modifier(key: Key) -> bool {
matches!(
key,
Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight
)
}
/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win), with the
/// command's params: `{}`, or `{panel}` for the panel shortcuts (`window.panel`).
pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json::Value)> {
let mut v = vec![];
for c in vectorcraft_engine::command_specs() {
if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) {
v.push((sc, c.id, json!({})));
}
}
for c in crate::menus::UI_COMMANDS {
if let Some(sc) = crate::menus::shortcut_of(c.0).and_then(parse) {
v.push((sc, c.0, json!({})));
}
}
for (panel, _) in crate::state::all_panels() {
if let Some(sc) = crate::shortcut_editor::panel_shortcut(panel).and_then(parse) {
v.push((sc, "window.panel", json!({ "panel": panel })));
}
}
// 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
}
/// Consume a press of `sc`. A `=` chord also takes [`Key::Plus`], which is how `+` arrives from
/// the numpad and from layouts where it has its own key (Shift+`=` arrives as either; extra Shift
/// is ignored).
pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut) -> bool {
i.consume_shortcut(sc)
|| (sc.logical_key == Key::Equals && i.consume_key(sc.modifiers, Key::Plus))
|| (sc.modifiers.shift && shifted(sc.logical_key).is_some_and(|k| i.consume_key(sc.modifiers, k)))
}
/// The key a shifted punctuation key arrives as: egui reports the character typed, so Cmd+Shift+[
/// comes in as Cmd+Shift+`{` (US-style layouts).
fn shifted(k: Key) -> Option<Key> {
Some(match k {
Key::OpenBracket => Key::OpenCurlyBracket,
Key::CloseBracket => Key::CloseCurlyBracket,
Key::Slash => Key::Questionmark,
Key::Semicolon => Key::Colon,
Key::Backslash => Key::Pipe,
_ => return None,
})
}
/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.)
const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste];
/// The paste command whose V chord `held` holds (Paste in Place for Cmd+Shift+V by default), else
/// Paste: egui reports every Cmd+V chord as a paste, never as its key.
pub(crate) fn paste_command(held: Modifiers) -> &'static str {
all_shortcuts()
.into_iter()
.find(|(sc, id, _)| sc.logical_key == Key::V && id.starts_with("edit.paste") && held.matches_logically(sc.modifiers))
.map_or("edit.paste", |(_, id, _)| id)
}
/// Keyboard pastes of something other than text. egui swallows a paste chord's key press and
/// sends a Paste event only when the clipboard holds text, so a paste key released without its
/// press (or a Paste event) reported was a paste of a bitmap or a PDF.
#[derive(Default)]
pub(crate) struct PasteChord {
/// Paste keys whose press was reported (typed, not pasting).
pressed: Vec<Key>,
/// A Paste event came since the last paste key was released.
pasted: bool,
}
impl PasteChord {
/// Follow one frame's `events` → the modifiers of a paste egui sent no event for (the chord's,
/// as its key is released).
pub(crate) fn textless_paste(&mut self, events: &[egui::Event]) -> Option<Modifiers> {
let mut fire = None;
for e in events {
match e {
egui::Event::Paste(_) => self.pasted = true,
// A release that comes while the window is away is never seen.
egui::Event::WindowFocused(false) => *self = Self::default(),
egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) && !self.pressed.contains(key) => self.pressed.push(*key),
egui::Event::Key { key, pressed: false, modifiers, .. } if PASTE_KEYS.contains(key) => {
match self.pressed.iter().position(|k| k == key) {
Some(i) => {
self.pressed.swap_remove(i);
}
// Unless a Paste event came (the guard takes it either way).
None if !std::mem::take(&mut self.pasted) => fire = Some(*modifiers),
None => {}
}
}
_ => {}
}
}
fire
}
}
/// End an IME composition outside the IME (a click away, the IME going away): the
/// marked text stays as typed, and the system IME is told to drop it.
pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) {
let Some(m) = app.ime_marked.take() else { return };
app.ime_discard = true;
if app.session.tool_composing() {
// Errors are already reported by the engine's interaction.
let _ = app.session.tool_text(&m, app.view_info());
}
}
/// Typed text and IME events, in the order they came, to the Type tool.
fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) {
let view = app.view_info();
// Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes
// on with it when the window comes back (measured with Kotoeri), as the tool does.
let events: Vec<egui::Event> =
ctx.input(|i| i.events.iter().filter(|e| matches!(e, egui::Event::Text(_) | egui::Event::Ime(_))).cloned().collect());
for e in events {
// Errors are already reported by the engine's interaction (a failed preview keeps it).
let _ = match e {
// Keys the IME passed through come as text only when nothing is marked.
egui::Event::Text(t) if !app.session.tool_composing() => app.session.tool_text(&t, view),
egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty());
app.session.tool_preedit(&text, active_range_chars, view)
}
// A bare line break confirms the composition (Enter): the marked text, not a newline.
egui::Event::Ime(egui::ImeEvent::Commit(t)) if t == "\n" || t == "\r" => {
keep_marked_text(app);
Ok(vec![])
}
egui::Event::Ime(egui::ImeEvent::Commit(t)) => {
app.ime_marked = None;
app.session.tool_text(&t, view)
}
// `DeleteSurrounding` isn't sent by egui-winit (desktop); the web host has no IME
// path to the canvas yet.
_ => Ok(vec![]),
};
}
}
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Followed before anything returns, so a key typed in a field isn't taken for a paste.
let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events));
// A numeric field being scrubbed has the keyboard: Escape cancels the drag.
if crate::scrub::phase(ctx) != crate::scrub::Phase::Idle {
return;
}
if app.ui.dialog.is_some() || app.ui.palette_open {
if crate::shortcut_editor::is_recording(app) {
return;
}
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();
// While an IME composes, Enter and Escape are its own (confirm / cancel).
let composing = app.session.tool_composing();
// 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 && !composing && ctx.input(|i| i.key_pressed(k)) {
// A key the tool claims (Esc with a loaded place cursor) is only the tool's.
let claimed = app.session.tool_claims_key(tk, view);
let r = app.session.tool_key(tk, Mods::default(), view);
// Enter also takes what the tool asks of the UI: Rotate, Scale, Reflect and Shear open
// their dialog with it.
if claimed || (k == Key::Enter && !busy) {
crate::canvas::apply_requests(app, r);
}
if k == Key::Escape && !busy && !claimed {
// Escape leaves Presentation Mode first: it hides the menus and panels.
if app.ui.screen_mode == 3 {
let _ = app.run("view.presentation", json!({}));
} else if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) {
let _ = app.run("object.pattern.done", json!({}));
} else 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 k == Key::Enter && !busy && !claimed {
// Enter with a selection tool opens the Move dialog, as a double-click on its button
// does.
let tool = app.session.tool_id();
if app.ui.dialog.is_none() && vectorcraft_tools::catalog::is_selection_tool(tool) {
// Nothing selected: it fails and nothing opens (the menu item is disabled then too).
let _ = crate::toolbar::open_options(app, tool);
}
// The Enter that opened a dialog isn't also its OK: dialogs take Enter, this frame too.
if app.ui.dialog.is_some() {
ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter));
}
}
}
}
if typing {
return;
}
// Tab is ours when no field has the keyboard (it shows and hides the panels, or goes to the
// Type tool): left to egui, it would also move the focus on to a field, and every key after it
// would count as typing.
if ctx.input(|i| i.key_pressed(Key::Tab)) {
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
}
// Type tool editing: text, IME composition and editing keys go to the tool.
if app.session.tool_wants_text() {
type_text(app, ctx);
// Keys of a frame that began composing are the IME's too (a Backspace that empties the
// marked text must not delete the committed character before it).
if composing || app.session.tool_composing() {
// The IME owns the keyboard until it commits: no editing keys, clipboard or shortcuts.
ctx.input_mut(|i| {
i.events.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_)))
});
return;
}
// Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text).
crate::panels::character::route_type_input(app, ctx);
// Enter was already delivered above as ToolKey::Enter (newline).
let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| consume(i, sc)));
if let Some((_, id, p)) = fire {
crate::menus::invoke(app, id, p);
}
return;
}
// Clipboard keys arrive as events, not key presses; the chord held says which paste.
let mut clip = vec![];
ctx.input_mut(|i| {
let held = i.modifiers;
i.events.retain(|e| {
let (id, text) = match e {
egui::Event::Copy => ("edit.copy", None),
egui::Event::Cut => ("edit.cut", None),
egui::Event::Paste(t) => (paste_command(held), Some(t.clone())),
_ => return true,
};
clip.push((id, text));
false
})
});
// Only the system clipboard service reads what isn't text.
if let Some(id) = textless_paste.map(paste_command)
&& app.services.system_clipboard.is_some()
{
clip.push((id, None));
}
for (id, text) in clip {
app.clipboard_in = text.map(|t| Flavour { mime: TEXT, data: t.into_bytes() });
crate::menus::invoke(app, id, json!({}));
}
if busy {
// The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)…
let arrows =
[(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)];
for (k, tk) in arrows {
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
let _ = app.session.tool_key(tk, Mods::default(), view);
}
}
// Digits (5 while dragging with the Perspective Selection tool), once per press.
let digits: Vec<u8> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, .. } => digit_of(*key),
_ => None,
})
.collect()
});
for d in digits {
let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view);
}
return;
}
// Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop:
// Delete/Backspace remove it, ←/→ nudge it; the loaded place cursor: the arrows cycle files).
let m = ctx.input(|i| i.modifiers);
for (k, tk) in [
(Key::Delete, ToolKey::Delete),
(Key::Backspace, ToolKey::Backspace),
(Key::ArrowLeft, ToolKey::Left),
(Key::ArrowRight, ToolKey::Right),
(Key::ArrowUp, ToolKey::Up),
(Key::ArrowDown, ToolKey::Down),
] {
if ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) {
let r = app.session.tool_key(tk, crate::canvas::mods(m, false), view);
crate::canvas::apply_requests(app, r);
return;
}
}
// Digits the active tool or the perspective grid takes (1–4 pick the plane while it shows).
if !(m.command || m.alt || m.ctrl) {
let digits: Vec<(u8, Key)> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, .. } => digit_of(*key).map(|d| (d, *key)),
_ => None,
})
.collect()
});
for (d, k) in digits {
if app.session.tool_claims_key(ToolKey::Digit(d), view) && ctx.input_mut(|i| i.consume_key(m, k)) {
// The digit's text is the key's too: no single-key shortcut sees it.
let text = d.to_string();
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(t) if *t == text)));
let r = app.session.tool_key(ToolKey::Digit(d), crate::canvas::mods(m, false), view);
crate::canvas::apply_requests(app, r);
return;
}
}
}
// Command shortcuts.
let mut fire = None;
for (sc, id, p) in all_shortcuts() {
// Letter / punctuation keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts,
// X, D, /, `,` and `.`).
let plain = !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl);
if plain && (sc.logical_key.name().len() == 1 || matches!(sc.logical_key, Key::Slash | Key::Comma | Key::Period)) {
continue;
}
if ctx.input_mut(|i| consume(i, &sc)) {
fire = Some((id, p));
break;
}
}
if let Some((id, p)) = fire {
crate::menus::invoke(app, id, p);
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.has_objects_or_guides()) {
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.has_objects_or_guides())
{
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() };
// A shifted punctuation character can also be written with its Shift (the Curvature tool's
// Shift+~ arrives as `~`).
let shifted = m.shift.then(|| format!("Shift+{text}"));
let lookup = |find: fn(&str) -> Option<&'static str>| find(&key).or_else(|| find(&text)).or_else(|| shifted.as_deref().and_then(find));
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default).
if let Some(id) = lookup(crate::shortcut_editor::command_for_key) {
let _ = app.run(id, json!({}));
continue;
}
if let Some(t) = lookup(crate::shortcut_editor::tool_for_key) {
app.select_tool(t);
}
}
}
/// The digit a number-row or keypad key types.
fn digit_of(k: Key) -> Option<u8> {
const DIGITS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
DIGITS.iter().position(|d| *d == k).map(|i| i as u8)
}
#[cfg(test)]
mod tests {
use super::*;
/// One headless frame delivering `events` to the shortcut handler.
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
handle(app, ui.ctx());
app.logic(ui.ctx());
});
out.textures_delta.clear();
out
}
/// Tab shows and hides the panels, and in the Type tool it types a tab, without moving the
/// keyboard focus on to a field: the keys after it (a tool letter, the next letter typed) still
/// work.
#[test]
fn tab_leaves_the_keyboard_to_the_shortcuts_and_the_type_tool() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 850.0));
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
};
let press = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
for _ in 0..3 {
run(&mut app, vec![]);
}
assert!(app.ui.dock && app.ui.toolbar);
run(&mut app, vec![press(Key::Tab)]);
run(&mut app, vec![]);
assert!(!app.ui.dock && !app.ui.toolbar, "Tab hid the panels");
run(&mut app, vec![press(Key::Tab)]);
run(&mut app, vec![]);
assert!(app.ui.dock && app.ui.toolbar, "a second Tab shows them again");
run(&mut app, vec![press(Key::P), egui::Event::Text("p".into())]);
assert_eq!(app.session.tool_id(), "pen", "and a tool letter after it works");
// The Type tool: a, Tab, b is one text.
app.select_tool("type");
let view = app.view_info();
for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] {
app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 50.0, 50.0), view).unwrap();
}
run(&mut app, vec![egui::Event::Text("a".into())]);
run(&mut app, vec![press(Key::Tab)]);
run(&mut app, vec![egui::Event::Text("b".into())]);
let doc = app.session.execute("document.inspect", &json!({})).unwrap();
assert_eq!(doc["layers"][0]["children"][0]["name"], "a\tb");
}
#[test]
fn copy_and_paste_events_use_the_system_clipboard() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
// Copy publishes SVG to the system clipboard (egui's CopyText output command).
let out = frame(&mut app, vec![egui::Event::Copy]);
let copied = out.platform_output.commands.iter().find_map(|c| match c {
egui::OutputCommand::CopyText(t) => Some(t.clone()),
_ => None,
});
let svg = copied.expect("copy publishes SVG");
assert!(svg.contains("<svg"));
// Pasting our own SVG back uses the internal clipboard; foreign SVG replaces it.
frame(&mut app, vec![egui::Event::Paste(svg)]);
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
let foreign = r##"<svg xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="5" r="5"/><circle cx="20" cy="5" r="5"/><circle cx="35" cy="5" r="5"/></svg>"##;
frame(&mut app, vec![egui::Event::Paste(foreign.into())]);
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 5);
// Plain text is not art: nothing is pasted from it (the internal clipboard is reused).
frame(&mut app, vec![egui::Event::Paste("hello".into())]);
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 8);
}
/// While a grid is dragged out, ↑/↓ change its rows and ←/→ its columns.
#[test]
fn arrows_while_dragging_a_grid_change_its_rows_and_columns() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.select_tool("rectangularGrid");
let view = app.view_info();
// Drag a grid out, pressing `keys` on the way: its (horizontal, vertical) dividers.
let grid = |app: &mut VectorcraftApp, keys: &[Key]| {
app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 80.0), view).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Drag, 300.0, 220.0), view).unwrap();
for &key in keys {
let press = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
frame(app, vec![press]);
}
app.session.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 220.0), view).unwrap();
let d = &app.session.doc().unwrap().doc;
let g = d.layers[0].children().unwrap().last().unwrap().clone();
let vectorcraft_doc::NodeKind::Group { children, .. } = &g.kind else { panic!("not a grid: {:?}", g.kind) };
let bounds: Vec<_> = children.iter().filter_map(|c| c.geometric_bounds()).collect();
(bounds.iter().filter(|b| b.height() < 0.5).count(), bounds.iter().filter(|b| b.width() < 0.5).count())
};
let (rows, columns) = grid(&mut app, &[]);
assert_eq!(grid(&mut app, &[Key::ArrowRight, Key::ArrowRight, Key::ArrowUp]), (rows + 1, columns + 2));
assert_eq!(grid(&mut app, &[Key::ArrowLeft, Key::ArrowDown]), (rows, columns + 1));
}
/// Enter opens the tool's dialog: the Move dialog for the selection tools, the tool's own for
/// Rotate, Scale, Reflect and Shear. The Enter that opens it isn't also its OK.
#[test]
fn enter_opens_the_tools_dialog_and_leaves_it_open() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
let enter = || egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
// A whole app frame: the shortcuts first, then the UI, where a dialog takes its Enter.
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)));
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
};
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
app.select_tool("selection");
frame(&mut app, vec![enter()]);
assert_eq!(kind(&app), None, "nothing is selected");
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap();
for (tool, dialog) in [
("selection", "move"),
("directSelection", "move"),
("groupSelection", "move"),
("rotate", "rotate"),
("scale", "scale"),
("reflect", "reflect"),
("shear", "shear"),
] {
app.select_tool(tool);
let before = steps(&app);
frame(&mut app, vec![enter()]);
assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: Enter opens it");
frame(&mut app, vec![]);
assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: and it stays open");
assert_eq!(steps(&app), before, "{tool}: the Enter that opened it didn't confirm it");
// The next Enter is its OK.
frame(&mut app, vec![enter()]);
assert_eq!((kind(&app), steps(&app)), (None, before + 1), "{tool}: Enter confirms");
}
app.select_tool("rectangle");
frame(&mut app, vec![enter()]);
assert_eq!(kind(&app), None, "another tool has its own Enter");
}
#[test]
fn comma_and_period_reapply_the_last_colour_and_gradient() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
app.session.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
app.session.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
let fill = |app: &VectorcraftApp| crate::panels::current_paints(app).0;
frame(&mut app, vec![egui::Event::Text(".".into())]);
assert!(matches!(fill(&app), vectorcraft_color::Paint::Gradient(_)), "`.` applies the last gradient");
frame(&mut app, vec![egui::Event::Text(",".into())]);
assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour");
}
#[test]
fn shifted_punctuation_shortcuts_work_as_typed() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let a = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone();
let b = app.session.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [a]})).unwrap();
let order =
|app: &VectorcraftApp| -> Vec<u64> { app.session.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id.0).collect() };
let (a, b) = (a.as_u64().unwrap(), b.as_u64().unwrap());
assert_eq!(order(&app), [a, b]);
let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT;
// Recorded in the Keyboard Shortcuts editor, they read as the keys (and so conflict).
assert_eq!(crate::shortcut_editor::chord_from_event(Key::CloseCurlyBracket, cmd_shift).as_deref(), Some("Cmd+Shift+]"));
assert_eq!(crate::shortcut_editor::chord_from_event(Key::Questionmark, cmd_shift).as_deref(), Some("Cmd+Shift+/"));
// Cmd+Shift+] arrives as Cmd+Shift+`}`, Cmd+Shift+[ as Cmd+Shift+`{`.
frame(&mut app, vec![press(Key::CloseCurlyBracket, cmd_shift)]);
assert_eq!(order(&app), [b, a], "Bring to Front");
frame(&mut app, vec![press(Key::OpenCurlyBracket, cmd_shift)]);
assert_eq!(order(&app), [a, b], "Send to Back");
// Cmd+Shift+/ arrives as Cmd+Shift+`?`: Search Commands.
frame(&mut app, vec![press(Key::Questionmark, cmd_shift)]);
assert!(app.ui.palette_open, "Search Commands");
app.ui.palette_open = false;
// The Curvature tool's Shift+~ arrives as the text `~` with Shift.
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), egui::Event::Text("~".into())]);
assert_eq!(app.session.tool_id(), "curvature");
}
#[test]
fn plus_typed_any_way_zooms_in() {
assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key");
assert_eq!(crate::shortcut_editor::chord_from_event(Key::Plus, Modifiers::COMMAND).as_deref(), Some("Cmd+="), "a recorded `+` too");
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let zoom = |app: &mut VectorcraftApp| app.view_mut().unwrap().zoom;
let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT;
// `=`, Shift+`=` (reported as `=` or as `+`), and the numpad `+` / a layout's own `+` key.
for (key, m) in [(Key::Equals, Modifiers::COMMAND), (Key::Equals, cmd_shift), (Key::Plus, cmd_shift), (Key::Plus, Modifiers::COMMAND)] {
let before = zoom(&mut app);
frame(&mut app, vec![press(key, m)]);
assert!(zoom(&mut app) > before, "{key:?} with {m:?} zooms in");
}
let before = zoom(&mut app);
frame(&mut app, vec![press(Key::Minus, Modifiers::COMMAND)]);
assert!(zoom(&mut app) < before, "Cmd+- zooms out");
}
#[test]
fn digit_keys_reach_a_dragging_tool_once_per_press() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
app.session.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone();
app.session.execute("perspective.attach", &json!({"ids": [id], "plane": "right"})).unwrap();
app.select_tool("perspectiveSelection");
let v = app.view_info();
let c =
app.session.doc().unwrap().doc.node(vectorcraft_engine::doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().center();
app.session.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 30.0, c.y), v).unwrap();
let key = |repeat| egui::Event::Key { key: Key::Num5, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE };
// A held key repeats: only its first press toggles.
frame(&mut app, vec![key(false), key(true)]);
let preview = app.session.doc().unwrap().interaction.as_ref().unwrap().preview.clone().unwrap();
assert_eq!((preview.0.as_str(), &preview.1["perpendicular"]), ("perspective.move", &json!(true)));
assert_eq!(digit_of(Key::Num0), Some(0));
assert_eq!(digit_of(Key::A), None);
}
#[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 vectorcraft_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);
}
}
}
}