Files
figcraft/crates/engine/src/keymap_tests.rs
T
bflatastic 8d5f8a0978 M1: keyboard shortcut parity suite and bindings
- 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).
2026-10-12 06:49:02 +07:00

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"));
}