mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
- A shortcut parity table (`crates/engine/src/shortcut_parity_tests.rs`): one row per shortcut in
the public documentation (help-centre feature articles: keyboard layouts, text properties,
alignment, frames, components, prototypes, plugins, keyboard navigation), with the macOS and the
Windows/Linux keys, what handles it here (keymap command, held key/gesture listed in the panel,
vector edit tool key, or missing), and a note in our own words. Rows the documentation is silent
on pin today's keys, so every default binding is in the table.
- 129 documented rows: 126 bound (110 before), 1 moved off a documented conflict (Run last
plugin on Windows: the docs also give Ctrl+Alt+P to pixel preview; the plugin keeps
Ctrl+Alt+Shift+P), 2 missing (moving the rotation origin with Alt+R; placing an object with
Enter after choosing a tool from the toolbar), 2 kept by a platform (Ctrl+Space with some CJK
input methods; F6 in browsers). 48 undocumented bindings pinned.
- Tests: every row fires its command on both platforms and sits in the right shortcuts-panel tab;
every default binding has a row; no binding uses a chord the OS keeps (Cmd+H/M/Q/Tab/Space,
Ctrl+Space, screenshot keys on macOS; Alt+Space/Tab/F4, Ctrl+Esc, Ctrl+Alt+Delete, Linux
workspace and VT keys); macOS keycaps show Cmd/Option glyphs only and Windows/Linux keycaps
Ctrl/Alt words only; vector edit tool keys; counts pinned for the docs.
- Newly bound: A (Frame tool), Shift+' (pixel grid; Cmd+' kept, the articles disagree),
Alt+Cmd+\ / Ctrl+Alt+\ (others' cursors in a live session), Ctrl+F6 (toolbar focus on
Windows/Linux), Shift+Cmd+U (link text; Cmd+K kept), Cmd/Ctrl+] and [ (list levels while
editing), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection; F8 kept,
Cmd/Ctrl+arrows draw the box out), Ctrl+Shift+A / Ctrl+Alt+Shift+A (suggest auto layout),
Ctrl+Alt+Cmd+K / Ctrl+Alt+Shift+K (go to main component).
- New commands: `view.multiplayerCursors {on?}` (View menu item, menu breadth 207/207),
`view.pan {dx, dy, big?}` (arrows pan the view by 20 screen px, 200 with Shift, when nothing is
selected; the canvas and MCP `key` both use it), `text.listIndent` / `text.listOutdent`.
- Behaviour: Control held while dragging suspends snapping on macOS too (Cmd/Ctrl already did);
the presenter takes Space and N for the next frame, C for comments (Shift+C kept) and F for full
screen, after the prototype's own key triggers; the paint bucket's tooltip shows Shift+B (B still
works).
- Keyboard layouts: shortcuts follow the character typed, named as on a US keyboard. A symbol
another layout types with Shift (`/` is Shift+7 on German keyboards) now counts without the
Shift (canvas, text editing and F6 navigation share one lookup). Characters egui has no key for
already arrive as the key at that position.
- Keyboard shortcuts panel: new rows for panning with the arrows, suspending snapping, moving along
the toolbar, and the presentation keys, in the tabs the documentation groups them in (en/es
strings).
- Docs: ui-parity.md gets a Keyboard shortcuts section with the counts; target-app-parity.md,
ROADMAP.md, gaps.md (gap 4 landed list), mcp.md, parity-checklist.md (regenerated).
272 lines
13 KiB
Rust
272 lines
13 KiB
Rust
//! The keymap: every default shortcut parses, is unique where it's live, and names a command;
|
|
//! platform differences, key-name normalisation, display, and the commands that only keys reach
|
|
//! (opacity digits, the zoom tool, panel toggles).
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
use crate::cmd::edit::opacity_from_digits;
|
|
use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup, matches, normalize, parse, primary};
|
|
use crate::params::id_of;
|
|
use crate::tools::ToolId;
|
|
use figcraft_doc::NodeId;
|
|
|
|
const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other];
|
|
|
|
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
|
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
|
}
|
|
|
|
fn rect(s: &mut Session, x: f64, y: f64) -> NodeId {
|
|
id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 10, "height": 10}))["id"]).unwrap()
|
|
}
|
|
|
|
fn mods(shift: bool, alt: bool, cmd: bool, ctrl: bool) -> Mods {
|
|
Mods { shift, alt, cmd, ctrl }
|
|
}
|
|
|
|
fn id_for(p: Platform, c: Context, key: &str, m: Mods) -> Option<&'static str> {
|
|
lookup(p, c, key, m).map(|b| b.id)
|
|
}
|
|
|
|
#[test]
|
|
fn every_binding_parses_and_names_a_command() {
|
|
for b in KEYS {
|
|
assert!(!b.mac.is_empty(), "{} has no keys", b.id);
|
|
for p in PLATFORMS {
|
|
for k in b.keys(p) {
|
|
assert!(parse(k).is_some(), "{}: `{k}` doesn't parse", b.id);
|
|
assert!(!display(k, p).is_empty(), "{}: `{k}`", b.id);
|
|
}
|
|
}
|
|
// An engine command, or a menu item the UI handles (file dialogs, clipboard formats).
|
|
let in_menu = crate::catalog::items().any(|m| m.id == b.id);
|
|
assert!(crate::find_command(b.id).is_some() || in_menu, "{} is neither a command nor a menu item", b.id);
|
|
assert!(!b.title().is_empty());
|
|
}
|
|
assert!(Group::ALL.iter().all(|g| KEYS.iter().any(|b| b.group == *g)), "every panel tab has shortcuts");
|
|
assert!(GESTURES.iter().all(|(_, l, m, o)| !l.is_empty() && !m.is_empty() && !o.is_empty()));
|
|
}
|
|
|
|
/// The chord a binding fires on, as the platform sees it (Ctrl and Cmd are one key off macOS).
|
|
fn chord(p: Platform, spec: &str) -> (bool, bool, bool, bool, String) {
|
|
let (m, k) = parse(spec).unwrap();
|
|
let (cmd, ctrl) = match p {
|
|
Platform::Mac => (m.cmd, m.ctrl),
|
|
Platform::Other => (m.cmd || m.ctrl, false),
|
|
};
|
|
(m.shift, m.alt, cmd, ctrl, k.to_ascii_uppercase())
|
|
}
|
|
|
|
fn overlap(a: Context, b: Context) -> bool {
|
|
a == b || a == Context::Both || b == Context::Both
|
|
}
|
|
|
|
#[test]
|
|
fn no_two_commands_share_a_chord_where_both_are_live() {
|
|
for p in PLATFORMS {
|
|
for (i, a) in KEYS.iter().enumerate() {
|
|
for b in KEYS.iter().skip(i + 1).filter(|b| b.id != a.id && overlap(a.context, b.context)) {
|
|
for ka in a.keys(p) {
|
|
for kb in b.keys(p) {
|
|
assert_ne!(chord(p, ka), chord(p, kb), "{p:?}: `{ka}` ({}) and `{kb}` ({}) collide", a.id, b.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn key_names_normalise() {
|
|
assert_eq!(normalize("Plus"), Some(("=", true)));
|
|
assert_eq!(normalize("Questionmark"), Some(("/", true)));
|
|
assert_eq!(normalize("Equals"), Some(("=", false)));
|
|
assert_eq!(normalize("Backspace"), Some(("Delete", false)));
|
|
assert_eq!(normalize("Left"), Some(("ArrowLeft", false)));
|
|
assert_eq!(normalize("Exclamationmark"), Some(("1", true)));
|
|
assert_eq!(normalize("PageDown"), Some(("PageDown", false)));
|
|
assert_eq!(normalize("Bogus"), None);
|
|
assert_eq!(normalize(""), None);
|
|
assert_eq!(parse("Ctrl+Shift+?").map(|(m, k)| (m.shift, m.ctrl, k)), Some((true, true, "/")));
|
|
assert!(parse("Cmd+").is_none());
|
|
assert!(parse("A+B").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn lookups_follow_the_platform_and_context() {
|
|
let none = Mods::default();
|
|
let shift = mods(true, false, false, false);
|
|
for p in PLATFORMS {
|
|
assert_eq!(id_for(p, Context::Canvas, "0", shift), Some("view.zoom100"));
|
|
assert_eq!(id_for(p, Context::Canvas, "Exclamationmark", shift), Some("view.zoomFit"));
|
|
assert_eq!(id_for(p, Context::Canvas, "2", shift), Some("view.zoomSelection"));
|
|
// `+` comes as its own key, with or without Shift held.
|
|
assert_eq!(id_for(p, Context::Canvas, "Plus", none), Some("view.zoomIn"));
|
|
assert_eq!(id_for(p, Context::Canvas, "Plus", shift), Some("view.zoomIn"));
|
|
assert_eq!(id_for(p, Context::Canvas, "Minus", none), Some("view.zoomOut"));
|
|
assert_eq!(id_for(p, Context::Canvas, "A", mods(false, true, false, false)), Some("align.left"));
|
|
assert_eq!(id_for(p, Context::Canvas, "Slash", mods(false, true, false, false)), Some("object.removeFill"));
|
|
assert_eq!(id_for(p, Context::Canvas, "Questionmark", shift), Some("object.removeStroke"));
|
|
assert_eq!(id_for(p, Context::Canvas, "U", mods(true, true, false, false)), Some("bool.union"));
|
|
assert_eq!(id_for(p, Context::Canvas, "V", none), Some("tool.move"));
|
|
// Cmd+K opens Actions on the canvas and makes a link while editing text.
|
|
assert_eq!(id_for(p, Context::Canvas, "K", mods(false, false, true, false)), Some("app.actions"));
|
|
assert_eq!(id_for(p, Context::Text, "K", mods(false, false, true, false)), Some("text.link"));
|
|
// Formatting works in both; tools and canvas commands don't fire while typing.
|
|
assert_eq!(id_for(p, Context::Text, "B", mods(false, false, true, false)), Some("text.bold"));
|
|
assert_eq!(id_for(p, Context::Canvas, "B", mods(false, false, true, false)), Some("text.bold"));
|
|
assert_eq!(id_for(p, Context::Text, "V", none), None);
|
|
assert_eq!(id_for(p, Context::Text, "A", mods(false, true, false, false)), None);
|
|
}
|
|
let mac = Platform::Mac;
|
|
let other = Platform::Other;
|
|
// Use as mask: ⌃⌘M on macOS, Ctrl+Alt+M elsewhere.
|
|
assert_eq!(id_for(mac, Context::Canvas, "M", mods(false, false, true, true)), Some("object.mask"));
|
|
assert_eq!(id_for(other, Context::Canvas, "M", mods(false, true, true, true)), Some("object.mask"));
|
|
// Bring to front: ⌥⌘] on macOS, Ctrl+Shift+] elsewhere (`}` with Shift).
|
|
assert_eq!(id_for(mac, Context::Canvas, "CloseBracket", mods(false, true, true, false)), Some("arrange.front"));
|
|
assert_eq!(id_for(other, Context::Canvas, "CloseCurlyBracket", mods(true, false, true, true)), Some("arrange.front"));
|
|
// Distribute and tidy up differ too.
|
|
assert_eq!(id_for(mac, Context::Canvas, "H", mods(false, true, false, true)), Some("distribute.horizontal"));
|
|
assert_eq!(id_for(other, Context::Canvas, "H", mods(true, true, false, false)), Some("distribute.horizontal"));
|
|
assert_eq!(id_for(mac, Context::Canvas, "T", mods(false, true, false, true)), Some("arrange.tidy"));
|
|
assert_eq!(id_for(other, Context::Canvas, "T", mods(true, true, true, true)), Some("arrange.tidy"));
|
|
assert_eq!(id_for(other, Context::Canvas, "T", mods(false, true, true, true)), Some("text.alignCenter"));
|
|
// macOS tells ⌃ from ⌘: ⌃G is layout grids, ⌘G groups; ⌃C picks a colour, ⌘C copies.
|
|
assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, false, true)), Some("view.layoutGrids"));
|
|
assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, true, false)), Some("object.group"));
|
|
assert_eq!(id_for(mac, Context::Canvas, "C", mods(false, false, false, true)), Some("edit.pickColor"));
|
|
assert_eq!(id_for(other, Context::Canvas, "C", mods(false, false, true, true)), Some("edit.copy"));
|
|
assert_eq!(id_for(other, Context::Canvas, "4", mods(true, false, true, true)), Some("view.layoutGrids"));
|
|
// Off macOS Ctrl may come as cmd, ctrl or both.
|
|
for (cmd, ctrl) in [(true, false), (false, true), (true, true)] {
|
|
assert_eq!(id_for(other, Context::Canvas, "Z", mods(false, false, cmd, ctrl)), Some("edit.undo"));
|
|
}
|
|
assert!(matches("Shift+Cmd+O", mac, "O", mods(true, false, true, false)));
|
|
assert!(!matches("Shift+Cmd+O", mac, "O", mods(true, false, true, true)), "an extra ⌃ is another chord on macOS");
|
|
}
|
|
|
|
#[test]
|
|
fn display_per_platform() {
|
|
let (mac, other) = (Platform::Mac, Platform::Other);
|
|
assert_eq!(display("Shift+Cmd+K", mac), "⇧⌘K");
|
|
assert_eq!(display("Shift+Cmd+K", other), "Ctrl+Shift+K");
|
|
assert_eq!(display("Ctrl+Cmd+M", mac), "⌃⌘M");
|
|
assert_eq!(display("Ctrl+Alt+M", other), "Ctrl+Alt+M");
|
|
assert_eq!(display("Alt+Cmd+Enter", mac), "⌥⌘↵");
|
|
assert_eq!(display("Alt+Cmd+Enter", other), "Ctrl+Alt+Enter");
|
|
assert_eq!(display("Cmd+=", mac), "⌘+");
|
|
assert_eq!(display("Shift+=", other), "+");
|
|
assert_eq!(display("Delete", mac), "⌫");
|
|
assert_eq!(display("Alt", other), "Alt");
|
|
assert_eq!(display("Cmd", mac), "⌘");
|
|
assert_eq!(display("", mac), "");
|
|
assert_eq!(primary("object.mask", other), Some("Ctrl+Alt+M"));
|
|
assert_eq!(primary("object.mask", mac), Some("Ctrl+Cmd+M"));
|
|
assert_eq!(primary("view.outlines", mac), Some("Shift+Cmd+O"));
|
|
assert_eq!(primary("polygon.none", mac), None);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_keys_come_from_the_keymap() {
|
|
assert_eq!(ToolId::Move.shortcut(), "V");
|
|
assert_eq!(ToolId::Section.shortcut(), "Shift+S");
|
|
assert_eq!(ToolId::Eyedropper.shortcut(), "I");
|
|
assert_eq!(ToolId::Polygon.shortcut(), "");
|
|
assert_eq!(crate::catalog::shortcut("tool.text"), Some("T"));
|
|
}
|
|
|
|
#[test]
|
|
fn opacity_digits() {
|
|
assert_eq!(opacity_from_digits("5"), Some(0.5));
|
|
assert_eq!(opacity_from_digits("0"), Some(1.0));
|
|
assert_eq!(opacity_from_digits("45"), Some(0.45));
|
|
assert_eq!(opacity_from_digits("05"), Some(0.05));
|
|
assert_eq!(opacity_from_digits("00"), Some(0.0));
|
|
assert_eq!(opacity_from_digits(""), None);
|
|
assert_eq!(opacity_from_digits("123"), None);
|
|
assert_eq!(opacity_from_digits("4x"), None);
|
|
assert_eq!(opacity_from_digits("٣"), None, "ASCII digits only");
|
|
|
|
let mut s = Session::new();
|
|
let a = rect(&mut s, 0.0, 0.0);
|
|
let b = rect(&mut s, 20.0, 0.0);
|
|
ex(&mut s, "select.set", json!({"ids": [a, b]}));
|
|
ex(&mut s, "object.opacityKeys", json!({"digits": "3"}));
|
|
let op = |s: &Session, id| s.doc().unwrap().node(id).unwrap().opacity;
|
|
assert_eq!((op(&s, a), op(&s, b)), (0.3, 0.3));
|
|
ex(&mut s, "object.opacityKeys", json!({"digits": "45", "ids": [a]}));
|
|
assert_eq!((op(&s, a), op(&s, b)), (0.45, 0.3));
|
|
ex(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(op(&s, a), 0.3, "one undo step");
|
|
assert!(s.execute("object.opacityKeys", &json!({"digits": "123"})).is_err());
|
|
assert!(s.execute("object.opacityKeys", &json!({})).is_err());
|
|
ex(&mut s, "select.none", json!({}));
|
|
assert!(s.execute("object.opacityKeys", &json!({"digits": "1"})).is_err(), "nothing selected");
|
|
}
|
|
|
|
#[test]
|
|
fn zoom_tool_steps_about_a_point_and_fits_an_area() {
|
|
let mut s = Session::new();
|
|
ex(&mut s, "view.set", json!({"zoom": 1.0, "cx": 0, "cy": 0, "vw": 1000, "vh": 800}));
|
|
// A click at (100, 50) zooms in a step and keeps that point where it was on screen.
|
|
let before = s.ds().unwrap().view.page_to_screen(100.0, 50.0);
|
|
let v = ex(&mut s, "view.zoomAt", json!({"x": 100, "y": 50}));
|
|
assert_eq!(v["zoom"], 2.0);
|
|
let after = s.ds().unwrap().view.page_to_screen(100.0, 50.0);
|
|
assert!((before.0 - after.0).abs() < 1e-6 && (before.1 - after.1).abs() < 1e-6);
|
|
let v = ex(&mut s, "view.zoomAt", json!({"x": 100, "y": 50, "out": true}));
|
|
assert_eq!(v["zoom"], 1.0);
|
|
// A dragged area fills the view.
|
|
let v = ex(&mut s, "view.zoomAt", json!({"x": 0, "y": 0, "width": 100, "height": 50}));
|
|
assert!(v["zoom"].as_f64().unwrap() > 5.0);
|
|
assert_eq!((v["cx"].as_f64(), v["cy"].as_f64()), (Some(50.0), Some(25.0)));
|
|
// Degenerate areas zoom a step instead; junk is refused.
|
|
let z = s.ds().unwrap().view.zoom;
|
|
ex(&mut s, "view.zoomAt", json!({"x": 0, "y": 0, "width": 0, "height": 50}));
|
|
assert!(s.ds().unwrap().view.zoom > z);
|
|
assert!(s.execute("view.zoomAt", &json!({"x": "a", "y": 0})).is_err());
|
|
assert!(s.execute("view.zoomAt", &json!({"y": 0})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn panel_toggles_request_the_ui() {
|
|
let mut s = Session::new();
|
|
for (cmd, kind) in
|
|
[("view.designPrototype", "panel.designPrototype"), ("view.devMode", "panel.devMode"), ("view.leftSidebar", "panel.leftSidebar")]
|
|
{
|
|
s.ui_requests.clear();
|
|
ex(&mut s, cmd, json!({}));
|
|
assert!(s.ui_requests.iter().any(|r| r.kind == kind), "{cmd}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn vector_edit_paint_bucket_is_b() {
|
|
use crate::vectoredit::VTool;
|
|
let mut s = Session::new();
|
|
let r = rect(&mut s, 0.0, 0.0);
|
|
ex(&mut s, "vector.edit.begin", json!({"id": r}));
|
|
assert!(crate::vectoredit::key(&mut s, "B", false, false, false).unwrap());
|
|
assert_eq!(s.ds().unwrap().vector_edit.as_ref().unwrap().tool, VTool::Paint);
|
|
assert_eq!(VTool::Paint.shortcut(), "Shift+B");
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_tab_cycles_documents() {
|
|
let mut s = Session::new();
|
|
ex(&mut s, "file.new", json!({}));
|
|
ex(&mut s, "file.new", json!({}));
|
|
let n = s.docs.len();
|
|
assert!(n >= 3);
|
|
let start = s.active;
|
|
ex(&mut s, "doc.nextTab", json!({}));
|
|
assert_eq!(s.active, (start + 1) % n);
|
|
ex(&mut s, "doc.previousTab", json!({}));
|
|
ex(&mut s, "doc.previousTab", json!({}));
|
|
assert_eq!(s.active, (start + n - 1) % n, "wraps around");
|
|
assert_eq!(lookup(Platform::Other, Context::Canvas, "Tab", mods(false, false, true, true)).map(|b| b.id), Some("doc.nextTab"));
|
|
assert_eq!(lookup(Platform::Mac, Context::Canvas, "Tab", mods(true, false, false, true)).map(|b| b.id), Some("doc.previousTab"));
|
|
}
|