mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 00:40:57 -06:00
2409 lines
131 KiB
Rust
2409 lines
131 KiB
Rust
//! Keyboard shortcuts: command shortcuts from the registry, single-key tool shortcuts, arrows,
|
||
//! and tool keys (Enter/Esc/↑/↓ while drawing).
|
||
|
||
use craft_keymap::egui::Hit;
|
||
use craft_keymap::{Active, CommandRef};
|
||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||
use serde_json::json;
|
||
use vectorcraft_engine::cmd::clipboard::{Flavour, TEXT};
|
||
use vectorcraft_engine::{Result as EngineResult, UiRequest, ViewInfo};
|
||
use vectorcraft_tools::{Mods, SurroundingUnit, 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.
|
||
pub fn parse(s: &str) -> Option<KeyboardShortcut> {
|
||
if s.is_empty() {
|
||
return None;
|
||
}
|
||
// A bare `+` (the Add Anchor Point tool's key) is the key itself, not a separator with
|
||
// nothing on either side.
|
||
let (mods_part, key_part) = if s == "+" {
|
||
("", "+")
|
||
} else 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: `paste_command` takes the first V chord the held keys
|
||
// match logically, so Cmd+Shift+V isn't taken by Cmd+V.
|
||
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
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Does every press of `key` this frame hold a chord with modifiers that a command shortcut is bound
|
||
/// to, matched as [`consume`] matches it? `cmd_only`: only chords with Cmd count. False when `key`
|
||
/// isn't pressed.
|
||
fn runs_a_command(i: &egui::InputState, key: Key, cmd_only: bool) -> bool {
|
||
let presses: Vec<Modifiers> = i
|
||
.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key => Some(*modifiers),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let shortcuts = all_shortcuts();
|
||
let bound = |m: &Modifiers| {
|
||
shortcuts
|
||
.iter()
|
||
.any(|(sc, ..)| sc.logical_key == key && (sc.modifiers.command || (!cmd_only && sc.modifiers.any())) && m.matches_logically(sc.modifiers))
|
||
};
|
||
!presses.is_empty() && presses.iter().all(bound)
|
||
}
|
||
|
||
/// Whether a key press runs a shortcut of the keymap in effect, the user's own included. The web
|
||
/// build asks this of every Cmd and Ctrl press, before egui sees it, to cancel what the browser
|
||
/// would also do with it (zoom the page on `Cmd+=`, undo on `Cmd+Z`, go back on `Cmd+[`).
|
||
/// `key` and `code` are the browser's names for the character typed and the key's position, which
|
||
/// egui reads with [`Key::from_name`]; `modifiers` as egui reports them. The press is matched as
|
||
/// the dispatcher matches it ([`dispatch`]) on a layout about which nothing is known: what typing
|
||
/// has shown of the layout is left out, and so are double presses.
|
||
pub fn runs_a_shortcut(key: &str, code: &str, modifiers: Modifiers) -> bool {
|
||
let physical_key = Key::from_name(code);
|
||
let Some(key) = Key::from_name(key).or(physical_key) else { return false };
|
||
let press = egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers };
|
||
let Some(input) = craft_keymap::KeyInput::from_egui(&press, crate::keymap::platform()) else { return false };
|
||
let mirror = crate::shortcut_editor::current();
|
||
craft_keymap::Layout::default().candidates(&input).iter().any(|c| mirror.keymap.lookup(c, false, &Active::NONE) != craft_keymap::Lookup::None)
|
||
}
|
||
|
||
/// Give a copy or cut chord held with more modifiers its key back when it has a shortcut of its own:
|
||
/// egui reports every Cmd+C and Cmd+X chord as Copy or Cut, Shift and Alt held too, so Cmd+Shift+C
|
||
/// (Align center) copied instead (#1940).
|
||
fn unclip_chords(i: &mut egui::InputState) {
|
||
if !i.events.iter().any(|e| matches!(e, egui::Event::Copy | egui::Event::Cut)) {
|
||
return;
|
||
}
|
||
let (held, shortcuts) = (i.modifiers, all_shortcuts());
|
||
for e in &mut i.events {
|
||
let (key, own) = match e {
|
||
egui::Event::Copy => (Key::C, "edit.copy"),
|
||
egui::Event::Cut => (Key::X, "edit.cut"),
|
||
_ => continue,
|
||
};
|
||
if shortcuts.iter().any(|(sc, id, _)| sc.logical_key == key && *id != own && held.matches_exact(sc.modifiers)) {
|
||
*e = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: held };
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox
|
||
/// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a
|
||
/// Ctrl+Alt+L press and then the text "ł", which the Type tool types. Without the press no command
|
||
/// chord runs for it: neither Cmd+Alt+L nor Cmd+L, which egui matches with the extra Alt held.
|
||
fn drop_typing_presses(i: &mut egui::InputState) {
|
||
let next = i.events.iter().skip(1).map(Some).chain([None]);
|
||
let typed: Vec<bool> = i
|
||
.events
|
||
.iter()
|
||
.zip(next)
|
||
.map(|(e, next)| {
|
||
matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.ctrl && modifiers.alt)
|
||
&& matches!(next, Some(egui::Event::Text(t)) if !t.is_empty())
|
||
})
|
||
.collect();
|
||
let mut typed = typed.into_iter();
|
||
i.events.retain(|_| !typed.next().unwrap_or(false));
|
||
}
|
||
|
||
/// 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(Clone, 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());
|
||
}
|
||
}
|
||
|
||
/// Cmd/Ctrl chords are shortcuts (Create Outlines, Bold, …): their letter is never typed into the
|
||
/// text if a host still emits `Text` for them (#2094). Alt is left to type: Option+key on macOS
|
||
/// and AltGr (Ctrl+Alt) on Windows keyboards type characters such as é, ø and @.
|
||
fn is_shortcut_chord(m: egui::Modifiers) -> bool {
|
||
(m.command || m.ctrl) && !m.alt
|
||
}
|
||
|
||
enum Seq {
|
||
Text(String),
|
||
Preedit(String, Option<std::ops::Range<usize>>),
|
||
Commit(String),
|
||
Erase(ToolKey, Mods),
|
||
/// An input method replaces text around the composition (upstream's character counts or
|
||
/// the fork's exact UTF-8 byte counts), then commits.
|
||
DeleteSurrounding(usize, usize, SurroundingUnit),
|
||
// The actual pointer/key still belongs to its existing handler. Its position in this
|
||
// sequence must nevertheless interrupt an old composition before later terminal input.
|
||
InterruptPointer,
|
||
InterruptNavigation,
|
||
InterruptCommand,
|
||
ResetCompleted(egui::ImeResetId),
|
||
}
|
||
|
||
/// Typed text and IME events, in the order they came, to the Type tool.
|
||
fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||
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 shortcut_mods = ctx.input(|i| is_shortcut_chord(i.modifiers));
|
||
// A frame that began composing keeps its editing keys for the IME: a Backspace that empties the
|
||
// marked text must not then delete the committed character before it.
|
||
let was_composing = app.session.tool_composing();
|
||
// Text, IME events and plain Backspace/Delete are taken together, in the order they arrived, so
|
||
// a keyboard-hook IME (UniKey/OpenKey) whose tone key sends Backspace(s) and then re-injects
|
||
// characters in one frame interleaves correctly. Taken as two passes (all text, then the keys)
|
||
// the deletes land after the inserts and the diacritics are lost. Each handled event is removed
|
||
// so `route_type_input` below does not process the same Backspace/Delete again.
|
||
let seq = type_sequence(ctx);
|
||
let viewport = ctx.viewport_id();
|
||
let mut old_composition_retired = false;
|
||
for e in seq {
|
||
// Before the UI, a changed composition or guard is settled `pending`: a token bound here
|
||
// could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to
|
||
// the stock descriptor. The post-UI finalizer binds the confirmed replacement.
|
||
crate::ime::sync_incoming_before_ui(app, ctx);
|
||
if let Seq::ResetCompleted(request) = &e {
|
||
old_composition_retired |= crate::ime::complete(app, ctx, *request);
|
||
continue;
|
||
}
|
||
if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) {
|
||
match &e {
|
||
// Accepted old payloads precede the fence even when their terminal arrives.
|
||
// None of these events proves the local reset has reached its boundary.
|
||
Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue,
|
||
_ => {}
|
||
}
|
||
}
|
||
// A pointer/navigation/command notice has not reached its real handler yet. Text and
|
||
// erases after it belong to the old caret, not to the destination it will establish.
|
||
if crate::ime::deferred(app, ctx) {
|
||
match &e {
|
||
Seq::Text(_) | Seq::Erase(..) => continue,
|
||
_ => {}
|
||
}
|
||
}
|
||
let preedit_empty = match &e {
|
||
Seq::Preedit(text, _) => Some(text.is_empty()),
|
||
_ => None,
|
||
};
|
||
let terminal_commit = matches!(&e, Seq::Commit(_));
|
||
let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..));
|
||
// Errors are already reported by the engine's interaction (a failed preview keeps it).
|
||
let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired);
|
||
if result.is_ok() && from_ime {
|
||
if terminal_commit {
|
||
app.ime_compositions.remove(&viewport);
|
||
} else {
|
||
crate::ime::remember_incoming(app, ctx, preedit_empty);
|
||
}
|
||
}
|
||
}
|
||
old_composition_retired
|
||
}
|
||
|
||
/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived.
|
||
fn type_sequence(ctx: &egui::Context) -> Vec<Seq> {
|
||
let mut seq = vec![];
|
||
ctx.input_mut(|i| {
|
||
// Move the events out instead of cloning their strings: a keyboard hook that reinjects a
|
||
// batch keeps the batch in `seq` only, not in both the input list and a second copy.
|
||
let mut kept = Vec::with_capacity(i.events.len());
|
||
// InputState's held flag describes the end of the batch. Recover its initial state from
|
||
// the first transition, so a move before a later press cannot cancel an earlier commit.
|
||
let mut primary_down = i
|
||
.events
|
||
.iter()
|
||
.find_map(|e| match e {
|
||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some(!pressed),
|
||
_ => None,
|
||
})
|
||
.unwrap_or_else(|| i.pointer.primary_down());
|
||
for e in std::mem::take(&mut i.events) {
|
||
match e {
|
||
egui::Event::Text(t) => seq.push(Seq::Text(t)),
|
||
egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
|
||
seq.push(Seq::Preedit(text, active_range_chars));
|
||
}
|
||
egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)),
|
||
egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => {
|
||
seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars));
|
||
}
|
||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => {
|
||
seq.push(Seq::DeleteSurrounding(before_bytes, after_bytes, SurroundingUnit::Bytes));
|
||
}
|
||
egui::Event::ImeResetCompleted { request_id } => seq.push(Seq::ResetCompleted(request_id)),
|
||
// X11/Wayland PRIMARY middle-click: text at the pointer, inserted as typed text (never
|
||
// routed as a key or shortcut).
|
||
egui::Event::MiddleClickPaste { text, .. } => seq.push(Seq::Text(text)),
|
||
// Only unmodified Backspace/Delete interleave with injected text; word-delete and other
|
||
// modified forms stay with `route_type_input`.
|
||
egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. }
|
||
if !m.command && !m.alt && !m.ctrl =>
|
||
{
|
||
let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete };
|
||
seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() }));
|
||
}
|
||
e @ egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => {
|
||
primary_down = pressed;
|
||
if pressed {
|
||
seq.push(Seq::InterruptPointer);
|
||
}
|
||
kept.push(e);
|
||
}
|
||
e @ egui::Event::PointerMoved(_) => {
|
||
if primary_down {
|
||
seq.push(Seq::InterruptPointer);
|
||
}
|
||
kept.push(e);
|
||
}
|
||
e @ egui::Event::Key { key, pressed: true, modifiers, .. }
|
||
if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End)
|
||
|| key == Key::A && modifiers.command =>
|
||
{
|
||
seq.push(Seq::InterruptNavigation);
|
||
kept.push(e);
|
||
}
|
||
e @ egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(modifiers) => {
|
||
seq.push(Seq::InterruptCommand);
|
||
kept.push(e);
|
||
}
|
||
e @ (egui::Event::Cut | egui::Event::Paste(_)) => {
|
||
seq.push(Seq::InterruptCommand);
|
||
kept.push(e);
|
||
}
|
||
other => kept.push(other),
|
||
}
|
||
}
|
||
i.events = kept;
|
||
});
|
||
seq
|
||
}
|
||
|
||
/// Apply one sequence item to the active tool; the caller filters stale/deferred events and
|
||
/// turns a successful IME mutation into its receipt.
|
||
fn apply_type_event(
|
||
app: &mut VectorcraftApp,
|
||
ctx: &egui::Context,
|
||
view: ViewInfo,
|
||
e: Seq,
|
||
shortcut_mods: bool,
|
||
was_composing: bool,
|
||
old_composition_retired: &mut bool,
|
||
) -> EngineResult<Vec<UiRequest>> {
|
||
match e {
|
||
Seq::InterruptPointer => {
|
||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||
Ok(vec![])
|
||
}
|
||
Seq::InterruptNavigation => {
|
||
// Active composition owns its navigation keys. Once cleared, application caret
|
||
// navigation interrupts its still-pending terminal event, even in this batch.
|
||
if !app.session.tool_composing() {
|
||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||
}
|
||
Ok(vec![])
|
||
}
|
||
Seq::InterruptCommand => {
|
||
if crate::ime::blocked(app, ctx) {
|
||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||
}
|
||
Ok(vec![])
|
||
}
|
||
Seq::ResetCompleted(_) => Ok(vec![]),
|
||
// Keys the IME passed through come as text only when nothing is marked. This is an
|
||
// application edit (also a hook IME's reinjected characters): `Other` dominates.
|
||
Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => {
|
||
let target_before = crate::ime::target(&mut app.session, view);
|
||
let before = crate::ime::stamp(&mut app.session, view);
|
||
let r = app.session.tool_text(&t, view);
|
||
if r.is_ok() {
|
||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||
}
|
||
r
|
||
}
|
||
Seq::Text(_) => Ok(vec![]),
|
||
// A pre-edit is not committed text: it changes neither the outgoing snapshot (the
|
||
// engine query removes it) nor the cause.
|
||
Seq::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.
|
||
// It is an accepted IME commit of that marked text when the tool inserts the marked
|
||
// text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an
|
||
// application edit instead.
|
||
Seq::Commit(t) if t == "\n" || t == "\r" => {
|
||
let target_before = crate::ime::target(&mut app.session, view);
|
||
let before = crate::ime::stamp(&mut app.session, view);
|
||
let composing = app.session.tool_composing();
|
||
let verbatim = app.session.tool_ime_session_matches(view)
|
||
&& match app.ime_marked.as_deref() {
|
||
Some(m) => app.session.tool_text_is_verbatim(m, view),
|
||
None => true,
|
||
};
|
||
keep_marked_text(app);
|
||
if composing {
|
||
if verbatim {
|
||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||
} else {
|
||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||
}
|
||
}
|
||
Ok(vec![])
|
||
}
|
||
// An accepted IME commit: stamp the bounded committed context immediately before and
|
||
// after, so only a real mutation (not a no-op, rejection or failure) earns the receipt.
|
||
// A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an
|
||
// application edit and must never earn `InputMethod`.
|
||
Seq::Commit(t) => {
|
||
app.ime_marked = None;
|
||
let target_before = crate::ime::target(&mut app.session, view);
|
||
let before = crate::ime::stamp(&mut app.session, view);
|
||
// A document edit since the composition means this commit starts a fresh typing
|
||
// session: that reset is application-side, so it cannot earn `InputMethod`.
|
||
let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view);
|
||
let r = app.session.tool_text(&t, view);
|
||
if r.is_ok() {
|
||
if verbatim {
|
||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||
} else {
|
||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||
}
|
||
}
|
||
r
|
||
}
|
||
// While composing (or in a frame that began composing), editing keys belong to the IME.
|
||
Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => {
|
||
// Mirror `route_type_input`/`text_key`: surface an erase error in the status bar
|
||
// instead of discarding it, and report the successful edit as application-origin.
|
||
let target_before = crate::ime::target(&mut app.session, view);
|
||
let before = crate::ime::stamp(&mut app.session, view);
|
||
let r = app.session.tool_key(tk, m, view);
|
||
match &r {
|
||
Err(e) => app.status(e.to_string()),
|
||
Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before),
|
||
}
|
||
r
|
||
}
|
||
Seq::Erase(..) => Ok(vec![]),
|
||
// Deleting around the composition starts the same typing session as the commit that
|
||
// follows, so the replacement is one undo step; an error reaches the status bar. The
|
||
// receipt covers only this accepted deletion.
|
||
Seq::DeleteSurrounding(before, after, unit) => {
|
||
let target_before = crate::ime::target(&mut app.session, view);
|
||
let stamp_before = crate::ime::stamp(&mut app.session, view);
|
||
let r = app.session.tool_ime_delete_surrounding(before, after, unit, view);
|
||
match &r {
|
||
Err(e) => app.status(e.to_string()),
|
||
Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before),
|
||
}
|
||
r
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Process one frame's events against the keymap at most once per context, viewport and frame, so
|
||
/// reruns cannot fire a shortcut or tool key twice.
|
||
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||
// One UI pass takes a frame's events. A discarded rerun and a test helper that also calls
|
||
// `logic` see the same events again; they must not fire a shortcut or tool key twice. The
|
||
// key includes the context (a restarted host starts at frame 0) and the viewport (frame
|
||
// numbers are per viewport), and remembers every key taken this host iteration: an
|
||
// interleaved root/auxiliary pass cannot make an earlier frame look new again.
|
||
let frame = (crate::graphics::context_tag(ctx), ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id()));
|
||
if !crate::take_frame_once(&mut app.shortcuts_frames, frame) {
|
||
return;
|
||
}
|
||
if !ctx.input(|input| input.focused) && !(app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT) {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
return;
|
||
}
|
||
// Taken before anything returns, so a press in a field or a dialog ends a pair of Escapes.
|
||
let mut escape_pair = escape_press(ctx);
|
||
// 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 {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
return;
|
||
}
|
||
if app.ui.dialog.is_some() || app.ui.palette_open {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) {
|
||
// Escape first restores this rotation gesture. The settings stay open.
|
||
return;
|
||
}
|
||
if crate::shortcut_editor::is_recording(app) {
|
||
return;
|
||
}
|
||
// Escape closes a dropdown list or popover open in the dialog (each closes itself on it) and
|
||
// leaves the dialog open.
|
||
let popup = app.ui.dialog.as_ref().is_some_and(|d| egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d));
|
||
if !popup && ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||
crate::dialogs::cancel(app);
|
||
app.ui.palette_open = false;
|
||
}
|
||
return;
|
||
}
|
||
if app.ui.screen_mode == 3 {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
presentation_keys(app, ctx, escape_pair);
|
||
return;
|
||
}
|
||
let typing = ctx.egui_wants_keyboard_input();
|
||
// Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already).
|
||
if app.ui.tools_drawer && !typing && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
|
||
app.ui.tools_drawer = false;
|
||
}
|
||
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 chord a command is bound to (Cmd+Enter) runs only that command, in the shortcuts
|
||
// below, also when the tool claims the key. While the Type tool edits text only Cmd
|
||
// chords run one, and while another tool is busy none does.
|
||
let text = app.session.tool_wants_text();
|
||
if (text || !busy) && ctx.input(|i| runs_a_command(i, k, text)) {
|
||
continue;
|
||
}
|
||
// 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 claimed {
|
||
ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
|
||
}
|
||
if k == Key::Escape && !busy && !claimed {
|
||
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.session.active().is_some_and(|d| d.global_edit.is_some()) {
|
||
let _ = app.run("select.globalEdit", json!({}));
|
||
} else if app.ui.flyout.is_some() {
|
||
app.ui.flyout = None;
|
||
} else if escape_pair.is_some_and(|second| escape_resets_rotation(app, ctx, second)) {
|
||
// Escape pressed twice resets a rotated view (with the Rotate View tool, once).
|
||
let _ = app.run("view.rotateReset", json!({}));
|
||
escape_pair = None;
|
||
}
|
||
}
|
||
// An Escape that ended something can start a pair too. One that closed a popup cannot.
|
||
if k == Key::Escape && escape_pair.is_some() && !egui::Popup::is_any_open(ctx) {
|
||
let now = ctx.input(|i| i.time);
|
||
ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now));
|
||
}
|
||
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 {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
return;
|
||
}
|
||
ctx.input_mut(unclip_chords);
|
||
// 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));
|
||
}
|
||
// While the Type tool edits text, a character typed with AltGr runs no command chord.
|
||
if app.session.tool_wants_text() {
|
||
ctx.input_mut(drop_typing_presses);
|
||
}
|
||
// Type tool editing: text, IME composition and editing keys go to the tool.
|
||
if app.session.tool_wants_text() {
|
||
// Route-level loss (no current target or caret geometry) retires a live guard before any
|
||
// marker in this pass can be considered. The finalizer may re-arm on a later activation.
|
||
crate::ime::suspend_if_route_lost(app, ctx);
|
||
let old_composition_retired = 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 && !old_composition_retired || 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;
|
||
}
|
||
// A deferred pointer/navigation notice has not established its destination yet. Its
|
||
// clipboard suffix must not cut or paste at the old caret; only the notice's own actual
|
||
// handler (kept in the raw vector) may run.
|
||
if crate::ime::deferred(app, ctx) {
|
||
ctx.input_mut(|input| {
|
||
input.events.retain(|event| match event {
|
||
egui::Event::Cut | egui::Event::Paste(_) => false,
|
||
egui::Event::Key { key: egui::Key::X | egui::Key::V, pressed: true, modifiers, .. }
|
||
if modifiers.command && !modifiers.shift && !modifiers.alt =>
|
||
{
|
||
false
|
||
}
|
||
_ => true,
|
||
})
|
||
});
|
||
}
|
||
// 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). Only chords with the
|
||
// command key run commands: the rest type or edit.
|
||
let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd);
|
||
for c in hits {
|
||
run_hit(app, ctx, &c);
|
||
}
|
||
settings_on_a_mac(app, ctx, mac(ctx));
|
||
return;
|
||
}
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
// A clipboard image (eframe's `clipboard` feature, 0.37): the host's text paste path never
|
||
// carries it. With the app's rich-format `SystemClipboard`, it is only the signal that the
|
||
// clipboard holds no text — the next tick invokes Paste, whose `adopt_system_clipboard` reads
|
||
// the formats once (never paste twice). Without one (the web/simple host), turn the pixels
|
||
// into a PNG flavour and paste that.
|
||
let image_paste = app.services.system_clipboard.is_none();
|
||
let mut pasted_image: Option<Vec<u8>> = None;
|
||
let mut read_image_formats = false;
|
||
// `MiddleClickPaste` is deliberately left in `i.events`: the Type tool above already took it,
|
||
// and otherwise egui's text field under the pointer (focused or not) pastes it later in this
|
||
// pass. It is not a shortcut.
|
||
ctx.input_mut(|i| {
|
||
i.events.retain(|e| match e {
|
||
egui::Event::PasteImage(image) => {
|
||
if image_paste {
|
||
if pasted_image.is_none() {
|
||
let pixels: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||
let rendered = vectorcraft_render::Rendered {
|
||
width: u32::try_from(image.size[0]).unwrap_or(0),
|
||
height: u32::try_from(image.size[1]).unwrap_or(0),
|
||
pixels,
|
||
};
|
||
// A renderer failure leaves the paste as the no-op an empty flavour gets.
|
||
pasted_image = rendered.to_png().ok();
|
||
}
|
||
} else {
|
||
read_image_formats = true;
|
||
}
|
||
false
|
||
}
|
||
_ => true,
|
||
})
|
||
});
|
||
if read_image_formats {
|
||
// A text paste in the same frame wins, as it does for a clipboard with text and a picture:
|
||
// the paste chord path below reads the text. An image-only clipboard is read here.
|
||
if pasted_image.is_none() && textless_paste.is_none() && !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Paste(_)))) {
|
||
// Remember the chord as it was when the image was pasted: the next `tick` runs in a
|
||
// later input pass whose modifiers need not describe this paste. The request also
|
||
// schedules that tick; nothing else may be coming to run it.
|
||
app.clipboard_image_request = Some(ctx.input(|i| i.modifiers));
|
||
ctx.request_repaint();
|
||
}
|
||
}
|
||
if let Some(png) = pasted_image {
|
||
let held = ctx.input(|i| i.modifiers);
|
||
app.clipboard_in = Some(Flavour { mime: vectorcraft_engine::cmd::clipboard::PNG, data: png });
|
||
crate::menus::invoke(app, paste_command(held), json!({}));
|
||
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);
|
||
}
|
||
// The tool gets each letter it takes (E while dragging a selection marquee) once per press,
|
||
// without Cmd, Ctrl or Alt. The key is the one egui reports, which on a layout without Latin
|
||
// letters is the key at that position on a US layout.
|
||
let letters: Vec<char> = ctx.input(|i| {
|
||
i.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. }
|
||
if !(modifiers.command || modifiers.alt || modifiers.ctrl) =>
|
||
{
|
||
letter_of(*key)
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
for c in letters {
|
||
if app.session.tool_claims_key(ToolKey::Letter(c), view) {
|
||
let _ = app.session.tool_key(ToolKey::Letter(c), 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);
|
||
// Keyboard actions can change the Artboard tool's active board too. Keep the
|
||
// navigator aligned with its valid survivor, as pointer dispatch does.
|
||
if r.is_ok()
|
||
&& app.session.tool_id() == "artboard"
|
||
&& let Some(i) = app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok())
|
||
&& app.session.active().is_some_and(|d| i < d.doc.artboards.len())
|
||
&& let Some(v) = app.view_mut()
|
||
{
|
||
v.artboard = i;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
// `[` and `]` the active tool takes (the Blob Brush's and the Eraser's size, the Symbolism
|
||
// tools' diameter, with Shift their intensity). Read as typed, so
|
||
// layouts where a bracket takes Option or AltGr count too; `{` and `}` are Shift's.
|
||
if !(m.command || m.ctrl) {
|
||
let typed: Vec<(String, ToolKey, bool)> = ctx.input(|i| {
|
||
i.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Text(t) => match t.as_str() {
|
||
"[" => Some((t.clone(), ToolKey::BracketLeft, false)),
|
||
"]" => Some((t.clone(), ToolKey::BracketRight, false)),
|
||
"{" => Some((t.clone(), ToolKey::BracketLeft, true)),
|
||
"}" => Some((t.clone(), ToolKey::BracketRight, true)),
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
let mut taken = false;
|
||
for (text, key, shift) in typed {
|
||
if app.session.tool_claims_key(key, view) {
|
||
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(t) if *t == text)));
|
||
let r = app.session.tool_key(key, Mods { shift, ..Mods::default() }, view);
|
||
crate::canvas::apply_requests(app, r);
|
||
taken = true;
|
||
}
|
||
}
|
||
if taken {
|
||
return;
|
||
}
|
||
}
|
||
// Command, panel and tool shortcuts. Keys that type a character without Cmd/Alt/Ctrl are
|
||
// handled below as text (tool shortcuts, X, D, /, `,` and `.`).
|
||
let hits = dispatch(app, ctx, |hit| !(hit.trigger.chord.is_plain() && types_text(hit.trigger.chord.key)));
|
||
let ran = !hits.is_empty();
|
||
for c in hits {
|
||
run_hit(app, ctx, &c);
|
||
}
|
||
if settings_on_a_mac(app, ctx, mac(ctx)) || ran {
|
||
return;
|
||
}
|
||
// Alt+arrows on selected type objects step their spacing as the Type tool does (Cmd/Ctrl:
|
||
// five steps); Alt+arrow copies and nudges every other selection.
|
||
let m = ctx.input(|i| i.modifiers);
|
||
if m.alt
|
||
&& app.session.active().is_some_and(|st| {
|
||
st.selection.guides.is_empty()
|
||
&& !st.selection.objects.is_empty()
|
||
&& st.selection.objects.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_))))
|
||
})
|
||
{
|
||
let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)];
|
||
for (key, tool_key) in keys {
|
||
if ctx.input_mut(|i| i.consume_key(m, key)) {
|
||
if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false) {
|
||
let by = if m.command { by * 5.0 } else { by };
|
||
let _ = app.run("type.step", json!({"attribute": attribute, "by": by}));
|
||
}
|
||
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). With a layout without Latin letters (Persian,
|
||
// Arabic, Russian, Greek…) the key's position stands in for what it types (#793).
|
||
let events: Vec<(String, Modifiers)> = ctx.input(|i| {
|
||
let mut physical = None;
|
||
i.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Key { physical_key, pressed: true, .. } => {
|
||
physical = *physical_key;
|
||
None
|
||
}
|
||
egui::Event::Text(t) => Some((latin(t, physical), i.modifiers)),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
let keymap = crate::shortcut_editor::current();
|
||
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 hits: Vec<CommandRef> = [Some(key), Some(text), shifted]
|
||
.into_iter()
|
||
.flatten()
|
||
.filter_map(|t| crate::keymap::vc_chord(&t).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)))
|
||
.collect();
|
||
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before
|
||
// tools.
|
||
let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue };
|
||
match hit.id.strip_prefix("tool:") {
|
||
Some(tool) => app.select_tool(tool),
|
||
None if hit.params.is_null() => {
|
||
let _ = app.run(&hit.id, json!({}));
|
||
}
|
||
None => crate::menus::invoke(app, &hit.id, hit.params.clone()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Runs this frame's key presses against the keymap in effect; `accept` declines a hit, leaving
|
||
/// its press (and the text it typed) to what follows.
|
||
fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&Hit) -> bool) -> Vec<CommandRef> {
|
||
let mirror = crate::shortcut_editor::current();
|
||
let dispatcher = &mut app.dispatcher;
|
||
ctx.input_mut(|i| {
|
||
// Shift+`=` types `+`: as the Plus key it also reaches a `=` chord held with Shift
|
||
// (Cmd+Shift+= zooms in as Cmd+= does).
|
||
for e in &mut i.events {
|
||
if let egui::Event::Key { key, modifiers, .. } = e
|
||
&& *key == Key::Equals
|
||
&& modifiers.shift
|
||
{
|
||
*key = Key::Plus;
|
||
}
|
||
}
|
||
dispatcher.dispatch_filtered(i, &mirror.keymap, &Active::NONE, accept).into_iter().map(|h| h.command).collect()
|
||
})
|
||
}
|
||
|
||
/// Runs what a shortcut is bound to: a tool, a panel (`window.panel {panel}`) or a command.
|
||
fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) {
|
||
if let Some(tool) = c.id.strip_prefix("tool:") {
|
||
app.select_tool(tool);
|
||
} else if c.id == "select.all" && crate::panels::artboards::selects_all(app, ctx) {
|
||
// With the Artboard tool, Select All's keys after a click on an Artboards panel row select
|
||
// every artboard instead of the art.
|
||
crate::panels::artboards::select_all(app);
|
||
} else {
|
||
let p = if c.params.is_null() { json!({}) } else { c.params.clone() };
|
||
crate::menus::invoke(app, &c.id, p);
|
||
}
|
||
}
|
||
|
||
/// Keys that type a character: letters, digits and punctuation (not Space).
|
||
fn types_text(key: craft_keymap::Key) -> bool {
|
||
key.name().chars().count() == 1
|
||
}
|
||
|
||
/// Whether the app runs on a Mac (on the web, in a browser on a Mac).
|
||
fn mac(ctx: &egui::Context) -> bool {
|
||
ctx.os() == egui::os::OperatingSystem::Mac
|
||
}
|
||
|
||
/// On a Mac, Cmd+, opens Settings as in every Mac app (#663), beside Preferences' own shortcut,
|
||
/// when no shortcut took the press this frame. Runs after [`dispatch`]. `true` when it ran.
|
||
fn settings_on_a_mac(app: &mut VectorcraftApp, ctx: &egui::Context, mac: bool) -> bool {
|
||
if !mac {
|
||
return false;
|
||
}
|
||
let pressed = ctx.input_mut(|i| {
|
||
let at = i.events.iter().position(
|
||
|e| matches!(e, egui::Event::Key { key: Key::Comma, pressed: true, modifiers, .. } if modifiers.matches_exact(Modifiers::COMMAND)),
|
||
);
|
||
at.map(|at| i.events.remove(at)).is_some()
|
||
});
|
||
if pressed {
|
||
crate::menus::invoke(app, "edit.preferences", json!({}));
|
||
}
|
||
pressed
|
||
}
|
||
|
||
/// The second press of Escape pressed twice comes less than this many seconds after the first.
|
||
const ESCAPE_PAIR_SECONDS: f64 = 1.0;
|
||
|
||
/// The key of the last counted Escape's time in egui's temporary data.
|
||
fn escape_pair_id() -> egui::Id {
|
||
egui::Id::unique("escape-pair")
|
||
}
|
||
|
||
/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or
|
||
/// has another key or pointer button press as well (a key repeat counts as another press, and the
|
||
/// modifier keys do not count). Otherwise, whether the last counted Escape came less than
|
||
/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the
|
||
/// last counted Escape's time.
|
||
fn escape_press(ctx: &egui::Context) -> Option<bool> {
|
||
let (escape, other, now) = ctx.input(|i| {
|
||
let (mut escape, mut other) = (false, false);
|
||
for e in &i.events {
|
||
match e {
|
||
egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true,
|
||
egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true,
|
||
egui::Event::PointerButton { pressed: true, .. } => other = true,
|
||
_ => {}
|
||
}
|
||
}
|
||
(escape, other, i.time)
|
||
});
|
||
if !escape && !other {
|
||
return None;
|
||
}
|
||
let counted = ctx.data_mut(|d| d.remove_temp::<f64>(escape_pair_id()));
|
||
(escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS))
|
||
}
|
||
|
||
/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open,
|
||
/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the
|
||
/// Rotate View tool is chosen.
|
||
fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool {
|
||
app.view().is_some_and(|v| v.rotation != 0.0)
|
||
&& (second || app.session.tool_id() == "rotateView")
|
||
&& !egui::Popup::is_any_open(ctx)
|
||
&& !crate::canvas::ruler_origin_dragging(ctx)
|
||
}
|
||
|
||
/// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to
|
||
/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it
|
||
/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on
|
||
/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]).
|
||
fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option<bool>) {
|
||
let (mut leave, mut steps) = (false, vec![]);
|
||
ctx.input_mut(|i| {
|
||
i.events.retain(|e| match e {
|
||
egui::Event::Key { key: Key::Escape, pressed: true, .. } => {
|
||
leave = true;
|
||
false
|
||
}
|
||
egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => {
|
||
steps.push(false);
|
||
false
|
||
}
|
||
egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => {
|
||
steps.push(true);
|
||
false
|
||
}
|
||
egui::Event::Key { pressed: true, .. }
|
||
| egui::Event::Text(_)
|
||
| egui::Event::Ime(_)
|
||
| egui::Event::Copy
|
||
| egui::Event::Cut
|
||
| egui::Event::Paste(_)
|
||
| egui::Event::PasteImage(_)
|
||
| egui::Event::MiddleClickPaste { .. } => false,
|
||
_ => true,
|
||
})
|
||
});
|
||
for next in steps {
|
||
presentation_step(app, next);
|
||
}
|
||
if leave {
|
||
let _ = app.run("view.presentation", json!({}));
|
||
// The Escape that left the mode can start a pair, as in `handle`.
|
||
if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) {
|
||
let now = ctx.input(|i| i.time);
|
||
ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar
|
||
/// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode.
|
||
/// On the last artboard a step forward leaves the view as it is, and so does a step back on the
|
||
/// first.
|
||
pub(crate) fn presentation_step(app: &mut VectorcraftApp, next: bool) {
|
||
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
|
||
let at = crate::panels::artboards::selected(app, n);
|
||
let to = if next { (at + 1 < n).then_some("next") } else { (at > 0).then_some("previous") };
|
||
if let Some(to) = to
|
||
&& let Err(e) = app.run("view.goToArtboard", json!({ "index": to }))
|
||
{
|
||
app.status(e);
|
||
}
|
||
}
|
||
|
||
/// What a single-key shortcut reads for `text`, typed by the key at `physical`: the text itself,
|
||
/// or, when it holds no ASCII character (a layout without Latin letters), what that key types on a
|
||
/// US layout, so V selects the Selection tool whatever the V key types.
|
||
fn latin(text: &str, physical: Option<Key>) -> String {
|
||
match physical {
|
||
Some(k) if !text.is_ascii() => k.symbol_or_name().to_lowercase(),
|
||
_ => text.to_string(),
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// The lowercase letter a letter key A–Z stands for.
|
||
fn letter_of(k: Key) -> Option<char> {
|
||
let mut name = k.name().chars();
|
||
match (name.next(), name.next()) {
|
||
(Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_lowercase()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[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| {
|
||
// The production path: `logic` takes this host iteration's input once through
|
||
// `handle_input_once`, which runs the shortcut handler when the fonts are ready. A
|
||
// direct call covers the passes before those fonts are installed; the frame key makes
|
||
// it a no-op on the passes that already took this frame.
|
||
app.logic(ui.ctx());
|
||
handle(app, ui.ctx());
|
||
});
|
||
out.textures_delta.clear();
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn option_and_altgr_still_type_but_cmd_and_ctrl_chords_do_not() {
|
||
use egui::Modifiers as M;
|
||
assert!(is_shortcut_chord(M::COMMAND | M::SHIFT));
|
||
assert!(is_shortcut_chord(M::CTRL));
|
||
assert!(!is_shortcut_chord(M::NONE));
|
||
assert!(!is_shortcut_chord(M::SHIFT));
|
||
assert!(!is_shortcut_chord(M::ALT), "Option+key types é, ø on macOS");
|
||
assert!(!is_shortcut_chord(M::CTRL | M::ALT), "AltGr types @ on a German keyboard");
|
||
}
|
||
|
||
/// A press of `key` with Ctrl and Alt held (as Firefox on Windows reports AltGr), followed by
|
||
/// the text it typed when `typed` is given.
|
||
fn ctrl_alt_press(key: Key, typed: Option<&str>) -> Vec<egui::Event> {
|
||
let m = Modifiers::CTRL | Modifiers::ALT | Modifiers::COMMAND;
|
||
let mut v =
|
||
vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }];
|
||
v.extend(typed.map(|t| egui::Event::Text(t.into())));
|
||
v
|
||
}
|
||
|
||
/// The text of the type object `id`, and the document's layer count.
|
||
fn text_and_layers(app: &mut VectorcraftApp, id: u64) -> (String, usize) {
|
||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||
let text = r["runs"].as_array().unwrap().iter().filter_map(|run| run["text"].as_str()).collect();
|
||
(text, app.session.active().unwrap().doc.layers.len())
|
||
}
|
||
|
||
/// While the Type tool edits text, a character typed with AltGr (Ctrl+Alt in Firefox on
|
||
/// Windows) is typed and runs no command: not New Layer Options… (Cmd+Alt+L), Reset Tracking
|
||
/// (Cmd+Alt+Q), Export for Screens (Cmd+Alt+E), Select All on Active Artboard (Cmd+Alt+A) or
|
||
/// Undo (Cmd+Z).
|
||
#[test]
|
||
fn altgr_characters_typed_in_text_run_no_command() {
|
||
let mut wrong = vec![];
|
||
for (key, typed) in [(Key::L, "ł"), (Key::Q, "@"), (Key::E, "€"), (Key::A, "ą"), (Key::Z, "ż")] {
|
||
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": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("text.setRangeStyle", &json!({"id": id, "tracking": 200, "kerning": -40})).unwrap();
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
app.session.set_tool_option("select", &json!({"start": 2, "end": 2}));
|
||
assert!(app.session.tool_wants_text());
|
||
let layers = text_and_layers(&mut app, id).1;
|
||
let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.len();
|
||
let before = selected(&app);
|
||
frame(&mut app, ctrl_alt_press(key, Some(typed)));
|
||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||
let spaced =
|
||
r["runs"].as_array().unwrap().iter().all(|run| run["style"]["tracking"] == json!(200.0) && run["style"]["kerning"] == json!(-40.0));
|
||
let got = (text_and_layers(&mut app, id), spaced, selected(&app) == before, app.ui.dialog.is_none(), app.session.tool_wants_text());
|
||
if got != ((format!("Hi{typed}"), layers), true, true, true, true) {
|
||
wrong.push(format!("{key:?}: (text, layers), tracking and kerning kept, selection kept, no dialog, still editing = {got:?}"));
|
||
}
|
||
}
|
||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||
}
|
||
|
||
/// What egui reports for the command key: Cmd on a Mac, Ctrl elsewhere. The keymap reads a
|
||
/// Mac's Ctrl as a key of its own, so Ctrl+L there is not Cmd+L.
|
||
fn command_key() -> Modifiers {
|
||
if crate::keymap::platform().is_mac() { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND }
|
||
}
|
||
|
||
/// Ctrl and Ctrl+Alt chords (Cmd and Cmd+Alt on a Mac) that type no text still run their
|
||
/// commands while the Type tool edits, and with the Selection tool a Ctrl+Alt press runs its
|
||
/// chord as before. Ctrl+L is New Layer, and Ctrl+Alt+L is New Layer Options…, which opens
|
||
/// Layer Options for a new layer.
|
||
#[test]
|
||
fn ctrl_alt_chords_without_typed_text_still_run() {
|
||
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("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
assert!(app.session.tool_wants_text());
|
||
let layers = text_and_layers(&mut app, id).1;
|
||
let new_layer_options =
|
||
|app: &VectorcraftApp| app.ui.dialog.as_ref().is_some_and(|d| d.kind == "layerOptions" && d.fields.get("mode") == Some(&json!("new")));
|
||
let m = command_key();
|
||
frame(&mut app, press(Key::L, m));
|
||
assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "Ctrl+L while editing: New Layer");
|
||
assert!(app.ui.dialog.is_none(), "Ctrl+L opens no dialog");
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
assert!(app.session.tool_wants_text());
|
||
frame(&mut app, press(Key::L, m | Modifiers::ALT));
|
||
assert!(new_layer_options(&app), "Ctrl+Alt+L with no text typed while editing: New Layer Options…");
|
||
assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "no layer is added until OK");
|
||
crate::dialogs::cancel(&mut app);
|
||
app.select_tool("selection");
|
||
let mut typed = press(Key::L, m | Modifiers::ALT);
|
||
typed.push(egui::Event::Text("ł".into()));
|
||
frame(&mut app, typed);
|
||
assert!(new_layer_options(&app), "Selection tool: Ctrl+Alt+L runs New Layer Options… as before");
|
||
assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "no layer is added until OK");
|
||
}
|
||
|
||
/// Cmd as egui reports it on this platform: the Command key on macOS, Ctrl elsewhere.
|
||
fn cmd() -> Modifiers {
|
||
if crate::keymap::platform().is_mac() { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND }
|
||
}
|
||
|
||
/// The web build keeps from the browser the presses the keymap binds: the View menu's zoom
|
||
/// chords, Undo and Redo, whatever the browser reports as the key typed and its position.
|
||
#[test]
|
||
fn presses_the_keymap_binds_run_a_shortcut() {
|
||
crate::shortcut_editor::sync(&crate::state::UiState::default());
|
||
let (cmd, shift, alt) = (cmd(), Modifiers::SHIFT, Modifiers::ALT);
|
||
for (key, code, mods) in [
|
||
("=", "Equal", cmd),
|
||
("+", "Equal", cmd | shift),
|
||
("+", "NumpadAdd", cmd),
|
||
("-", "Minus", cmd),
|
||
("0", "Digit0", cmd),
|
||
("º", "Digit0", cmd | alt),
|
||
("z", "KeyZ", cmd),
|
||
("Z", "KeyZ", cmd | shift),
|
||
("[", "BracketLeft", cmd),
|
||
// German's `+` key, where US has `]`: the character decides.
|
||
("+", "BracketRight", cmd),
|
||
] {
|
||
assert!(runs_a_shortcut(key, code, mods), "{key} ({code}) {mods:?}");
|
||
}
|
||
// Keys the browser has no name egui knows for are matched by their position.
|
||
assert!(runs_a_shortcut("Dead", "KeyZ", cmd), "a dead key at Z");
|
||
for (key, code, mods) in [("Insert", "Numpad0", cmd), ("F13", "F13", cmd), ("Unidentified", "", cmd)] {
|
||
assert!(!runs_a_shortcut(key, code, mods), "{key} ({code}) runs nothing");
|
||
}
|
||
}
|
||
|
||
/// The user's shortcuts decide: a chord given to another command runs that one, and a chord
|
||
/// taken from its command runs nothing.
|
||
#[test]
|
||
fn presses_follow_the_users_shortcuts() {
|
||
let mut ui = crate::state::UiState::default();
|
||
crate::keymap::set_override(&mut ui.shortcut_overrides, "view.zoomIn", "");
|
||
crate::keymap::set_override(&mut ui.shortcut_overrides, "view.grid", "Cmd+Alt+Shift+F9");
|
||
crate::shortcut_editor::sync(&ui);
|
||
let zoom_in = runs_a_shortcut("=", "Equal", cmd());
|
||
let grid = runs_a_shortcut("F9", "F9", cmd() | Modifiers::ALT | Modifiers::SHIFT);
|
||
crate::shortcut_editor::sync(&crate::state::UiState::default());
|
||
assert!(!zoom_in, "Zoom In has no shortcut");
|
||
assert!(grid, "Show Grid's own chord");
|
||
assert!(runs_a_shortcut("=", "Equal", cmd()), "the defaults again");
|
||
}
|
||
|
||
/// #2094: Cmd+Shift+O is Type › Create Outlines (not File › Open's Cmd+O, not the Artboard
|
||
/// tool's Shift+O), with the Selection tool or while the Type tool edits the text.
|
||
#[test]
|
||
fn cmd_shift_o_creates_outlines() {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||
assert_eq!(crate::menus::shortcut_of("type.createOutlines"), Some("Cmd+Shift+O"));
|
||
let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT;
|
||
let chord = egui::Event::Key { key: Key::O, physical_key: None, pressed: true, repeat: false, modifiers: cmd_shift };
|
||
let is_text = |app: &VectorcraftApp, id: u64| {
|
||
matches!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_)))
|
||
};
|
||
let group_kids = |app: &VectorcraftApp| -> usize {
|
||
let id = app.session.active().unwrap().selection.objects.first().copied().unwrap();
|
||
match &app.session.doc().unwrap().doc.node(id).unwrap().kind {
|
||
vectorcraft_doc::NodeKind::Group { children, .. } => children.len(),
|
||
_ => 0,
|
||
}
|
||
};
|
||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
|
||
assert!(is_text(&app, id));
|
||
frame(&mut app, vec![chord.clone()]);
|
||
assert!(!is_text(&app, id), "Selection tool: the chord outlines the text");
|
||
assert_eq!(group_kids(&app), 2, "H and i");
|
||
assert_ne!(app.session.tool_id(), "artboard", "not the Artboard tool's Shift+O");
|
||
assert!(app.ui.dialog.is_none(), "not File › Open");
|
||
// While the Type tool edits, the same chord outlines; a Text event with Cmd held must not type.
|
||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 80, "text": "Yo"})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
assert!(app.session.tool_wants_text());
|
||
frame(&mut app, vec![egui::Event::ModifiersChanged(cmd_shift), chord, egui::Event::Text("o".into())]);
|
||
assert!(!is_text(&app, id), "Type tool: the chord outlines the text being edited");
|
||
assert_eq!(group_kids(&app), 2, "Yo only: the stray Text with Cmd was not typed");
|
||
assert!(app.ui.dialog.is_none(), "not File › Open while typing");
|
||
}
|
||
|
||
/// Cmd+Shift+B while the Type tool edits text is Type › Bold, not Hide Bounding Box (#724).
|
||
#[test]
|
||
fn cmd_shift_b_while_typing_is_bold_not_the_bounding_box() {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||
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();
|
||
}
|
||
frame(&mut app, vec![egui::Event::Text("Bold".into())]);
|
||
assert!(app.session.tool_wants_text());
|
||
let chord =
|
||
egui::Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT };
|
||
frame(&mut app, vec![chord.clone()]);
|
||
assert!(app.ui.view.bounding_box, "the bounding box stays");
|
||
// The chord reached Bold: the text is in a bold face, or its family has none and says so.
|
||
let face = crate::panels::character::text_style(&app).map(|(s, _)| s.font_style).unwrap_or_default();
|
||
assert!(face.contains("Bold") || app.ui.status.contains("has no Bold style"), "{face:?} {:?}", app.ui.status);
|
||
// Out of the text, the chord hides the bounding box as before.
|
||
app.select_tool("selection");
|
||
frame(&mut app, vec![chord]);
|
||
assert!(!app.ui.view.bounding_box);
|
||
}
|
||
|
||
/// Cmd+Shift+L, C, R, J and F align paragraphs (#1111): every paragraph of the selected type,
|
||
/// or while the Type tool edits text, those its selection touches.
|
||
#[test]
|
||
fn cmd_shift_letters_align_paragraphs() {
|
||
use vectorcraft_doc::Justify;
|
||
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("text.create", &json!({"x": 20, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
|
||
let chord =
|
||
|key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT };
|
||
let paras = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||
vectorcraft_doc::NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).justify).collect::<Vec<_>>(),
|
||
_ => panic!("text"),
|
||
};
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
for (key, want) in [
|
||
(Key::C, Justify::Center),
|
||
(Key::R, Justify::Right),
|
||
(Key::J, Justify::JustifyLeft),
|
||
(Key::F, Justify::JustifyAll),
|
||
(Key::L, Justify::Left),
|
||
] {
|
||
frame(&mut app, vec![chord(key)]);
|
||
assert_eq!(paras(&app), [want, want], "{key:?}");
|
||
}
|
||
// With the Type tool, only the paragraph its selection touches.
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
app.session.set_tool_option("select", &json!({"start": 5, "end": 6}));
|
||
assert!(app.session.tool_wants_text());
|
||
frame(&mut app, vec![chord(Key::R)]);
|
||
assert_eq!(paras(&app), [Justify::Left, Justify::Right]);
|
||
// Nothing selected: nothing to align.
|
||
app.select_tool("selection");
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
assert!(!crate::menus::enabled(&app, "type.alignCenter"));
|
||
}
|
||
|
||
/// Cmd+Alt+Q resets the tracking and kerning of the selected type (tracking 0, kerning Auto),
|
||
/// and of the Type tool's selected text while it edits.
|
||
#[test]
|
||
fn cmd_alt_q_resets_tracking_and_kerning() {
|
||
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("text.create", &json!({"x": 20, "y": 50, "text": "One Two"})).unwrap()["id"].as_u64().unwrap();
|
||
let spaced =
|
||
|app: &mut VectorcraftApp| app.session.execute("text.setRangeStyle", &json!({"id": id, "tracking": 200, "kerning": -40})).unwrap();
|
||
let spacing = |app: &mut VectorcraftApp| {
|
||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||
r["runs"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|run| (run["text"].clone(), run["style"]["tracking"].clone(), run["style"]["kerning"].clone()))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let chord =
|
||
egui::Event::Key { key: Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT };
|
||
spaced(&mut app);
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
frame(&mut app, vec![chord.clone()]);
|
||
assert_eq!(spacing(&mut app), [(json!("One Two"), json!(0.0), json!(null))]);
|
||
// While the Type tool edits: only its selection.
|
||
spaced(&mut app);
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
app.session.set_tool_option("select", &json!({"start": 4, "end": 7}));
|
||
assert!(app.session.tool_wants_text());
|
||
frame(&mut app, vec![chord]);
|
||
assert_eq!(spacing(&mut app), [(json!("One "), json!(200.0), json!(-40.0)), (json!("Two"), json!(0.0), json!(null))]);
|
||
}
|
||
|
||
/// Cmd+Shift+X resets the horizontal scale of the selected type to 100%, and of the Type
|
||
/// tool's selected text while it edits. egui reports the chord as a Cut with Shift held, and
|
||
/// the type object is not cut.
|
||
#[test]
|
||
fn cmd_shift_x_resets_horizontal_scale() {
|
||
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("text.create", &json!({"x": 20, "y": 50, "text": "One Two"})).unwrap()["id"].as_u64().unwrap();
|
||
let stretched = |app: &mut VectorcraftApp| app.session.execute("text.setRangeStyle", &json!({"id": id, "hScale": 150})).unwrap();
|
||
let scales = |app: &mut VectorcraftApp| {
|
||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||
r["runs"].as_array().unwrap().iter().map(|run| (run["text"].clone(), run["style"]["h_scale"].clone())).collect::<Vec<_>>()
|
||
};
|
||
let chord = vec![egui::Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT), egui::Event::Cut];
|
||
stretched(&mut app);
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
frame(&mut app, chord.clone());
|
||
assert_eq!(scales(&mut app), [(json!("One Two"), json!(100.0))]);
|
||
// While the Type tool edits: only its selection, and the text stays.
|
||
stretched(&mut app);
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
app.session.set_tool_option("select", &json!({"start": 4, "end": 7}));
|
||
assert!(app.session.tool_wants_text());
|
||
frame(&mut app, chord);
|
||
assert_eq!(scales(&mut app), [(json!("One "), json!(150.0)), (json!("Two"), json!(100.0))]);
|
||
}
|
||
|
||
/// 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");
|
||
}
|
||
|
||
/// egui counts frames per viewport: two viewports of one context can both be on frame 0. Each
|
||
/// one must take its shortcut, not lose the second to a dedupe key that omits the viewport.
|
||
#[test]
|
||
fn two_viewports_on_the_same_frame_number_each_take_their_shortcut() {
|
||
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(800.0, 600.0));
|
||
let aux = egui::ViewportId::from_hash_of("vectorcraft-shortcuts-aux");
|
||
let tab = egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||
let mut frames = vec![];
|
||
// The auxiliary viewport goes first so the root one has not advanced past frame 0.
|
||
for viewport in [aux, egui::ViewportId::ROOT] {
|
||
let mut raw = egui::RawInput { events: vec![tab.clone()], screen_rect: Some(screen), viewport_id: viewport, ..Default::default() };
|
||
raw.viewports.entry(viewport).or_default();
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
frames.push(ui.ctx().cumulative_frame_nr_for(viewport));
|
||
handle(&mut app, ui.ctx());
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
assert_eq!(frames, [0, 0], "both viewports are on frame 0");
|
||
assert!(app.ui.dock, "two Tabs in two viewports toggled the panels twice (the second was not a duplicate)");
|
||
}
|
||
|
||
/// Shift+Tab shows and hides the panels and leaves the toolbar and the Control bar as they are,
|
||
/// while Tab hides all three. Hiding the panels closes a panel popped out of the icon column.
|
||
/// In a focused field neither key shows or hides anything.
|
||
#[test]
|
||
fn shift_tab_hides_the_panels_but_not_the_toolbar_or_the_control_bar() {
|
||
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 = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers };
|
||
let shown = |app: &VectorcraftApp| (app.ui.dock, app.ui.toolbar, app.ui.control_bar);
|
||
app.ui.control_bar = true;
|
||
for _ in 0..3 {
|
||
run(&mut app, vec![]);
|
||
}
|
||
assert_eq!(shown(&app), (true, true, true));
|
||
app.run("window.panel", json!({"panel": "color"})).unwrap();
|
||
run(&mut app, vec![press(Modifiers::SHIFT)]);
|
||
run(&mut app, vec![]);
|
||
assert_eq!(shown(&app), (false, true, true), "Shift+Tab hid the panels only");
|
||
assert_eq!(app.ui.open_panel, None, "and the panel popped out of the icon column");
|
||
run(&mut app, vec![press(Modifiers::SHIFT)]);
|
||
run(&mut app, vec![]);
|
||
assert_eq!(shown(&app), (true, true, true), "a second Shift+Tab shows them again");
|
||
app.run("window.panel", json!({"panel": "color"})).unwrap();
|
||
run(&mut app, vec![press(Modifiers::NONE)]);
|
||
run(&mut app, vec![]);
|
||
assert_eq!(shown(&app), (false, false, false), "Tab hides the toolbar and the Control bar too");
|
||
assert_eq!(app.ui.open_panel, None);
|
||
run(&mut app, vec![press(Modifiers::NONE)]);
|
||
run(&mut app, vec![]);
|
||
assert_eq!(shown(&app), (true, true, true));
|
||
// A field of the Color panel has the keyboard focus: Shift+Tab and Tab do not show or hide anything.
|
||
app.run("window.panel", json!({"panel": "color"})).unwrap();
|
||
for _ in 0..3 {
|
||
run(&mut app, vec![]);
|
||
}
|
||
let fields: Vec<(egui::Id, egui::Rect)> = ctx.viewport(|vp| {
|
||
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.sense.senses_click()).map(|w| (w.id, w.rect)).collect()
|
||
});
|
||
let (id, rect) =
|
||
fields.into_iter().find(|&(id, _)| egui::TextEdit::load_state(&ctx, id).is_some()).unwrap_or_else(|| panic!("no field on screen"));
|
||
let click =
|
||
|pressed| egui::Event::PointerButton { pos: rect.center(), button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||
run(&mut app, vec![egui::Event::PointerMoved(rect.center()), click(true)]);
|
||
run(&mut app, vec![click(false)]);
|
||
assert!(ctx.memory(|m| m.has_focus(id)), "the field has the keyboard");
|
||
for modifiers in [Modifiers::SHIFT, Modifiers::NONE] {
|
||
run(&mut app, vec![press(modifiers)]);
|
||
run(&mut app, vec![]);
|
||
assert_eq!(shown(&app), (true, true, true), "{modifiers:?}");
|
||
assert_eq!(app.ui.open_panel.as_deref(), Some("color"), "{modifiers:?}");
|
||
}
|
||
}
|
||
|
||
/// The second Tab shows again only the bars the first Tab hid: a Control bar that was off stays
|
||
/// off, and a toolbar hidden with Window › Tools stays hidden, after a restart too. Shift+Tab
|
||
/// between two Tabs, and Tab between two Shift+Tabs, leave a state the same key goes back from.
|
||
/// After a switch of workspace, Tab hides and shows that workspace's bars.
|
||
#[test]
|
||
fn tab_shows_again_only_the_bars_it_hid() {
|
||
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 = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers };
|
||
let tab = |app: &mut VectorcraftApp, modifiers| {
|
||
run(app, vec![press(modifiers)]);
|
||
run(app, vec![]);
|
||
(app.ui.dock, app.ui.toolbar, app.ui.control_bar)
|
||
};
|
||
for _ in 0..3 {
|
||
run(&mut app, vec![]);
|
||
}
|
||
assert_eq!((app.ui.dock, app.ui.toolbar, app.ui.control_bar), (true, true, false), "the Control bar starts off");
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "the second Tab leaves the Control bar off");
|
||
app.run("window.toolbar", json!({})).unwrap();
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (true, false, false), "a toolbar hidden with Window › Tools stays hidden");
|
||
app.run("window.toolbar", json!({})).unwrap();
|
||
// Hidden by Tab when the app quits: the next Tab after a restart shows the same bars.
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false));
|
||
app.ui = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "after a restart");
|
||
// Shift+Tab after Tab shows the panels, and a second Shift+Tab hides them again; Tab then
|
||
// shows what the first Tab hid.
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::SHIFT), (true, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::SHIFT), (false, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::SHIFT), (true, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "Tab after Shift+Tab");
|
||
// Tab after Shift+Tab hides the toolbar, and a second Tab goes back to Shift+Tab's state.
|
||
assert_eq!(tab(&mut app, Modifiers::SHIFT), (false, true, false));
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, true, false), "Shift+Tab's state");
|
||
// Panels shown with Shift+Tab between two Tabs stay shown.
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::SHIFT), (true, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "the panels Shift+Tab showed");
|
||
// A bar shown another way after Tab: the next Tab hides it, and the Tab after that shows it again.
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false));
|
||
app.run("window.control", json!({})).unwrap();
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, true), "only the Control bar");
|
||
// Nothing shows and Tab hid nothing: Tab shows all three.
|
||
app.run("window.control", json!({})).unwrap();
|
||
assert_eq!((app.ui.dock, app.ui.toolbar, app.ui.control_bar), (false, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, true));
|
||
// After a switch of workspace, Tab hides that workspace's bars and shows them again.
|
||
app.run("window.toolbar", json!({})).unwrap();
|
||
app.run("window.control", json!({})).unwrap();
|
||
app.run("window.workspace.new", json!({"name": "Panels Only"})).unwrap();
|
||
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false));
|
||
app.run("window.workspace", json!({"name": "Panels Only"})).unwrap();
|
||
assert_eq!((app.ui.dock, app.ui.toolbar, app.ui.control_bar), (true, false, false));
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false), "Tab after a switch of workspace");
|
||
assert_eq!(tab(&mut app, Modifiers::NONE), (true, false, false));
|
||
}
|
||
|
||
#[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"));
|
||
// The copy is handed to egui once, in the pass that took it: no second command follows.
|
||
let again = frame(&mut app, vec![]);
|
||
assert!(!again.platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_))));
|
||
// 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));
|
||
}
|
||
|
||
/// A press of `key` with `m` held.
|
||
fn press(key: Key, m: Modifiers) -> Vec<egui::Event> {
|
||
vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }]
|
||
}
|
||
|
||
/// Binds Show Grid to `chord`, or with `None` leaves every command its default shortcut.
|
||
fn bind_grid(app: &mut VectorcraftApp, chord: Option<&str>) {
|
||
app.ui.shortcut_overrides.clear();
|
||
if let Some(c) = chord {
|
||
app.ui.shortcut_overrides.insert("view.grid", vec![c.to_string()]);
|
||
}
|
||
crate::shortcut_editor::sync(&app.ui);
|
||
}
|
||
|
||
/// Clicks the active tool at (`x`, 200) and returns how many objects the first layer holds.
|
||
fn click_at(app: &mut VectorcraftApp, x: f64) -> usize {
|
||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||
let view = app.view_info();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, x, 200.0), view).unwrap();
|
||
}
|
||
app.session.doc().unwrap().doc.layers[0].children().unwrap().len()
|
||
}
|
||
|
||
/// A command bound to Cmd+Enter runs without the active tool's Enter: the Selection tool opens
|
||
/// no Move dialog, the Type tool types no line break, and the Pen tool's path stays open. With
|
||
/// nothing bound to it, Cmd+Enter goes to the tool as Enter does.
|
||
#[test]
|
||
fn a_command_on_cmd_enter_runs_without_the_tools_enter() {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap()["id"].clone();
|
||
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
|
||
let grid = app.ui.view.grid;
|
||
app.select_tool("selection");
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
frame(&mut app, press(Key::Enter, Modifiers::COMMAND));
|
||
assert_eq!((kind(&app).as_deref(), app.ui.view.grid), (Some("move"), grid), "nothing bound: Cmd+Enter opens Move as Enter does");
|
||
app.ui.dialog = None;
|
||
bind_grid(&mut app, Some("Cmd+Enter"));
|
||
frame(&mut app, press(Key::Enter, Modifiers::COMMAND));
|
||
assert_eq!((kind(&app), app.ui.view.grid), (None, !grid), "Cmd+Enter runs Show Grid only");
|
||
frame(&mut app, press(Key::Enter, Modifiers::NONE));
|
||
assert_eq!((kind(&app).as_deref(), app.ui.view.grid), (Some("move"), !grid), "Enter still opens Move");
|
||
app.ui.dialog = None;
|
||
let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
assert!(app.session.tool_wants_text());
|
||
frame(&mut app, press(Key::Enter, Modifiers::COMMAND));
|
||
let typed = text_and_layers(&mut app, text).0;
|
||
assert_eq!((typed.as_str(), app.ui.view.grid), ("Hi", grid), "Cmd+Enter runs Show Grid and types no line break");
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("pen");
|
||
click_at(&mut app, 100.0);
|
||
let objects = click_at(&mut app, 150.0);
|
||
frame(&mut app, press(Key::Enter, Modifiers::COMMAND));
|
||
assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects, !grid), "Cmd+Enter runs Show Grid and the path stays open");
|
||
bind_grid(&mut app, None);
|
||
frame(&mut app, press(Key::Enter, Modifiers::COMMAND));
|
||
assert_eq!(click_at(&mut app, 250.0), objects + 1, "nothing bound: Cmd+Enter ends the path as Enter does");
|
||
}
|
||
|
||
/// A command bound to Cmd+Escape runs without the active tool's Escape: the Pen tool's path
|
||
/// stays open, isolation mode stays on, and the Type tool goes on editing its text. With nothing
|
||
/// bound to it, Cmd+Escape goes to the tool as Escape does. Windows keeps Cmd+Escape (Ctrl+Esc)
|
||
/// for the Start menu, so no command can be bound to it there and it goes to the tool.
|
||
#[test]
|
||
fn a_command_on_cmd_escape_runs_without_the_tools_escape() {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap()["id"].clone();
|
||
let grid = app.ui.view.grid;
|
||
let cmd_escape = || press(Key::Escape, Modifiers::COMMAND);
|
||
let bindable = crate::keymap::platform() != craft_keymap::Platform::Windows;
|
||
bind_grid(&mut app, Some("Cmd+Escape"));
|
||
app.select_tool("pen");
|
||
click_at(&mut app, 100.0);
|
||
let objects = click_at(&mut app, 150.0);
|
||
frame(&mut app, cmd_escape());
|
||
if bindable {
|
||
assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects, !grid), "Cmd+Escape runs Show Grid and the path stays open");
|
||
} else {
|
||
assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects + 1, grid), "Windows: Cmd+Escape stays unbound and ends the path");
|
||
}
|
||
let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].clone();
|
||
let edit = |app: &mut VectorcraftApp| {
|
||
app.session.execute("select.set", &json!({"ids": [text]})).unwrap();
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
assert!(app.session.tool_wants_text());
|
||
};
|
||
if bindable {
|
||
edit(&mut app);
|
||
frame(&mut app, cmd_escape());
|
||
assert_eq!(
|
||
(app.session.tool_wants_text(), app.ui.view.grid),
|
||
(true, grid),
|
||
"Cmd+Escape runs Show Grid and the Type tool goes on editing"
|
||
);
|
||
}
|
||
app.select_tool("selection");
|
||
app.session.execute("object.isolate", &json!({"id": id})).unwrap();
|
||
let isolated = |app: &VectorcraftApp| app.session.active().unwrap().isolation.is_some();
|
||
if bindable {
|
||
frame(&mut app, cmd_escape());
|
||
assert_eq!((isolated(&app), app.ui.view.grid), (true, !grid), "Cmd+Escape runs Show Grid and isolation mode stays on");
|
||
}
|
||
bind_grid(&mut app, None);
|
||
frame(&mut app, cmd_escape());
|
||
assert!(!isolated(&app), "nothing bound: Cmd+Escape leaves isolation mode as Escape does");
|
||
edit(&mut app);
|
||
frame(&mut app, cmd_escape());
|
||
assert!(!app.session.tool_wants_text(), "nothing bound: Cmd+Escape ends the editing as Escape does");
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("pen");
|
||
let objects = click_at(&mut app, 300.0);
|
||
frame(&mut app, cmd_escape());
|
||
assert_eq!(click_at(&mut app, 350.0), objects + 1, "nothing bound: Cmd+Escape ends the path as Escape does");
|
||
}
|
||
|
||
/// 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());
|
||
// The undo steps and the journal entries (the dialog's OK runs its command, which the journal records).
|
||
let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.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. With its default values only Reflect changes the art, so only
|
||
// Reflect records an undo step.
|
||
frame(&mut app, vec![enter()]);
|
||
let after = (before.0 + usize::from(dialog == "reflect"), before.1 + 1);
|
||
assert_eq!((kind(&app), steps(&app)), (None, after), "{tool}: Enter confirms");
|
||
}
|
||
app.select_tool("rectangle");
|
||
frame(&mut app, vec![enter()]);
|
||
assert_eq!(kind(&app), None, "another tool has its own Enter");
|
||
}
|
||
|
||
/// A drag with the Selection tool, then Enter: the Move dialog opens on the drag's offset, and
|
||
/// Enter is its OK. With Copy ticked once, each Enter, Enter after that makes another copy at
|
||
/// the same spacing.
|
||
#[test]
|
||
fn enter_after_a_drag_repeats_its_offset() {
|
||
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": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let enter = || egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||
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 lefts = |app: &VectorcraftApp| {
|
||
let kids = app.session.doc().unwrap().doc.layers[0].children().unwrap();
|
||
kids.iter().map(|n| n.geometric_bounds().unwrap().x0.round()).collect::<Vec<_>>()
|
||
};
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
for (kind, x, y) in
|
||
[(PointerKind::Down, 20.0, 20.0), (PointerKind::Drag, 40.0, 20.0), (PointerKind::Drag, 60.0, 20.0), (PointerKind::Up, 60.0, 20.0)]
|
||
{
|
||
app.session.pointer(&PointerEvent::new(kind, x, y), view).unwrap();
|
||
}
|
||
let [moved] = lefts(&app)[..] else { panic!("one rectangle") };
|
||
let step = moved - 10.0;
|
||
assert!(step > 0.0);
|
||
// Keys reach the canvas from the second frame on.
|
||
frame(&mut app, vec![]);
|
||
frame(&mut app, vec![enter()]);
|
||
let d = app.ui.dialog.as_mut().expect("Enter opens Move");
|
||
assert_eq!((d.kind.as_str(), d.f64("dx", f64::NAN).round(), d.f64("dy", f64::NAN)), ("move", step, 0.0));
|
||
d.fields.insert("copy".into(), json!(true));
|
||
frame(&mut app, vec![enter()]);
|
||
for _ in 0..2 {
|
||
frame(&mut app, vec![enter()]);
|
||
frame(&mut app, vec![enter()]);
|
||
}
|
||
assert!(app.ui.dialog.is_none());
|
||
assert_eq!(lefts(&app), (0..4).map(|i| moved + step * f64::from(i)).collect::<Vec<_>>());
|
||
// With Preview on too, each Enter, Enter still makes one copy, and one undo step.
|
||
frame(&mut app, vec![enter()]);
|
||
app.ui.dialog.as_mut().expect("Move").fields.insert("preview".into(), json!(true));
|
||
frame(&mut app, vec![enter()]);
|
||
let steps = app.session.active().unwrap().history.undo.len();
|
||
for _ in 0..2 {
|
||
frame(&mut app, vec![enter()]);
|
||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("preview") && d.bool("copy")) && app.session.in_interaction());
|
||
frame(&mut app, vec![enter()]);
|
||
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
|
||
}
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 2);
|
||
assert_eq!(lefts(&app), (0..7).map(|i| moved + step * f64::from(i)).collect::<Vec<_>>());
|
||
}
|
||
|
||
/// #1661: typed `[` and `]` reach the tool that takes them: the Blob Brush's and the Eraser's
|
||
/// size (Option or AltGr brackets too), the stroke weight left as it is; other tools leave them
|
||
/// alone.
|
||
#[test]
|
||
fn brackets_size_the_blob_brush_and_the_eraser() {
|
||
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("stroke.set", &json!({"weight": 3})).unwrap();
|
||
let size = |app: &mut VectorcraftApp| app.session.tool_options()["size"].as_f64().unwrap();
|
||
let text = |t: &str| egui::Event::Text(t.into());
|
||
for tool in ["blobBrush", "eraser"] {
|
||
app.select_tool(tool);
|
||
let before = size(&mut app);
|
||
frame(&mut app, vec![text("]")]);
|
||
frame(&mut app, vec![text("]")]);
|
||
assert_eq!(size(&mut app), before + 2.0, "{tool}");
|
||
// Option-8 types `[` on a Turkish Mac keyboard.
|
||
let option_8 = egui::Event::Key { key: Key::Num8, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT };
|
||
frame(&mut app, vec![option_8, text("[")]);
|
||
assert_eq!(size(&mut app), before + 1.0, "{tool}: a bracket typed with Option");
|
||
}
|
||
assert_eq!(app.session.paint.stroke_width, 3.0, "the stroke weight stays");
|
||
app.select_tool("rectangle");
|
||
frame(&mut app, vec![text("[")]);
|
||
assert_eq!(app.session.paint.stroke_width, 3.0);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
/// Cmd/Ctrl+Alt+Shift+. and , step the font size by five Size/Leading increments, and
|
||
/// Cmd/Ctrl+Shift+. still steps it by one: the chord with Alt does not reach the one-step
|
||
/// command.
|
||
#[test]
|
||
fn cmd_alt_shift_period_and_comma_step_the_font_size_by_five() {
|
||
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("text.create", &json!({"x": 20, "y": 50, "text": "One Two", "size": 12})).unwrap()["id"].as_u64().unwrap();
|
||
let sizes = |app: &mut VectorcraftApp| {
|
||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||
r["runs"].as_array().unwrap().iter().map(|run| (run["text"].clone(), run["style"]["size"].clone())).collect::<Vec<_>>()
|
||
};
|
||
let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||
let five = Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT;
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
frame(&mut app, vec![press(Key::Period, five)]);
|
||
assert_eq!(sizes(&mut app), [(json!("One Two"), json!(22.0))]);
|
||
frame(&mut app, vec![press(Key::Period, Modifiers::COMMAND | Modifiers::SHIFT)]);
|
||
assert_eq!(sizes(&mut app), [(json!("One Two"), json!(24.0))], "one step");
|
||
frame(&mut app, vec![press(Key::Comma, five)]);
|
||
assert_eq!(sizes(&mut app), [(json!("One Two"), json!(14.0))]);
|
||
// While the Type tool edits: only its selection.
|
||
app.select_tool("type");
|
||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||
app.session.set_tool_option("select", &json!({"start": 4, "end": 7}));
|
||
assert!(app.session.tool_wants_text());
|
||
frame(&mut app, vec![press(Key::Period, five)]);
|
||
assert_eq!(sizes(&mut app), [(json!("One "), json!(14.0)), (json!("Two"), json!(24.0))]);
|
||
}
|
||
|
||
/// #3786: Alt+arrows step a selected text object's spacing instead of copying and nudging it.
|
||
#[test]
|
||
fn alt_arrows_step_selected_type_object() {
|
||
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("text.create", &json!({"x": 20, "y": 50, "text": "One Two", "size": 20})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("selection");
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
|
||
let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT)]);
|
||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||
assert_eq!(r["runs"][0]["style"]["tracking"], json!(20.0));
|
||
assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)]);
|
||
|
||
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowDown, Modifiers::ALT)]);
|
||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||
assert_eq!(r["runs"][0]["style"]["leading"], json!(26.0));
|
||
|
||
let alt_shift = Modifiers::ALT | Modifiers::SHIFT;
|
||
frame(&mut app, vec![egui::Event::ModifiersChanged(alt_shift), press(Key::ArrowUp, alt_shift)]);
|
||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||
assert_eq!(r["runs"][0]["style"]["baseline_shift"], json!(2.0));
|
||
|
||
// Shift+Alt+Left/Right have no text-spacing action; they must still be consumed instead of
|
||
// falling through to Alt+arrow's copy-and-nudge behaviour.
|
||
for key in [Key::ArrowLeft, Key::ArrowRight] {
|
||
frame(&mut app, vec![egui::Event::ModifiersChanged(alt_shift), press(key, alt_shift)]);
|
||
assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)]);
|
||
}
|
||
|
||
// Other objects keep Alt+arrow's copy-and-nudge shortcut.
|
||
let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.set", &json!({"ids": [rect]})).unwrap();
|
||
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT)]);
|
||
let selected = &app.session.active().unwrap().selection.objects;
|
||
assert_eq!(selected.len(), 1);
|
||
assert_ne!(selected[0], vectorcraft_doc::NodeId(rect), "a non-type object is still copied by Alt+arrow");
|
||
}
|
||
|
||
/// #793: with a layout without Latin letters the one-key shortcuts go by the key's position:
|
||
/// the P key typing Persian `ح` chooses the Pen, the V key typing Russian `м` the Selection
|
||
/// tool. A Latin layout keeps the letter typed: AZERTY's A (the Q key's place) is A.
|
||
#[test]
|
||
fn one_key_shortcuts_follow_the_key_on_layouts_without_latin_letters() {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||
let typed = |key, physical, text: &str| {
|
||
vec![
|
||
egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE },
|
||
egui::Event::Text(text.into()),
|
||
]
|
||
};
|
||
frame(&mut app, typed(Key::P, Key::P, "ح"));
|
||
assert_eq!(app.session.tool_id(), "pen");
|
||
frame(&mut app, typed(Key::V, Key::V, "м"));
|
||
assert_eq!(app.session.tool_id(), "selection");
|
||
frame(&mut app, typed(Key::A, Key::Q, "a"));
|
||
assert_eq!(app.session.tool_id(), "directSelection", "AZERTY: the letter typed");
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
/// `+` selects the Add Anchor Point tool, as `-` selects Delete Anchor Point (#2142): typed as
|
||
/// Shift+`=` on a US layout, or by a layout's own `+` key or the numpad's.
|
||
#[test]
|
||
fn plus_selects_the_add_anchor_point_tool() {
|
||
assert_eq!(parse("+"), parse("="), "a bare `+` is the `=` key");
|
||
assert_eq!(crate::shortcut_editor::tool_for_key("+"), Some("addAnchor"));
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||
for m in [Modifiers::SHIFT, Modifiers::NONE] {
|
||
app.select_tool("selection");
|
||
frame(&mut app, vec![egui::Event::ModifiersChanged(m), egui::Event::Text("+".into())]);
|
||
assert_eq!(app.session.tool_id(), "addAnchor", "`+` with {m:?}");
|
||
}
|
||
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::NONE), egui::Event::Text("-".into())]);
|
||
assert_eq!(app.session.tool_id(), "deleteAnchor");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// E pressed while the Selection tool drags a marquee makes it select only the objects inside
|
||
/// it: once per press, without Cmd, and from the key at E's position on a layout without Latin
|
||
/// letters.
|
||
#[test]
|
||
fn e_reaches_a_dragged_marquee_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();
|
||
let inside = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||
let touched = app.session.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||
app.select_tool("selection");
|
||
let e = |key, repeat, modifiers| egui::Event::Key { key, physical_key: Some(Key::E), pressed: true, repeat, modifiers };
|
||
let mut marquee = |events: Vec<egui::Event>| {
|
||
let v = app.view_info();
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap();
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Drag, 350.0, 250.0), v).unwrap();
|
||
frame(&mut app, events);
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 350.0, 250.0), v).unwrap();
|
||
let ids = app.session.doc().unwrap().selection.objects.iter().map(|i| json!(i.0)).collect::<Vec<_>>();
|
||
assert_eq!(app.session.tool_id(), "selection");
|
||
ids
|
||
};
|
||
assert_eq!(marquee(vec![]), vec![inside.clone(), touched.clone()]);
|
||
// A held key repeats: only its first press switches.
|
||
assert_eq!(marquee(vec![e(Key::E, false, Modifiers::NONE), e(Key::E, true, Modifiers::NONE)]), vec![inside.clone()]);
|
||
let up = egui::Event::Key { key: Key::E, physical_key: Some(Key::E), pressed: false, repeat: false, modifiers: Modifiers::NONE };
|
||
let twice = vec![e(Key::E, false, Modifiers::NONE), up, e(Key::E, false, Modifiers::NONE)];
|
||
assert_eq!(marquee(twice), vec![inside.clone(), touched.clone()], "pressed twice: what it touches");
|
||
// A Russian layout: egui-winit reports the key at E's position, and the text it types.
|
||
assert_eq!(marquee(vec![e(Key::E, false, Modifiers::NONE), egui::Event::Text("у".into())]), vec![inside.clone()]);
|
||
assert_eq!(marquee(vec![e(Key::E, false, Modifiers::COMMAND)]), vec![inside, touched], "Cmd+E is not E");
|
||
assert_eq!(letter_of(Key::E), Some('e'));
|
||
assert_eq!(letter_of(Key::Num1), None);
|
||
assert_eq!(letter_of(Key::Escape), None);
|
||
}
|
||
|
||
/// Shortcuts reach tools, panels (`window.panel {panel}`) and commands through the keymap,
|
||
/// and a held Cmd+= keeps zooming in.
|
||
#[test]
|
||
fn shortcuts_run_tools_panels_and_commands() {
|
||
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("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||
app.session.execute("select.set", &json!({"ids": []})).unwrap();
|
||
let press = |key, modifiers, repeat| egui::Event::Key { key, physical_key: None, pressed: true, repeat, modifiers };
|
||
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:pen", "F2");
|
||
crate::shortcut_editor::sync(&app.ui);
|
||
frame(&mut app, vec![press(Key::F2, Modifiers::NONE, false)]);
|
||
assert_eq!(app.session.tool_id(), "pen", "a tool on a key that types nothing");
|
||
app.select_tool("selection");
|
||
frame(&mut app, vec![press(Key::F7, Modifiers::SHIFT, false)]);
|
||
assert_eq!(app.ui.open_panel.as_deref(), Some("align"));
|
||
frame(&mut app, vec![press(Key::A, Modifiers::COMMAND, false)]);
|
||
assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "Select All");
|
||
let zoom = |app: &mut VectorcraftApp| app.view_mut().unwrap().zoom;
|
||
frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND, false)]);
|
||
let once = zoom(&mut app);
|
||
frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND, true)]);
|
||
assert!(zoom(&mut app) > once, "a key repeat zooms again");
|
||
}
|
||
|
||
/// Cmd+, opens Settings on a Mac only, beside the frame's other shortcuts, and never takes a
|
||
/// chord a shortcut already has.
|
||
#[test]
|
||
fn cmd_comma_opens_settings_on_a_mac() {
|
||
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();
|
||
let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||
let on = |app: &mut VectorcraftApp, os, events| {
|
||
app.ui.dialog = None;
|
||
app.session.execute("select.set", &json!({"ids": []})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
ctx.set_os(os);
|
||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| handle(app, ui.ctx())).textures_delta.clear();
|
||
let settings = app.ui.dialog.as_ref().is_some_and(|d| d.kind == "preferences");
|
||
(settings, app.session.active().unwrap().selection.objects.len())
|
||
};
|
||
let (mac, linux) = (egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Nix);
|
||
let comma = press(Key::Comma, Modifiers::COMMAND);
|
||
let select_all = press(Key::A, Modifiers::COMMAND);
|
||
assert_eq!(on(&mut app, mac, vec![comma.clone()]), (true, 0));
|
||
assert_eq!(on(&mut app, mac, vec![select_all.clone(), comma.clone()]), (true, 1), "with another shortcut in the frame");
|
||
assert_eq!(on(&mut app, linux, vec![select_all, comma.clone()]), (false, 1), "not elsewhere");
|
||
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "select.all", "Cmd+,");
|
||
crate::shortcut_editor::sync(&app.ui);
|
||
assert_eq!(on(&mut app, mac, vec![comma]), (false, 1), "a shortcut that has it keeps it");
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
|
||
/// An IME replacement on the Type tool arrives as `DeleteSurroundingBytes` + `Commit` events
|
||
/// and is taken once, in order: the marked text is dropped, the committed text replaces it, and
|
||
/// the events never reach `route_type_input` as typed characters.
|
||
#[test]
|
||
fn ime_delete_surrounding_bytes_edits_the_type_tool_once() {
|
||
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();
|
||
let v = vectorcraft_engine::ViewInfo::default();
|
||
app.session.select_tool("type", v).unwrap();
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 100.0), v).unwrap();
|
||
app.session.tool_text("Héllo", v).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// Fonts install on the first logic pass and are ready on the second.
|
||
for _ in 0..2 {
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx()));
|
||
out.textures_delta.clear();
|
||
}
|
||
let events = vec![
|
||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 3, after_bytes: 0 }),
|
||
egui::Event::Ime(egui::ImeEvent::Commit("y".into())),
|
||
];
|
||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.logic(ui.ctx()));
|
||
out.textures_delta.clear();
|
||
let mut text = String::new();
|
||
if let Some(st) = app.session.active() {
|
||
st.doc.walk(|node| {
|
||
if let vectorcraft_doc::NodeKind::Text(t) = &node.kind {
|
||
text = t.plain_text();
|
||
}
|
||
});
|
||
}
|
||
assert_eq!(text, "Héy");
|
||
assert!(app.session.tool_wants_text(), "editing continues after the replacement");
|
||
}
|
||
|
||
/// X11/Wayland PRIMARY middle-click after a press in a panel field: `handle` runs before the
|
||
/// fields are drawn, and must leave the event for egui's text field (it pastes even without
|
||
/// focus). Deleting it here made PRIMARY paste dead outside dialogs.
|
||
#[test]
|
||
fn middle_click_paste_reaches_an_unfocused_text_field() {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 200.0));
|
||
let id = egui::Id::unique("middle-click-paste-test");
|
||
let mut buf = String::new();
|
||
let mut at = egui::pos2(40.0, 40.0);
|
||
for step in 0..2 {
|
||
let events =
|
||
if step == 1 { vec![egui::Event::PointerMoved(at), egui::Event::MiddleClickPaste { pos: at, text: "x".into() }] } else { vec![] };
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
|
||
handle(&mut app, ui.ctx());
|
||
let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id));
|
||
at = resp.rect.center();
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
assert_eq!(buf, "x");
|
||
}
|
||
|
||
/// A shortcut is taken once per frame, but only within the context that frame belongs to: a
|
||
/// new context starts again at frame 0 (a restarted host, or a test's fresh context) and its
|
||
/// input is new, not a rerun of the old context's frame 0.
|
||
#[test]
|
||
fn a_new_context_takes_its_own_frame_zero() {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
let press = |app: &mut VectorcraftApp| {
|
||
let ctx = egui::Context::default();
|
||
let ev =
|
||
egui::Event::Key { key: Key::N, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT };
|
||
let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| handle(app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
};
|
||
press(&mut app);
|
||
assert_eq!(app.session.documents().len(), 1, "New Without Dialog makes a document");
|
||
press(&mut app);
|
||
assert_eq!(app.session.documents().len(), 2, "a second context's frame 0 is new input");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|
||
}
|
||
}
|