diff --git a/ROADMAP.md b/ROADMAP.md index 5cd4084..56c8864 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | | Value | Kind | |---|---|---| -| Menu/command breadth | **100%** (204 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | +| Menu/command breadth | **100%** (207 / 207) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | | Feature breadth | **~84%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | @@ -131,6 +131,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129 + shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it + is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on + Windows, 2 missing. New: others' cursors toggle, list levels on Cmd/Ctrl+] and [ while editing, + keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, Control + suspending snapping on macOS, Space/N/C/F while presenting, the Frame tool on A, pixel grid on + Shift+', link on Shift+Cmd+U, ungroup on Cmd/Ctrl+Backspace, suggest auto layout and go to main + component keys; shifted symbols on other keyboard layouts. - **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key (`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are @@ -237,6 +245,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | | 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | | 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index efdb322..425ed79 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -87,6 +87,7 @@ pub static MENU: &[MenuItem] = &[ m!("View", "Rulers", "view.rulers"), m!("View", "Slices", "view.slices"), m!("View", "Comments", "view.comments"), + m!("View", "Multiplayer cursors", "view.multiplayerCursors"), m!("View", SEP, ""), m!("View/Outlines", "Show outlines", "view.outlines"), m!("View/Outlines", "Show outlines for selection", "view.outlinesSelection"), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 6224276..ffc6807 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -86,7 +86,7 @@ pub fn specs() -> Vec { spec!( "select.area", "Select area", - "{x, y, width, height (page units; a marquee from x, y), add? (toggle like a Shift marquee), deep? (nested layers, like Cmd/Ctrl)} → {selection}; with no box: draw one from the keyboard in the app (arrows move it, Shift+arrows size it, Enter selects, Escape cancels)", + "{x, y, width, height (page units; a marquee from x, y), add? (toggle like a Shift marquee), deep? (nested layers, like Cmd/Ctrl)} → {selection}; with no box: draw one from the keyboard in the app (arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it, Enter selects, Escape cancels)", false, select_area ), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 40fde81..14ea176 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -402,6 +402,12 @@ pub fn specs() -> Vec { ok() } ), + spec!("text.listIndent", "Indent list", "{} while editing: the selected list items a level deeper", true, |s, _| { + Ok(json!({"changed": textedit::list_indent(s, false)?})) + }), + spec!("text.listOutdent", "Outdent list", "{} while editing: the selected list items a level out", true, |s, _| { + Ok(json!({"changed": textedit::list_indent(s, true)?})) + }), spec!("text.edit.caret", "Set caret", "{index, extend?}", false, |s, p| { let i = req_num("text.edit.caret", p, "index")?.max(0.0) as usize; textedit::set_caret(s, i, boolean(p, "extend").unwrap_or(false))?; diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 757b50c..0c6000a 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -43,6 +43,20 @@ fn zoom_to(s: &mut Session, z: f64) -> Result { Ok(json!({"zoom": v.zoom})) } +/// How far an arrow key pans the view with nothing selected (screen pixels; Shift: ten times). +pub const PAN_STEP_PX: f64 = 20.0; + +/// `view.pan`: the arrow keys with nothing selected move the view by keyboard steps. +fn pan(s: &mut Session, p: &Value) -> Result { + let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * 10.0 } else { PAN_STEP_PX }; + let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step; + let (dx, dy) = (dir("dx"), dir("dy")); + let v = view(s)?; + // Scrolling moves the content; panning right shows what is to the right. + v.scroll(-dx, -dy, false); + Ok(json!({"cx": v.cx, "cy": v.cy})) +} + /// Figma's zoom steps. const STEPS: &[f64] = &[0.02, 0.03, 0.06, 0.13, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0]; @@ -192,6 +206,10 @@ pub fn specs() -> Vec { spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)), spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)), spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)), + spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| { + flag(s, p, |v| &mut v.multiplayer_cursors) + }), + spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan), spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)), spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { flag(s, p, |v| &mut v.outlines_selection) diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 146453a..4affabd 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -179,7 +179,7 @@ pub static KEYS: &[Binding] = &[ k(Tools, "tool.move", &["V"]), k(Tools, "tool.scale", &["K"]), k(Tools, "tool.hand", &["H"]), - k(Tools, "tool.frame", &["F"]), + k(Tools, "tool.frame", &["F", "A"]), k(Tools, "tool.section", &["Shift+S"]), k(Tools, "tool.slice", &["S"]), k(Tools, "tool.rectangle", &["R"]), @@ -193,12 +193,14 @@ pub static KEYS: &[Binding] = &[ k(Tools, "tool.comment", &["C"]), k(Tools, "tool.shapeBuilder", &["Shift+M"]), // View - k(View, "view.pixelGrid", &["Cmd+'"]), + // The documentation gives both keys for the pixel grid in different places. + k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]), k(View, "view.layoutGrids", &["Shift+G", "Ctrl+G"]).win(&["Shift+G", "Ctrl+Shift+4"]), k(View, "view.rulers", &["Shift+R"]), k(View, "view.outlines", &["Shift+Cmd+O"]), k(View, "view.pixelPreview", &["Ctrl+P"]).win(&["Ctrl+Alt+P"]), k(View, "view.comments", &["Shift+C"]), + k(View, "view.multiplayerCursors", &["Alt+Cmd+\\"]), k(View, "view.snapPixel", &["Shift+Cmd+'"]), k(View, "view.layers", &["Alt+1"]).label("Layers panel"), k(View, "view.assets", &["Alt+2"]).label("Assets panel"), @@ -208,7 +210,7 @@ pub static KEYS: &[Binding] = &[ k(View, "view.designPrototype", &["Shift+E"]), k(View, "view.devMode", &["Shift+D"]).label("Dev Mode on/off"), k(View, "view.leftSidebar", &["Shift+Cmd+\\"]), - k(View, "view.nextRegion", &["F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(), + k(View, "view.nextRegion", &["F6"]).win(&["F6", "Ctrl+F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(), k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(), k(View, "view.contextMenu", &["Shift+F10"]).label("Context menu of the selection (the focused layer row or the canvas)"), k(View, "layers.collapseAll", &["Alt+L"]), @@ -229,9 +231,11 @@ pub static KEYS: &[Binding] = &[ k(Text, "text.italic", &["Cmd+I"]).both(), k(Text, "text.underline", &["Cmd+U"]).both(), k(Text, "text.strikethrough", &["Shift+Cmd+X"]).both(), - k(Text, "text.link", &["Cmd+K"]).text(), + k(Text, "text.link", &["Shift+Cmd+U", "Cmd+K"]).text(), k(Text, "text.bulletList", &["Shift+Cmd+8"]).both(), k(Text, "text.numberList", &["Shift+Cmd+7"]).both(), + k(Text, "text.listIndent", &["Cmd+]"]).text(), + k(Text, "text.listOutdent", &["Cmd+["]).text(), k(Text, "text.alignLeft", &["Alt+Cmd+L"]).both(), k(Text, "text.alignCenter", &["Alt+Cmd+T"]).both(), k(Text, "text.alignRight", &["Alt+Cmd+R"]).both(), @@ -267,12 +271,15 @@ pub static KEYS: &[Binding] = &[ k(Selection, "select.next", &["Tab"]), k(Selection, "select.previous", &["Shift+Tab"]), k(Selection, "object.group", &["Cmd+G"]), - k(Selection, "object.ungroup", &["Shift+Cmd+G"]), + k(Selection, "object.ungroup", &["Shift+Cmd+G", "Cmd+Delete"]), k(Selection, "object.frameSelection", &["Alt+Cmd+G"]), k(Selection, "object.toggleVisible", &["Shift+Cmd+H"]), k(Selection, "object.toggleLock", &["Shift+Cmd+L"]), k(Selection, "object.rename", &["Cmd+R", "F2"]), - k(Selection, "select.area", &["F8"]).label("Select with a box from the keyboard (arrows move it, Shift+arrows size it, Enter selects)"), + // Alt+Space opens the window menu on Windows; the documented Windows key is Ctrl+Space. + k(Selection, "select.area", &["Alt+Space", "F8"]) + .win(&["Ctrl+Space", "F8"]) + .label("Select with a box from the keyboard (arrows move it, Shift+arrows size it, Enter selects)"), // Edit k(Edit, "edit.undo", &["Cmd+Z"]), k(Edit, "edit.redo", &["Shift+Cmd+Z"]), @@ -309,9 +316,11 @@ pub static KEYS: &[Binding] = &[ k(Arrange, "arrange.tidy", &["Ctrl+Alt+T"]).win(&["Ctrl+Alt+Shift+T"]), k(Arrange, "layout.add", &["Shift+A"]), k(Arrange, "layout.remove", &["Alt+Shift+A"]), + k(Arrange, "layout.suggest", &["Ctrl+Shift+A"]).win(&["Ctrl+Alt+Shift+A"]), // Components k(Components, "component.create", &["Alt+Cmd+K"]), k(Components, "component.detach", &["Alt+Cmd+B"]), + k(Components, "component.goToMain", &["Ctrl+Alt+Cmd+K"]).win(&["Ctrl+Alt+Shift+K"]), k(Components, "view.assets", &["Shift+I"]).label("Insert a component (Assets)"), // Prototype k(Prototype, "proto.present", &["Alt+Cmd+Enter"]).label("Present"), @@ -322,6 +331,7 @@ pub static KEYS: &[Binding] = &[ /// dispatched from here): (group, label, macOS keys, Windows/Linux keys). pub static GESTURES: &[(Group, &str, &str, &str)] = &[ (Tools, "Pan the canvas", "Space drag", "Space+drag"), + (Tools, "Pan with nothing selected (Shift: farther)", "← ↑ → ↓", "← ↑ → ↓"), (Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"), (Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"), (Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"), @@ -332,7 +342,15 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[ (Transform, "Resize by the nudge amount", "⌘ arrows", "Ctrl+arrows"), (Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"), (Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"), + (Transform, "Suspend snapping while dragging", "Hold ⌃ or ⌘", "Hold Ctrl"), + (Prototype, "Presenting: next frame", "→, Space or N", "→, Space or N"), + (Prototype, "Presenting: previous frame", "←", "←"), + (Prototype, "Presenting: restart", "R", "R"), + (Prototype, "Presenting: cycle the size options", "Z", "Z"), + (Prototype, "Presenting: comments", "C or ⇧C", "C or Shift+C"), + (Prototype, "Presenting: full screen", "F", "F"), (View, "Next or previous control in the focused region", "⇥ / ⇧⇥", "Tab / Shift+Tab"), + (View, "Move along the toolbar or the layer rows", "← → ↑ ↓", "← → ↑ ↓"), (View, "Back to the canvas from a focused region", "Esc", "Esc"), ]; diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 7ff351b..8f6f28c 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -250,7 +250,7 @@ fn vector_edit_paint_bucket_is_b() { 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(), "B"); + assert_eq!(VTool::Paint.shortcut(), "Shift+B"); } #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e5e8141..624ac03 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -143,6 +143,8 @@ pub struct ViewState { pub pixel_scale: u8, pub show_slices: bool, pub comments: bool, + /// Others' cursors in a live session (View ▸ Multiplayer cursors). + pub multiplayer_cursors: bool, pub frame_outlines: bool, /// Soft-proof sRGB: the canvas marks the colours of a Display P3 file that sRGB can't show. pub proof_srgb: bool, @@ -170,6 +172,7 @@ impl Default for ViewState { pixel_scale: 1, show_slices: false, comments: true, + multiplayer_cursors: true, frame_outlines: false, proof_srgb: false, snap_geometry: true, @@ -1404,6 +1407,8 @@ mod shapebuilder_tests; #[cfg(test)] mod shapes_behaviour_tests; #[cfg(test)] +mod shortcut_parity_tests; +#[cfg(test)] mod slot_tests; #[cfg(test)] mod stroke_style_tests; diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs new file mode 100644 index 0000000..a970bce --- /dev/null +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -0,0 +1,449 @@ +//! Keyboard shortcut parity with the public shortcut documentation, per platform. One row per +//! documented shortcut: the action in our own words, the macOS and the Windows/Linux keys as the +//! documentation gives them, what does it here (a keymap command, a held key or gesture the panel +//! lists, a vector edit tool key, or nothing yet) and a note. Rows the documentation is silent on +//! pin today's keys, so every default binding is in this table: changing one means changing a row. +//! +//! The documentation is spread over the help centre's feature articles (keyboard layouts, text +//! properties, alignment, frames, components, prototypes, plugins, accessibility…); where two +//! articles disagree the note says so and both keys are bound when they don't collide. + +use serde_json::json; + +use crate::Session; +use crate::keymap::Group::{self, *}; +use crate::keymap::{Context, GESTURES, KEYS, Platform, display, lookup, parse}; +use crate::params::id_of; +use crate::vectoredit::VTool; + +const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other]; + +/// What answers a documented shortcut here. +#[derive(Clone, Copy, Debug)] +enum Ours { + /// The keymap binds the documented keys to this command. + Bound(&'static str), + /// Bound to this command on other keys where the documented ones collide with another + /// documented shortcut: (command, macOS keys, Windows/Linux keys; empty: the macOS keys). + Moved(&'static str, &'static [&'static str], &'static [&'static str]), + /// A held key, pointer gesture or presentation key; the shortcuts panel lists it under this label. + Gesture(&'static str), + /// A tool key in vector edit mode. + VectorTool(VTool), + /// No such behaviour yet. + Missing, +} +use Ours::*; + +struct Row { + /// The shortcuts panel tab the row belongs to. + group: Group, + action: &'static str, + mac: &'static [&'static str], + /// Empty: the macOS keys with Cmd read as Ctrl. + win: &'static [&'static str], + ours: Ours, + documented: bool, + /// Who takes the keys before the app on some platform (empty: nobody). + reserved: &'static str, + note: &'static str, +} + +impl Row { + const fn reserved(mut self, by: &'static str) -> Self { + self.reserved = by; + self + } + fn keys(&self, p: Platform) -> &'static [&'static str] { + if p == Platform::Other && !self.win.is_empty() { self.win } else { self.mac } + } + /// The keys that fire the row's command here, and the command. + fn ours(&self, p: Platform) -> Option<(&'static str, &'static [&'static str])> { + match self.ours { + Bound(id) => Some((id, self.keys(p))), + Moved(id, mac, win) => Some((id, if p == Platform::Other && !win.is_empty() { win } else { mac })), + _ => None, + } + } +} + +const fn doc(group: Group, action: &'static str, mac: &'static [&'static str], win: &'static [&'static str], ours: Ours, note: &'static str) -> Row { + Row { group, action, mac, win, ours, documented: true, reserved: "", note } +} + +/// A binding the documentation is silent on: today's keys, pinned. +const fn pin(group: Group, id: &'static str, mac: &'static [&'static str], win: &'static [&'static str]) -> Row { + Row { group, action: id, mac, win, ours: Bound(id), documented: false, reserved: "", note: "documentation silent: pinned" } +} + +const BROWSER_TAB: &str = "browsers keep it for their tabs and windows (web build)"; + +#[rustfmt::skip] +static ROWS: &[Row] = &[ + // Essential + doc(Essential, "Open the actions menu", &["Cmd+K", "Cmd+/"], &[], Bound("app.actions"), "Cmd+/ is in the keyboard layout tables"), + doc(Essential, "Open the keyboard shortcuts panel", &["Ctrl+Shift+?"], &[], Bound("help.shortcuts"), "Control on macOS too (Shift+Cmd+? is the system's Help search)"), + doc(Essential, "Show or hide the interface", &["Cmd+\\"], &[], Bound("view.toggleUi"), ""), + doc(Essential, "Find layers and text", &["Cmd+F"], &[], Bound("edit.findReplace"), ""), + doc(Essential, "Chat at your cursor in a live session", &["/"], &[], Bound("collab.cursorChat"), ""), + doc(Essential, "Sample a colour", &["I", "Ctrl+C"], &["I"], Bound("edit.pickColor"), "Control+C on macOS only (Ctrl+C copies elsewhere)"), + doc(Essential, "Save to version history", &["Alt+Cmd+S"], &["Ctrl+Alt+S"], Bound("file.saveVersion"), ""), + doc(Essential, "Run the last plugin", &["Alt+Cmd+P"], &["Ctrl+Alt+P"], Moved("plugins.runLast", &["Alt+Cmd+P"], &["Ctrl+Alt+Shift+P"]), + "conflict: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; pixel preview keeps it, the plugin takes Ctrl+Alt+Shift+P"), + pin(Essential, "app.preferences", &["Cmd+,"], &[]), + pin(Essential, "file.new", &["Cmd+N"], &[]).reserved(BROWSER_TAB), + pin(Essential, "file.open", &["Cmd+O"], &[]), + pin(Essential, "file.save", &["Cmd+S"], &[]), + pin(Essential, "file.saveAs", &["Shift+Cmd+S"], &[]), + pin(Essential, "file.close", &["Cmd+W"], &[]).reserved(BROWSER_TAB), + // Tools + doc(Tools, "Move tool", &["V"], &[], Bound("tool.move"), ""), + doc(Tools, "Scale tool", &["K"], &[], Bound("tool.scale"), ""), + doc(Tools, "Frame tool", &["F", "A"], &[], Bound("tool.frame"), "A was unbound before"), + doc(Tools, "Rectangle tool", &["R"], &[], Bound("tool.rectangle"), ""), + doc(Tools, "Line tool", &["L"], &[], Bound("tool.line"), ""), + doc(Tools, "Arrow tool", &["Shift+L"], &[], Bound("tool.arrow"), ""), + doc(Tools, "Ellipse tool", &["O"], &[], Bound("tool.ellipse"), ""), + doc(Tools, "Pen tool", &["P"], &[], Bound("tool.pen"), ""), + doc(Tools, "Pencil tool", &["Shift+P"], &[], Bound("tool.pencil"), ""), + doc(Tools, "Text tool", &["T"], &[], Bound("tool.text"), ""), + doc(Tools, "Comment mode", &["C"], &[], Bound("tool.comment"), ""), + doc(Tools, "Hand tool while held", &["Space"], &[], Gesture("Pan the canvas"), "held, not pressed"), + doc(Tools, "Pan with the arrows when nothing is selected (Shift: farther)", &["ArrowRight"], &[], Gesture("Pan with nothing selected (Shift: farther)"), + "view.pan; nudges move the selection when there is one"), + pin(Tools, "tool.hand", &["H"], &[]), + pin(Tools, "tool.section", &["Shift+S"], &[]), + pin(Tools, "tool.slice", &["S"], &[]), + pin(Tools, "file.placeImage", &["Shift+Cmd+K"], &[]), + pin(Tools, "tool.shapeBuilder", &["Shift+M"], &[]), + // Vector edit mode + doc(Selection, "Edit the selected shape's points", &["Enter"], &[], Bound("select.children"), "Enter on a shape edits it; on a frame or group it selects the children"), + doc(Shape, "Vector edit: move", &["V"], &[], VectorTool(VTool::Move), ""), + doc(Shape, "Vector edit: pen", &["P"], &[], VectorTool(VTool::Pen), ""), + doc(Shape, "Vector edit: cut", &["X"], &[], VectorTool(VTool::Cut), ""), + doc(Shape, "Vector edit: eraser", &["Shift+E"], &[], VectorTool(VTool::Eraser), ""), + doc(Shape, "Vector edit: lasso", &["Q"], &[], VectorTool(VTool::Lasso), ""), + doc(Shape, "Vector edit: paint bucket", &["Shift+B"], &[], VectorTool(VTool::Paint), "B alone works too (pinned)"), + // View + doc(View, "Pixel grid", &["Shift+'", "Cmd+'"], &[], Bound("view.pixelGrid"), + "articles disagree (Shift+' in most, Cmd+' in the zoom article); both bound, Shift+' first"), + doc(View, "Snap to the pixel grid", &["Shift+Cmd+'"], &[], Bound("view.snapPixel"), ""), + doc(View, "Layout guides", &["Ctrl+G", "Shift+G"], &["Ctrl+Shift+4", "Shift+G"], Bound("view.layoutGrids"), ""), + doc(View, "Outline mode", &["Shift+Cmd+O"], &[], Bound("view.outlines"), ""), + doc(View, "Pixel preview", &["Ctrl+P"], &["Ctrl+Alt+P"], Bound("view.pixelPreview"), ""), + doc(View, "Others' cursors in a live session", &["Alt+Cmd+\\"], &["Ctrl+Alt+\\"], Bound("view.multiplayerCursors"), "new command"), + doc(View, "Hide the side panels (minimize the interface)", &["Shift+Cmd+\\"], &[], Bound("view.leftSidebar"), ""), + doc(View, "Layers panel", &["Alt+1"], &[], Bound("view.layers"), "one article writes Ctrl+1 for Windows, another Alt+1; Alt+1 bound"), + doc(View, "Assets panel", &["Alt+2"], &[], Bound("view.assets"), ""), + doc(View, "Libraries", &["Alt+3"], &[], Bound("libraries.open"), ""), + doc(View, "Dev Mode on or off", &["Shift+D"], &[], Bound("view.devMode"), ""), + doc(View, "Move the keyboard to the toolbar (then the next region)", &["F6"], &["Ctrl+F6"], Bound("view.nextRegion"), + "F6 works on Windows and Linux too; F6 and F8 need Fn on many Mac keyboards").reserved("browsers move to the address bar on F6 (web build)"), + doc(View, "Move along the toolbar and the layer rows", &["ArrowRight"], &[], Gesture("Move along the toolbar or the layer rows"), ""), + pin(View, "view.rulers", &["Shift+R"], &[]), + pin(View, "view.comments", &["Shift+C"], &[]), + pin(View, "view.design", &["Alt+8"], &[]), + pin(View, "view.prototype", &["Alt+9"], &[]), + pin(View, "view.designPrototype", &["Shift+E"], &[]), + pin(View, "view.nextRegion", &["F6"], &["F6"]), + pin(View, "view.previousRegion", &["Shift+F6"], &[]), + pin(View, "view.contextMenu", &["Shift+F10"], &[]), + pin(View, "layers.collapseAll", &["Alt+L"], &[]), + pin(View, "doc.nextTab", &["Ctrl+Tab"], &[]).reserved(BROWSER_TAB), + pin(View, "doc.previousTab", &["Ctrl+Shift+Tab"], &[]).reserved(BROWSER_TAB), + pin(View, "view.prevPage", &["PageUp"], &[]), + pin(View, "view.nextPage", &["PageDown"], &[]), + // Zoom + doc(Zoom, "Zoom in", &["Cmd+=", "Shift+="], &[], Bound("view.zoomIn"), "`+` with or without Cmd/Ctrl"), + doc(Zoom, "Zoom out", &["Cmd+-", "-"], &[], Bound("view.zoomOut"), ""), + doc(Zoom, "Zoom to fit", &["Shift+1"], &[], Bound("view.zoomFit"), ""), + doc(Zoom, "Zoom to the selection", &["Shift+2"], &[], Bound("view.zoomSelection"), ""), + pin(Zoom, "view.zoomIn", &["Shift+Cmd+="], &[]), + pin(Zoom, "view.zoomOut", &["Shift+-"], &[]), + pin(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"], &[]), + pin(Zoom, "view.prevFrame", &["Shift+N", "Home"], &[]), + pin(Zoom, "view.nextFrame", &["N", "End"], &[]), + // Text + doc(Text, "Bold", &["Cmd+B"], &[], Bound("text.bold"), ""), + doc(Text, "Italic", &["Cmd+I"], &[], Bound("text.italic"), ""), + doc(Text, "Underline", &["Cmd+U"], &[], Bound("text.underline"), ""), + doc(Text, "Strikethrough", &["Shift+Cmd+X"], &[], Bound("text.strikethrough"), ""), + doc(Text, "Link the selected text", &["Shift+Cmd+U"], &[], Bound("text.link"), "while editing; Cmd+K (the comment composer's link key) kept too"), + doc(Text, "Numbered list", &["Shift+Cmd+7"], &[], Bound("text.numberList"), ""), + doc(Text, "Bulleted list", &["Shift+Cmd+8"], &[], Bound("text.bulletList"), ""), + doc(Text, "List items a level deeper", &["Cmd+]"], &[], Bound("text.listIndent"), "while editing (Cmd+] brings forward otherwise); Tab too"), + doc(Text, "List items a level out", &["Cmd+["], &[], Bound("text.listOutdent"), "while editing; Shift+Tab too"), + doc(Text, "Larger text", &["Shift+Cmd+."], &[], Bound("text.sizeUp"), "written Shift+Cmd+>"), + doc(Text, "Smaller text", &["Shift+Cmd+,"], &[], Bound("text.sizeDown"), "written Shift+Cmd+<"), + doc(Text, "Heavier weight", &["Alt+Cmd+."], &[], Bound("text.weightUp"), ""), + doc(Text, "Lighter weight", &["Alt+Cmd+,"], &[], Bound("text.weightDown"), ""), + doc(Text, "More letter spacing", &["Alt+."], &[], Bound("text.trackingUp"), "the text article writes Alt; one layout table writes Cmd, which would be the font size keys"), + doc(Text, "Less letter spacing", &["Alt+,"], &[], Bound("text.trackingDown"), ""), + doc(Text, "More line height", &["Alt+Shift+."], &[], Bound("text.lineHeightUp"), ""), + doc(Text, "Less line height", &["Alt+Shift+,"], &[], Bound("text.lineHeightDown"), ""), + pin(Text, "text.link", &["Cmd+K"], &[]), + pin(Text, "text.alignLeft", &["Alt+Cmd+L"], &[]).reserved("some Linux desktops lock the screen on Ctrl+Alt+L"), + pin(Text, "text.alignCenter", &["Alt+Cmd+T"], &[]).reserved("Ubuntu opens a terminal on Ctrl+Alt+T"), + pin(Text, "text.alignRight", &["Alt+Cmd+R"], &[]), + pin(Text, "text.alignJustify", &["Alt+Cmd+J"], &[]), + // Shape + doc(Shape, "Union", &["Alt+Shift+U"], &[], Bound("bool.union"), ""), + doc(Shape, "Subtract", &["Alt+Shift+S"], &[], Bound("bool.subtract"), ""), + doc(Shape, "Intersect", &["Alt+Shift+I"], &[], Bound("bool.intersect"), ""), + doc(Shape, "Exclude", &["Alt+Shift+E"], &[], Bound("bool.exclude"), ""), + doc(Shape, "Flatten", &["Alt+Shift+F"], &[], Bound("vector.flatten"), ""), + doc(Shape, "Remove fill", &["Alt+/"], &[], Bound("object.removeFill"), "one layout table writes Alt+, for Windows; Alt+, is letter spacing"), + doc(Shape, "Remove stroke", &["Shift+/"], &[], Bound("object.removeStroke"), ""), + pin(Shape, "vector.flatten", &["Cmd+E"], &[]), + pin(Shape, "vector.outlineStroke", &["Alt+Cmd+O"], &[]), + pin(Shape, "object.swapFillStroke", &["Shift+X"], &[]), + pin(Shape, "vector.join", &["Cmd+J"], &[]), + pin(Shape, "vector.smoothJoin", &["Shift+Cmd+J"], &[]), + pin(Shape, "vector.deleteHeal", &["Shift+Delete"], &[]), + // Selection + doc(Selection, "Select all", &["Cmd+A"], &[], Bound("select.all"), ""), + doc(Selection, "Invert the selection", &["Shift+Cmd+A"], &[], Bound("select.inverse"), ""), + doc(Selection, "Select matching layers", &["Alt+Cmd+A"], &["Ctrl+Alt+A"], Bound("select.matching"), ""), + doc(Selection, "Deselect", &["Escape"], &[], Bound("select.none"), "steps out of editing first"), + doc(Selection, "Select the parent", &["Shift+Enter", "\\"], &[], Bound("select.parent"), ""), + doc(Selection, "Next sibling", &["Tab"], &[], Bound("select.next"), ""), + doc(Selection, "Previous sibling", &["Shift+Tab"], &[], Bound("select.previous"), ""), + doc(Selection, "Group", &["Cmd+G"], &[], Bound("object.group"), ""), + doc(Selection, "Ungroup", &["Shift+Cmd+G", "Cmd+Delete"], &[], Bound("object.ungroup"), "Cmd/Ctrl+Backspace was unbound before"), + doc(Selection, "Frame the selection", &["Alt+Cmd+G"], &["Ctrl+Alt+G"], Bound("object.frameSelection"), ""), + doc(Selection, "Hide or show", &["Shift+Cmd+H"], &[], Bound("object.toggleVisible"), ""), + doc(Selection, "Lock or unlock", &["Shift+Cmd+L"], &[], Bound("object.toggleLock"), ""), + doc(Selection, "Rename (several layers: batch rename)", &["Cmd+R"], &[], Bound("object.rename"), ""), + doc(Selection, "Keyboard box selection", &["Alt+Space"], &["Ctrl+Space"], Bound("select.area"), + "then the arrows move it, Cmd/Ctrl+arrows draw it out (Shift: farther), Enter selects, Escape closes; F8 kept") + .reserved("Ctrl+Space switches the input method with some Chinese, Japanese and Korean input methods on Windows and Linux"), + doc(Selection, "Select nested layers", &["Cmd"], &[], Gesture("Deep select"), "held while clicking or dragging"), + pin(Selection, "object.rename", &["F2"], &[]), + pin(Selection, "select.area", &["F8"], &[]), + // Edit + doc(Edit, "Undo", &["Cmd+Z"], &[], Bound("edit.undo"), ""), + doc(Edit, "Copy", &["Cmd+C"], &[], Bound("edit.copy"), ""), + doc(Edit, "Paste", &["Cmd+V"], &[], Bound("edit.paste"), ""), + doc(Edit, "Duplicate", &["Cmd+D"], &[], Bound("edit.duplicate"), ""), + doc(Edit, "Delete", &["Delete"], &[], Bound("edit.delete"), "Backspace or Delete"), + doc(Edit, "Paste over the selection", &["Shift+Cmd+V"], &[], Bound("edit.pasteOver"), ""), + doc(Edit, "Paste to replace", &["Shift+Cmd+R"], &[], Bound("edit.pasteReplace"), ""), + doc(Edit, "Copy as PNG", &["Shift+Cmd+C"], &[], Bound("edit.copyAsPng"), ""), + doc(Edit, "Copy all properties", &["Alt+Cmd+C"], &["Ctrl+Alt+C"], Bound("edit.copyProperties"), ""), + doc(Edit, "Paste all properties", &["Alt+Cmd+V"], &["Ctrl+Alt+V"], Bound("edit.pasteProperties"), ""), + doc(Edit, "Export", &["Shift+Cmd+E"], &[], Bound("file.export"), ""), + doc(Edit, "Duplicate by dragging", &["Alt"], &[], Gesture("Duplicate by dragging"), "held while dragging"), + pin(Edit, "edit.redo", &["Shift+Cmd+Z"], &[]), + pin(Edit, "edit.cut", &["Cmd+X"], &[]), + pin(Edit, "file.import", &["Shift+Cmd+I"], &[]), + // Transform + doc(Transform, "Flip horizontally", &["Shift+H"], &[], Bound("object.flipH"), ""), + doc(Transform, "Flip vertically", &["Shift+V"], &[], Bound("object.flipV"), ""), + doc(Transform, "Use as mask (again: release)", &["Ctrl+Cmd+M"], &["Ctrl+Alt+M"], Bound("object.mask"), ""), + doc(Transform, "Resize a frame to fit its contents", &["Alt+Shift+Cmd+R"], &["Ctrl+Alt+Shift+R"], Bound("object.resizeToFit"), ""), + doc(Transform, "Nudge (Shift: big nudge)", &["ArrowRight"], &[], Gesture("Nudge (Shift: big nudge)"), ""), + doc(Transform, "Suspend snapping while dragging", &["Ctrl"], &[], Gesture("Suspend snapping while dragging"), + "Control on macOS (Cmd keeps working); Ctrl elsewhere"), + doc(Transform, "Show the rotation origin to move it", &["Alt+R"], &[], Missing, "layers turn about their centre; no movable origin yet"), + // Arrange + doc(Arrange, "Bring forward", &["Cmd+]"], &[], Bound("arrange.forward"), ""), + doc(Arrange, "Send backward", &["Cmd+["], &[], Bound("arrange.backward"), ""), + doc(Arrange, "Bring to front", &["Alt+Cmd+]"], &["Ctrl+Shift+]"], Bound("arrange.front"), "one layout table shows only the bracket"), + doc(Arrange, "Send to back", &["Alt+Cmd+["], &["Ctrl+Shift+["], Bound("arrange.back"), ""), + doc(Arrange, "Align left", &["Alt+A"], &[], Bound("align.left"), ""), + doc(Arrange, "Align horizontal centres", &["Alt+H"], &[], Bound("align.hcenter"), ""), + doc(Arrange, "Align right", &["Alt+D"], &[], Bound("align.right"), ""), + doc(Arrange, "Align top", &["Alt+W"], &[], Bound("align.top"), ""), + doc(Arrange, "Align vertical centres", &["Alt+V"], &[], Bound("align.vcenter"), ""), + doc(Arrange, "Align bottom", &["Alt+S"], &[], Bound("align.bottom"), ""), + doc(Arrange, "Add auto layout", &["Shift+A"], &[], Bound("layout.add"), ""), + doc(Arrange, "Remove auto layout", &["Alt+Shift+A"], &[], Bound("layout.remove"), ""), + doc(Arrange, "Suggest auto layout", &["Ctrl+Shift+A"], &["Ctrl+Alt+Shift+A"], Bound("layout.suggest"), "was unbound"), + pin(Arrange, "distribute.horizontal", &["Ctrl+Alt+H"], &["Alt+Shift+H"]), + pin(Arrange, "distribute.vertical", &["Ctrl+Alt+V"], &["Alt+Shift+V"]), + pin(Arrange, "arrange.tidy", &["Ctrl+Alt+T"], &["Ctrl+Alt+Shift+T"]), + // Components + doc(Components, "Create a component", &["Alt+Cmd+K"], &["Ctrl+Alt+K"], Bound("component.create"), ""), + doc(Components, "Detach an instance", &["Alt+Cmd+B"], &["Ctrl+Alt+B"], Bound("component.detach"), ""), + doc(Components, "Go to the main component", &["Ctrl+Alt+Cmd+K"], &["Ctrl+Alt+Shift+K"], Bound("component.goToMain"), "was unbound"), + doc(Components, "Quick insert", &["Shift+I"], &[], Bound("view.assets"), "opens the Assets panel's search"), + // Prototype + doc(Prototype, "Preview in the canvas", &["Shift+Space"], &[], Bound("proto.preview"), ""), + doc(Prototype, "Present", &["Alt+Cmd+Enter"], &["Ctrl+Alt+Enter"], Bound("proto.present"), ""), + doc(Prototype, "Presenting: restart", &["R"], &[], Gesture("Presenting: restart"), "the inline preview too"), + doc(Prototype, "Presenting: next frame", &["ArrowRight", "Space", "N"], &[], Gesture("Presenting: next frame"), "Space and N were unbound"), + doc(Prototype, "Presenting: previous frame", &["ArrowLeft"], &[], Gesture("Presenting: previous frame"), ""), + doc(Prototype, "Presenting: cycle the size options", &["Z"], &[], Gesture("Presenting: cycle the size options"), ""), + doc(Prototype, "Presenting: comments", &["C"], &[], Gesture("Presenting: comments"), "Shift+C kept; keys the prototype uses go to it first"), + doc(Prototype, "Presenting: full screen", &["F"], &[], Gesture("Presenting: full screen"), "new"), + doc(View, "Place an object with the tool chosen from the toolbar", &["Enter"], &[], Missing, "Enter picks the tool; nothing is placed until the canvas is clicked"), +]; + +/// Chords the operating system takes before any app sees them; no default binding may use one. +const OS_RESERVED: &[(Platform, &str, &str)] = &[ + (Platform::Mac, "Cmd+H", "hide the app"), + (Platform::Mac, "Cmd+M", "minimize"), + (Platform::Mac, "Cmd+Q", "quit"), + (Platform::Mac, "Cmd+Tab", "app switcher"), + (Platform::Mac, "Cmd+Space", "Spotlight"), + (Platform::Mac, "Ctrl+Space", "next input source"), + (Platform::Mac, "Alt+Cmd+Escape", "force quit"), + (Platform::Mac, "Shift+Cmd+3", "screenshot"), + (Platform::Mac, "Shift+Cmd+4", "screenshot"), + (Platform::Mac, "Shift+Cmd+5", "screenshot"), + (Platform::Mac, "Shift+Cmd+/", "Help menu search"), + (Platform::Mac, "Ctrl+ArrowLeft", "Mission Control spaces"), + (Platform::Mac, "Ctrl+ArrowRight", "Mission Control spaces"), + (Platform::Mac, "Ctrl+ArrowUp", "Mission Control"), + (Platform::Mac, "Ctrl+ArrowDown", "App Exposé"), + (Platform::Other, "Alt+Space", "the window menu"), + (Platform::Other, "Alt+Tab", "window switcher"), + (Platform::Other, "Alt+F4", "close the window"), + (Platform::Other, "Ctrl+Escape", "Start menu"), + (Platform::Other, "Ctrl+Shift+Escape", "Task Manager"), + (Platform::Other, "Ctrl+Alt+Delete", "security screen"), + (Platform::Other, "Ctrl+Alt+ArrowLeft", "Linux workspaces"), + (Platform::Other, "Ctrl+Alt+ArrowRight", "Linux workspaces"), + (Platform::Other, "Ctrl+Alt+F1", "Linux virtual terminals"), + (Platform::Other, "Ctrl+Alt+F2", "Linux virtual terminals"), +]; + +/// A chord as the platform sees it (Cmd and Ctrl are one key off macOS). +fn chord(p: Platform, spec: &str) -> (bool, bool, bool, bool, String) { + let (m, k) = parse(spec).unwrap_or_else(|| panic!("`{spec}` doesn't parse")); + 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()) +} + +/// The commands a chord fires on the canvas and while editing text. +fn fires(p: Platform, spec: &str) -> [Option<&'static str>; 2] { + let (m, k) = parse(spec).unwrap_or_else(|| panic!("`{spec}` doesn't parse")); + [Context::Canvas, Context::Text].map(|c| lookup(p, c, k, m).map(|b| b.id)) +} + +/// Keys of gesture rows name a key, not a chord to look up. +fn is_chord(spec: &str) -> bool { + !matches!(spec, "Cmd" | "Alt" | "Ctrl") +} + +#[test] +fn documented_shortcuts_fire_their_commands_on_both_platforms() { + for row in ROWS { + for p in PLATFORMS { + assert!(row.keys(p).iter().all(|k| !is_chord(k) || parse(k).is_some()), "{}: keys don't parse", row.action); + if let Some((id, keys)) = row.ours(p) { + assert!(!keys.is_empty(), "{}", row.action); + for k in keys { + assert!(fires(p, k).contains(&Some(id)), "{p:?}: `{k}` should run {id} ({}), runs {:?}", row.action, fires(p, k)); + } + // The panel lists the binding in the row's tab. + assert!(KEYS.iter().any(|b| b.id == id && b.group == row.group), "{id} isn't in the {:?} tab", row.group); + } + if let Moved(id, ..) = row.ours { + let ours = row.ours(p).map(|o| o.1).unwrap_or_default(); + for k in row.keys(p).iter().filter(|k| !ours.contains(k)) { + assert!(!fires(p, k).contains(&Some(id)), "{p:?}: `{k}` was moved off {id}"); + assert!(fires(p, k).iter().any(Option::is_some), "{p:?}: `{k}` should be the conflicting documented command"); + } + } + } + if let Gesture(label) = row.ours { + assert!(GESTURES.iter().any(|g| g.0 == row.group && g.1 == label), "the panel's {:?} tab doesn't list `{label}`", row.group); + } + assert!(row.documented || !row.note.is_empty()); + } +} + +#[test] +fn vector_edit_tool_keys() { + for row in ROWS { + let VectorTool(tool) = row.ours else { continue }; + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("vector.edit.begin", &json!({"id": id_of(&r["id"]).unwrap()})).unwrap(); + for k in row.mac { + let (m, key) = parse(k).unwrap(); + assert!(crate::vectoredit::key(&mut s, key, m.shift, m.alt, m.cmd).unwrap(), "`{k}` taken"); + assert_eq!(s.ds().unwrap().vector_edit.as_ref().unwrap().tool, tool, "`{k}`"); + } + assert_eq!(tool.shortcut(), row.mac[0], "the tooltip shows the documented key"); + } +} + +#[test] +fn every_default_binding_is_in_the_table() { + for b in KEYS { + for p in PLATFORMS { + for k in b.keys(p) { + let listed = ROWS.iter().filter_map(|r| r.ours(p)).any(|(id, keys)| id == b.id && keys.iter().any(|x| chord(p, x) == chord(p, k))); + assert!(listed, "{p:?}: `{k}` ({}) has no row: add it, documented or pinned", b.id); + } + } + } +} + +#[test] +fn counts_match_the_docs() { + let documented: Vec<&Row> = ROWS.iter().filter(|r| r.documented).collect(); + let bound = documented.iter().filter(|r| matches!(r.ours, Bound(_) | Gesture(_) | VectorTool(_))).count(); + let moved = documented.iter().filter(|r| matches!(r.ours, Moved(..))).count(); + let missing = documented.iter().filter(|r| matches!(r.ours, Missing)).count(); + let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count(); + let pinned = ROWS.len() - documented.len(); + // docs/ui-parity.md quotes these. + assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 126, 1, 2, 2, 48)); +} + +#[test] +fn no_binding_takes_a_chord_the_system_keeps() { + for (p, spec, what) in OS_RESERVED { + for b in KEYS { + assert!(b.keys(*p).iter().all(|k| chord(*p, k) != chord(*p, spec)), "{p:?}: {} uses `{spec}` ({what})", b.id); + } + } +} + +#[test] +fn modifiers_read_the_platform_way() { + for b in KEYS { + for k in b.keys(Platform::Mac) { + let shown = display(k, Platform::Mac); + assert!(!shown.contains("Ctrl") && !shown.contains("Alt"), "{}: macOS shows `{shown}`", b.id); + } + for k in b.keys(Platform::Other) { + let shown = display(k, Platform::Other); + assert!(!shown.contains(['⌘', '⌥', '⌃', '⇧']), "{}: Windows/Linux shows `{shown}`", b.id); + // Windows and Linux have no Command key: Cmd and Ctrl both mean Ctrl there. + assert!(!(k.contains("Cmd") && k.contains("Ctrl")), "{}: `{k}` names Ctrl twice off macOS", b.id); + } + assert!(b.keys(Platform::Mac).iter().chain(b.keys(Platform::Other)).all(|k| !k.contains("Option") && !k.contains("Win"))); + } +} + +#[test] +fn control_held_while_dragging_suspends_snapping() { + use crate::tools::{PointerEvent, PointerKind}; + let mut s = Session::new(); + for x in [0, 400] { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(); + } + let b = id_of(&s.execute("shape.rectangle", &json!({"x": 200, "y": 300, "width": 100, "height": 100})).unwrap()["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let ev = |kind, x, y, ctrl| { + let mut e = PointerEvent::at(kind, x, y, 1.0); + e.ctrl = ctrl; + e + }; + // Grabbed at its centre and dropped 2 px off equal gaps between the other two. + for (ctrl, want) in [(false, (200.0, 0.0)), (true, (198.0, 2.0))] { + s.pointer(&ev(PointerKind::Down, 250.0, 350.0, ctrl)).unwrap(); + s.pointer(&ev(PointerKind::Move, 248.0, 52.0, ctrl)).unwrap(); + s.pointer(&ev(PointerKind::Up, 248.0, 52.0, ctrl)).unwrap(); + let r = s.doc().unwrap().world_bounds(b).unwrap(); + assert_eq!((r.x0, r.y0), want, "Control held: {ctrl}"); + s.execute("edit.undo", &json!({})).unwrap(); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b68cf8e..d082dbf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -269,6 +269,9 @@ fn every_command_survives_junk_params() { // Prototype expressions and gamepad keys as junk. json!({"id": -5, "expression": "x[\u{0}", "expr": {"op": {"op": "add", "args": [1]}}, "trigger": {"type": "keyPress", "key": "Gamepad \u{0}"}}), json!({"id": 1, "expr": {"inMode": {"variable": [], "mode": 1e308}}, "trigger": {"type": "keyPress", "key": 7}}), + // Keyboard steps (view.pan) and toggles as junk. + json!({"dx": 1e308, "dy": "NaN", "big": "yes", "on": 3}), + json!({"dx": -1e308, "dy": [1], "big": null, "on": null}), ]; for spec in all_commands() { if matches!( diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 6d1cf15..e031b7a 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -145,6 +145,12 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result { Ok(true) } +/// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like +/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is). +pub fn list_indent(s: &mut Session, out: bool) -> Result { + list_key(s, "Tab", out) +} + /// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word /// ends where the script changes (Han, hiragana, katakana and Hangul runs are words of their own in /// text without spaces). Combining marks and voicing marks belong to the char before them. diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 4e385fb..72b9432 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1064,7 +1064,8 @@ impl Session { d.x = 0.0; } } - let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd; + // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. + let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; let mut guides = Vec::new(); if snap_on { let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); @@ -1266,7 +1267,7 @@ impl Session { fn resize_drag(&mut self, ev: &PointerEvent, start: Point, r: &ResizeStart, scale_tool: bool) -> Result<()> { let page = self.page()?; - let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd; + let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; let ids: Vec = r.nodes.iter().map(|n| n.0).collect(); let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; let mut p = ev.p(); @@ -1432,7 +1433,7 @@ impl Session { let tool = self.tool.tool; let mut p = ev.p(); let mut guides = Vec::new(); - if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd { + if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); p.x += dx; p.y += dy; diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index f239dd4..f544f0f 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -83,7 +83,7 @@ impl VTool { VTool::Move => "V", VTool::Pen => "P", VTool::Bend => "Cmd", - VTool::Paint => "B", + VTool::Paint => "Shift+B", VTool::Lasso => "Q", VTool::Cut => "X", VTool::Eraser => "Shift+E", diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eea21d2..bdea7bc 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -177,7 +177,9 @@ impl Headless { "ArrowUp" => (0, -1), _ => (0, 1), }; - self.execute("transform.nudge", &json!({"dx": dx, "dy": dy, "big": m.shift})) + // With nothing selected the arrows pan the view. + let cmd = if self.session.selection().is_empty() { "view.pan" } else { "transform.nudge" }; + self.execute(cmd, &json!({"dx": dx, "dy": dy, "big": m.shift})) } // Number keys set the selection's opacity. d if m.is_none() && d.len() == 1 && d.chars().all(|c| c.is_ascii_digit()) && self.session.prefs.number_keys_opacity => { diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 270cf7a..3c8dd47 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -171,6 +171,8 @@ name = "English" "Brush size jitter" = "Brush size jitter" "Brush spacing" = "Brush spacing" "Bulleted list" = "Bulleted list" +"C or Shift+C" = "C or Shift+C" +"C or ⇧C" = "C or ⇧C" "CMYK colour" = "CMYK colour" "Color profile: {name}" = "Color profile: {name}" "CSS custom properties…" = "CSS custom properties…" @@ -431,6 +433,7 @@ name = "English" "Extend collection" = "Extend collection" "Extends \"{name}\": overridden values are marked" = "Extends \"{name}\": overridden values are marked" "Eye" = "Eye" +"F" = "F" "Fade" = "Fade" "False" = "False" "Fetching from the server…" = "Fetching from the server…" @@ -522,7 +525,9 @@ name = "English" "Highlights" = "Highlights" "Hold Alt" = "Hold Alt" "Hold Alt+Z" = "Hold Alt+Z" +"Hold Ctrl" = "Hold Ctrl" "Hold Z" = "Hold Z" +"Hold ⌃ or ⌘" = "Hold ⌃ or ⌘" "Hold ⌥" = "Hold ⌥" "Hold ⌥Z" = "Hold ⌥Z" "Home" = "Home" @@ -701,6 +706,7 @@ name = "English" "Mouse enter" = "Mouse enter" "Mouse leave" = "Mouse leave" "Mouse up" = "Mouse up" +"Move along the toolbar or the layer rows" = "Move along the toolbar or the layer rows" "Move" = "Move" "Move column left" = "Move column left" "Move column right" = "Move column right" @@ -835,6 +841,7 @@ name = "English" "Pages: {pages}" = "Pages: {pages}" "Paint bucket" = "Paint bucket" "Pan the canvas" = "Pan the canvas" +"Pan with nothing selected (Shift: farther)" = "Pan with nothing selected (Shift: farther)" "Panels" = "Panels" "Paper" = "Paper" "Paragraph indent" = "Paragraph indent" @@ -880,6 +887,12 @@ name = "English" "Present or preview" = "Present or preview" "Present this flow" = "Present this flow" "Presentation" = "Presentation" +"Presenting: comments" = "Presenting: comments" +"Presenting: cycle the size options" = "Presenting: cycle the size options" +"Presenting: full screen" = "Presenting: full screen" +"Presenting: next frame" = "Presenting: next frame" +"Presenting: previous frame" = "Presenting: previous frame" +"Presenting: restart" = "Presenting: restart" "Preserve scroll position" = "Preserve scroll position" "Presets" = "Presets" "Press a key…" = "Press a key…" @@ -905,6 +918,7 @@ name = "English" "Push" = "Push" "Push changes to main component" = "Push changes to main component" "Put text on path" = "Put text on path" +"R" = "R" "Radial" = "Radial" "Radius" = "Radius" "Ratio" = "Ratio" @@ -1143,6 +1157,7 @@ name = "English" "Subtract" = "Subtract" "Subtracts" = "Subtracts" "Suffix" = "Suffix" +"Suspend snapping while dragging" = "Suspend snapping while dragging" "Swap instance" = "Swap instance" "Swap library: use another library's assets of the same names" = "Swap library: use another library's assets of the same names" "Swap orientation" = "Swap orientation" @@ -1303,6 +1318,7 @@ name = "English" "Your name" = "Your name" "Your name in comments" = "Your name in comments" "Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off." +"Z" = "Z" "Zoom" = "Zoom" "Zoom around the pointer" = "Zoom around the pointer" "Zoom device to fill window" = "Zoom device to fill window" @@ -1398,6 +1414,9 @@ name = "English" "{value} isn't a colour" = "{value} isn't a colour" "{when} · edited" = "{when} · edited" "← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Space or N" "⇥ / ⇧⇥" = "⇥ / ⇧⇥" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index f8b2bc7..6cae1d0 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -172,6 +172,8 @@ status = "draft — needs native review" "Brush size jitter" = "Variación de tamaño del pincel" "Brush spacing" = "Espaciado del pincel" "Bulleted list" = "Lista con viñetas" +"C or Shift+C" = "C o Shift+C" +"C or ⇧C" = "C o ⇧C" "CMYK colour" = "Color CMYK" "Color profile: {name}" = "Perfil de color: {name}" "CSS custom properties…" = "Propiedades personalizadas CSS…" @@ -432,6 +434,7 @@ status = "draft — needs native review" "Extend collection" = "Extender colección" "Extends \"{name}\": overridden values are marked" = "Extiende \"{name}\": los valores modificados están marcados" "Eye" = "Ojo" +"F" = "F" "Fade" = "Desvanecido" "False" = "Falso" "Fetching from the server…" = "Obteniendo del servidor…" @@ -523,7 +526,9 @@ status = "draft — needs native review" "Highlights" = "Luces" "Hold Alt" = "Mantén Alt" "Hold Alt+Z" = "Mantén Alt+Z" +"Hold Ctrl" = "Mantén Ctrl" "Hold Z" = "Mantén Z" +"Hold ⌃ or ⌘" = "Mantén ⌃ o ⌘" "Hold ⌥" = "Mantén ⌥" "Hold ⌥Z" = "Mantén ⌥Z" "Home" = "Inicio" @@ -702,6 +707,7 @@ status = "draft — needs native review" "Mouse enter" = "Al entrar el puntero" "Mouse leave" = "Al salir el puntero" "Mouse up" = "Al soltar el botón" +"Move along the toolbar or the layer rows" = "Recorrer la barra de herramientas o las filas de capas" "Move" = "Mover" "Move column left" = "Mover columna a la izquierda" "Move column right" = "Mover columna a la derecha" @@ -836,6 +842,7 @@ status = "draft — needs native review" "Pages: {pages}" = "Páginas: {pages}" "Paint bucket" = "Bote de pintura" "Pan the canvas" = "Desplazar el lienzo" +"Pan with nothing selected (Shift: farther)" = "Desplazar sin nada seleccionado (Mayús: más lejos)" "Panels" = "Paneles" "Paper" = "Papel" "Paragraph indent" = "Sangría de párrafo" @@ -881,6 +888,12 @@ status = "draft — needs native review" "Present or preview" = "Presentar o previsualizar" "Present this flow" = "Presentar este flujo" "Presentation" = "Presentación" +"Presenting: comments" = "Presentando: comentarios" +"Presenting: cycle the size options" = "Presentando: alternar las opciones de tamaño" +"Presenting: full screen" = "Presentando: pantalla completa" +"Presenting: next frame" = "Presentando: siguiente marco" +"Presenting: previous frame" = "Presentando: marco anterior" +"Presenting: restart" = "Presentando: reiniciar" "Preserve scroll position" = "Conservar posición de desplazamiento" "Presets" = "Ajustes predefinidos" "Press a key…" = "Pulsa una tecla…" @@ -906,6 +919,7 @@ status = "draft — needs native review" "Push" = "Empujar" "Push changes to main component" = "Enviar cambios al componente principal" "Put text on path" = "Poner texto en trazado" +"R" = "R" "Radial" = "Radial" "Radius" = "Radio" "Ratio" = "Proporción" @@ -1144,6 +1158,7 @@ status = "draft — needs native review" "Subtract" = "Restar" "Subtracts" = "Restas" "Suffix" = "Sufijo" +"Suspend snapping while dragging" = "Suspender el ajuste al arrastrar" "Swap instance" = "Intercambiar instancia" "Swap library: use another library's assets of the same names" = "Intercambiar biblioteca: usar los recursos con los mismos nombres de otra biblioteca" "Swap orientation" = "Cambiar orientación" @@ -1304,6 +1319,7 @@ status = "draft — needs native review" "Your name" = "Tu nombre" "Your name in comments" = "Tu nombre en los comentarios" "Your system asks for reduced motion: animations stay off." = "Tu sistema pide reducir el movimiento: las animaciones siguen desactivadas." +"Z" = "Z" "Zoom" = "Zoom" "Zoom around the pointer" = "Zoom alrededor del puntero" "Zoom device to fill window" = "Ampliar el dispositivo hasta llenar la ventana" @@ -1399,6 +1415,9 @@ status = "draft — needs native review" "{value} isn't a colour" = "{value} no es un color" "{when} · edited" = "{when} · editado" "← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Espacio o N" "⇥ / ⇧⇥" = "⇥ / ⇧⇥" "⌘ arrows" = "⌘ flechas" "⌘ click" = "⌘ clic" @@ -1774,6 +1793,8 @@ status = "draft — needs native review" "text.lineHeightDown" = "Reducir interlineado" "text.lineHeightUp" = "Aumentar interlineado" "text.link" = "Crear enlace" +"text.listIndent" = "Aumentar sangría de la lista" +"text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" "text.maxLines" = "Máximo de líneas" "text.numberList" = "Lista numerada" @@ -1900,11 +1921,13 @@ status = "draft — needs native review" "view.layoutGrids" = "Cuadrículas de diseño" "view.leftSidebar" = "Mostrar/ocultar barra lateral izquierda" "view.maskOutlines" = "Contornos de máscaras" +"view.multiplayerCursors" = "Cursores de otros participantes" "view.nextFrame" = "Zoom al siguiente marco" "view.nextPage" = "Página siguiente" "view.nextRegion" = "Enfocar la siguiente región" "view.outlines" = "Contornos" "view.outlinesSelection" = "Contornos de la selección" +"view.pan" = "Desplazar la vista" "view.pixelGrid" = "Cuadrícula de píxeles" "view.pixelPreview" = "Vista previa de píxeles" "view.prevFrame" = "Zoom al marco anterior" diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index d74b54b..8a9078a 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -450,11 +450,10 @@ fn keys(app: &mut FigApp, ctx: &Context) { /// The first of the commands `ids` a key pressed this frame is bound to (canvas keys), with the key. fn binding_pressed(ctx: &Context, ids: &[&str]) -> Option<(&'static str, Key, Modifiers)> { - let platform = crate::shortcuts::platform(); ctx.input(|i| { i.events.iter().find_map(|e| match e { - Event::Key { key, pressed: true, modifiers, .. } => { - keymap::lookup(platform, keymap::Context::Canvas, key.name(), crate::shortcuts::mods(*modifiers)) + Event::Key { key, physical_key, pressed: true, modifiers, .. } => { + crate::shortcuts::lookup(keymap::Context::Canvas, *key, *physical_key, *modifiers) .filter(|b| ids.contains(&b.id)) .map(|b| (b.id, *key, *modifiers)) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b33fbb3..5670f40 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -116,6 +116,8 @@ mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod shortcut_keys_tests; +#[cfg(test)] mod shortcuts_tests; #[cfg(test)] mod titlebar_tests; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index f5dc2fb..af27546 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -229,9 +229,11 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter, ctx: &egui::Context) { } } } - // Cursors: on your page of the session's document; before its welcome arrives (or with - // another document in front) they show in page coordinates on whatever page you look at. + // Cursors (unless View ▸ Multiplayer cursors is off): on your page of the session's + // document; before its welcome arrives (or with another document in front) they show in + // page coordinates on whatever page you look at. if let Some((pg, x, y)) = p.cursor + && ds.view.multiplayer_cursors && (pg == page || !shared) { cursors.push((*id, peer.name.clone(), fill, (x, y))); diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 446ee78..65e835a 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -1596,6 +1596,16 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { action = player(&mut pres, ui, ui.max_rect(), now, Chrome { keys: !typing, ..FULL }).or(action); }); + // Keys the prototype left alone: C shows the comments, F fills the screen. + if !typing { + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::C)) { + action = Some(BarAction::Comments); + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F)) { + let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full)); + } + } match action { Some(BarAction::Comments) => pres.view.comments = !pres.view.comments, Some(BarAction::Inspect) => pres.view.inspect = !pres.view.inspect, @@ -2440,14 +2450,21 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke _ => {} } } - // Arrow keys step through top-level frames when nothing else uses them. - if matches!(key, egui::Key::ArrowLeft | egui::Key::ArrowRight) + // The arrows (and Space or N forward) step through top-level frames when nothing else + // uses them. + let forward = match key { + egui::Key::ArrowRight => Some(true), + egui::Key::Space | egui::Key::N if mods.is_none() => Some(true), + egui::Key::ArrowLeft => Some(false), + _ => None, + }; + if let Some(forward) = forward && p.overlays.is_empty() && let Some(base) = p.doc.as_ref() { let frames = base.top_frames(base.page_of(p.current).unwrap_or(p.current)); if let Some(i) = frames.iter().position(|f| *f == p.current) { - let next = if *key == egui::Key::ArrowRight { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) }; + let next = if forward { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) }; if let Some(n) = next.copied() { p.go(n, Transition::Instant, 0.0, Easing::Linear, now, true, false); consume_key(ctx, *key); diff --git a/crates/ui-egui/src/shortcut_keys_tests.rs b/crates/ui-egui/src/shortcut_keys_tests.rs new file mode 100644 index 0000000..7328951 --- /dev/null +++ b/crates/ui-egui/src/shortcut_keys_tests.rs @@ -0,0 +1,154 @@ +//! The documented shortcuts the keymap table can't check by lookup alone, driven through egui +//! headless: arrows pan with nothing selected, the keyboard box selection key and Cmd/Ctrl+arrows +//! drawing it out, list indentation while editing text, symbols another keyboard layout types with +//! Shift, and the presentation keys (Space and N forward, C comments, F full screen). + +use egui::{Event, Key, Modifiers, ViewportCommand, ViewportId}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use figcraft_engine::keymap::Platform; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn press_with(u: &mut Ui, key: Key, physical_key: Option, modifiers: Modifiers) -> egui::FullOutput { + let ev = |pressed| Event::Key { key, physical_key, pressed, repeat: false, modifiers }; + u.frame_with(vec![ev(true), ev(false)], modifiers) +} + +fn press(u: &mut Ui, key: Key, modifiers: Modifiers) -> egui::FullOutput { + press_with(u, key, None, modifiers) +} + +/// Cmd on macOS, Ctrl elsewhere, as egui reports it. +fn cmd() -> Modifiers { + if crate::shortcuts::platform() == Platform::Mac { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND } +} + +fn view(u: &Ui) -> (f64, f64, f64) { + let v = &u.app.session.ds().unwrap().view; + (v.cx, v.cy, v.zoom) +} + +#[test] +fn arrows_pan_the_view_when_nothing_is_selected() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()); + let mut u = Ui::new(s); + u.app.run("select.set", json!({"ids": []})); + u.app.run("view.set", json!({"zoom": 2.0, "cx": 0, "cy": 0})); + press(&mut u, Key::ArrowRight, Modifiers::NONE); + assert_eq!(view(&u), (10.0, 0.0, 2.0), "20 screen pixels at 200%"); + press(&mut u, Key::ArrowUp, Modifiers::SHIFT); + assert_eq!(view(&u), (10.0, -100.0, 2.0), "Shift: ten times as far"); + // With a selection the arrows nudge it and the view stays. + u.app.run("select.set", json!({"ids": [r]})); + press(&mut u, Key::ArrowRight, Modifiers::NONE); + assert_eq!(view(&u), (10.0, -100.0, 2.0)); + assert_eq!(u.app.session.doc().unwrap().world_bounds(r).unwrap().x0, 1.0); +} + +#[test] +fn keyboard_box_selection_key_and_cmd_arrows() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap(); + let mut u = Ui::new(s); + // Alt+Space on macOS, Ctrl+Space on Windows and Linux (Alt+Space is the window menu there). + let m = if crate::shortcuts::platform() == Platform::Mac { Modifiers::ALT } else { cmd() }; + press(&mut u, Key::Space, m); + let b = u.app.ui.key_box.expect("the box shows"); + let zoom = view(&u).2; + press(&mut u, Key::ArrowRight, cmd()); + let grown = u.app.ui.key_box.unwrap(); + assert_eq!((grown[0], grown[2]), (b[0], b[2] + 10.0 / zoom), "Cmd/Ctrl+arrows draw the box out"); + press(&mut u, Key::ArrowRight, cmd() | Modifiers::SHIFT); + assert_eq!(u.app.ui.key_box.unwrap()[2], grown[2] + 50.0 / zoom, "with Shift farther"); + press(&mut u, Key::ArrowRight, Modifiers::NONE); + assert_eq!(u.app.ui.key_box.unwrap()[0], b[0] + 10.0 / zoom, "plain arrows move it"); + press(&mut u, Key::Escape, Modifiers::NONE); + assert!(u.app.ui.key_box.is_none()); +} + +#[test] +fn cmd_brackets_indent_lists_while_editing() { + let mut s = Session::new(); + let t = id(s.execute("text.create", &json!({"text": "One", "x": 0, "y": 0})).unwrap()); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let mut u = Ui::new(s); + press(&mut u, Key::Enter, Modifiers::NONE); + press(&mut u, Key::Num8, cmd() | Modifiers::SHIFT); + let indent = |u: &Ui| u.app.session.doc().unwrap().node(t).unwrap().kind.text().unwrap().para_list(0).indent; + assert_eq!(indent(&u), 0); + press(&mut u, Key::CloseBracket, cmd()); + assert_eq!(indent(&u), 1, "Cmd/Ctrl+] nests the item"); + press(&mut u, Key::OpenBracket, cmd()); + assert_eq!(indent(&u), 0, "Cmd/Ctrl+[ moves it out"); + assert!(u.app.session.ds().unwrap().text_edit.is_some(), "still editing"); +} + +#[test] +fn symbols_typed_with_shift_on_other_layouts_count_without_it() { + let mut s = Session::new(); + let f = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let r = id(s.execute("node.create", &json!({"type": "rectangle", "parent": f, "x": 10, "y": 10, "width": 10, "height": 10})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + // `\` typed as Shift+7 (the key's own symbol differs): Select parent, as `\` on a US keyboard. + press_with(&mut u, Key::Backslash, Some(Key::Num7), Modifiers::SHIFT); + assert_eq!(u.app.session.selection(), vec![f]); + // On the key that is `\` everywhere, Shift+\ stays another chord (unbound). + u.app.run("select.set", json!({"ids": [r]})); + press_with(&mut u, Key::Backslash, Some(Key::Backslash), Modifiers::SHIFT); + assert_eq!(u.app.session.selection(), vec![r]); +} + +#[test] +fn presentation_keys() { + let mut s = Session::new(); + let a = id(s.execute("node.create", &json!({"type": "frame", "name": "A", "x": 0, "y": 0, "width": 300, "height": 200})).unwrap()); + let b = id(s.execute("node.create", &json!({"type": "frame", "name": "B", "x": 400, "y": 0, "width": 300, "height": 200})).unwrap()); + let mut u = Ui::new(s); + u.app.run("proto.present", json!({"start": a})); + u.frame(vec![]); + let current = |u: &Ui| u.app.inspect()["presenter"]["current"].clone(); + assert_eq!(current(&u), json!(a)); + press(&mut u, Key::N, Modifiers::NONE); + assert_eq!(current(&u), json!(b), "N: next frame"); + press(&mut u, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(current(&u), json!(a)); + press(&mut u, Key::Space, Modifiers::NONE); + assert_eq!(current(&u), json!(b), "Space: next frame"); + let out = press(&mut u, Key::F, Modifiers::NONE); + let cmds = out.viewport_output.get(&ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default(); + assert!(cmds.contains(&ViewportCommand::Fullscreen(true)), "F: full screen, {cmds:?}"); + let comments = u.app.ui.present_view.comments; + press(&mut u, Key::C, Modifiers::NONE); + assert_eq!(u.app.ui.present_view.comments, !comments, "C: comments"); + assert!(u.app.ui.present.is_some(), "still presenting"); +} + +#[test] +fn multiplayer_cursors_key_hides_and_shows_others_cursors() { + use crate::live_tests::{Harness, cursor}; + use figcraft_engine::live::Event as LiveEvent; + let (mut h, _) = Harness::new(); + h.frames(3); + h.joined(); + let page = h.page(); + h.send(vec![LiveEvent::Presence(1, cursor(page, 400.0, 300.0))]); + h.frames(2); + assert_eq!(h.app.ui.live.shown(), vec![1], "Hana's cursor shows"); + let m = + if crate::shortcuts::platform() == Platform::Mac { Modifiers::ALT | Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::ALT | cmd() }; + let ev = |pressed| Event::Key { key: Key::Backslash, physical_key: None, pressed, repeat: false, modifiers: m }; + h.frame(vec![ev(true), ev(false)]); + h.frames(1); + assert!(h.app.ui.live.shown().is_empty(), "Alt+Cmd+backslash hides it"); + h.app.run("view.multiplayerCursors", json!({})); + h.frames(2); + assert_eq!(h.app.ui.live.shown(), vec![1], "and shows it again"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 93d6ece..3fc57c9 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -34,6 +34,33 @@ pub fn mods(m: Modifiers) -> keymap::Mods { keymap::Mods { shift: m.shift, alt: m.alt, cmd: m.command || m.mac_cmd, ctrl: m.ctrl } } +/// Symbols a US keyboard types without Shift. +const UNSHIFTED_SYMBOLS: [Key; 11] = [ + Key::Slash, + Key::Backslash, + Key::Minus, + Key::Equals, + Key::Period, + Key::Comma, + Key::Quote, + Key::Semicolon, + Key::OpenBracket, + Key::CloseBracket, + Key::Backtick, +]; + +/// The binding a key fires in `context`. Shortcuts follow the character typed, named as on a US +/// keyboard: a symbol another layout types with Shift (`/` is Shift+7 on a German keyboard, where +/// the key's position differs from the symbol) counts without the Shift. Characters egui has no key +/// for (`ü`, `ж`) already arrive as the key at that position. +pub fn lookup(context: keymap::Context, key: Key, physical: Option, m: Modifiers) -> Option<&'static keymap::Binding> { + let mut held = mods(m); + if held.shift && physical.is_some_and(|p| p != key) && UNSHIFTED_SYMBOLS.contains(&key) { + held.shift = false; + } + keymap::lookup(platform(), context, key.name(), held) +} + /// The key a menu shows for a command. pub fn shortcut(id: &str) -> Option<&'static str> { keymap::primary(id, platform()) @@ -94,7 +121,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { Event::Copy => crate::menus::invoke(app, "edit.copy"), Event::Cut => crate::menus::invoke(app, "edit.cut"), Event::Paste(text) => crate::menus::paste(app, Some(text)), - Event::Key { key, pressed: true, modifiers, repeat, .. } if key_event(app, *key, *modifiers, *repeat, now, ctx) => { + Event::Key { key, physical_key, pressed: true, modifiers, repeat } + if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) => + { // handled } _ => {} @@ -120,7 +149,7 @@ pub fn run_binding(app: &mut FigApp, id: &str) { } } -fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool { +fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool { if key_box(app, key, m) { return true; } @@ -138,7 +167,7 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, c return true; } } - if let Some(b) = keymap::lookup(platform(), keymap::Context::Canvas, key.name(), mods(m)) { + if let Some(b) = lookup(keymap::Context::Canvas, key, physical, m) { // Copy/cut/paste arrive as clipboard events on most platforms. if matches!(b.id, "edit.copy" | "edit.cut" | "edit.paste") { return true; @@ -152,7 +181,7 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, c if app.ui.layers_focus && !m.command && !m.alt && crate::layers::key_nav(app, key, m.shift) { return true; } - // Arrow nudges. + // Arrow nudges; with nothing selected the arrows pan the view. let (dx, dy) = match key { Key::ArrowLeft => (-1.0, 0.0), Key::ArrowRight => (1.0, 0.0), @@ -161,13 +190,13 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, c _ => (0.0, 0.0), }; if (dx, dy) != (0.0, 0.0) && !m.alt { - if !app.session.selection().is_empty() { - if m.command { - // Cmd/Ctrl+arrows resize: right/down grow, left/up shrink. - app.run("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift})); - } else { - app.run("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift})); - } + if app.session.selection().is_empty() { + app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift})); + } else if m.command { + // Cmd/Ctrl+arrows resize: right/down grow, left/up shrink. + app.run("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift})); + } else { + app.run("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift})); } return true; } @@ -182,12 +211,14 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, c false } -/// Keys while a selection box drawn from the keyboard shows (`select.area`, F8): the arrows move -/// it (with Shift they move its far corner, sizing it) by 10 screen pixels, Enter selects what it -/// covers like a marquee (Shift toggles, Cmd/Ctrl takes nested layers), Escape drops it. +/// Keys while a selection box drawn from the keyboard shows (`select.area`, Alt+Space or Ctrl+Space, +/// F8): the arrows move it, Cmd/Ctrl+arrows or Shift+arrows move its far corner, sizing it (Shift +/// with Cmd/Ctrl: five times as far), by 10 screen pixels; Enter selects what it covers like a +/// marquee (Shift toggles, Cmd/Ctrl takes nested layers), Escape drops it. fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool { let Some(mut b) = app.ui.key_box else { return false }; - let step = 10.0 / app.session.ds().map_or(1.0, |d| d.view.zoom.max(1e-6)); + let size = m.shift || m.command; + let step = if m.shift && m.command { 50.0 } else { 10.0 } / app.session.ds().map_or(1.0, |d| d.view.zoom.max(1e-6)); let (dx, dy) = match key { Key::ArrowLeft => (-step, 0.0), Key::ArrowRight => (step, 0.0), @@ -196,7 +227,7 @@ fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool { _ => (0.0, 0.0), }; if (dx, dy) != (0.0, 0.0) { - if !m.shift { + if !size { b[0] += dx; b[1] += dy; } @@ -328,8 +359,8 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) { } } } - Event::Key { key, pressed: true, modifiers: m, .. } => { - if (m.command || m.alt || m.ctrl) && text_shortcut(app, *key, *m) { + Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { + if (m.command || m.alt || m.ctrl) && text_shortcut(app, *key, *physical_key, *m) { skip_text = true; continue; } @@ -382,8 +413,8 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) { } /// Shortcuts live while editing text (Bold, lists, alignment, size/weight/spacing steps, Save…). -fn text_shortcut(app: &mut FigApp, key: Key, m: Modifiers) -> bool { - match keymap::lookup(platform(), keymap::Context::Text, key.name(), mods(m)) { +fn text_shortcut(app: &mut FigApp, key: Key, physical: Option, m: Modifiers) -> bool { + match lookup(keymap::Context::Text, key, physical, m) { Some(b) => { crate::menus::invoke(app, b.id); true diff --git a/docs/gaps.md b/docs/gaps.md index 7b39dcd..6b05b5f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -89,13 +89,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe craftrules `standards/test-corpora.md`). ### 4. Interaction fidelity not compared tool by tool -- **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging - (e.g. Ctrl to ignore snapping on macOS), hover states, popovers, menus, snapping thresholds. +- **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging, + hover states, popovers, menus, snapping thresholds. - **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout, selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline mode and measuring, then (2026-10-12) comments and version history, and the layers and Assets panels (`crates/engine/src/*_behaviour_tests.rs`, `crates/ui-egui/src/layers_behaviour_tests.rs`), - each with its fixes. + each with its fixes; then keyboard shortcuts per platform (`shortcut_parity_tests.rs`: 129 + documented rows, 126 bound, 1 moved off a documented conflict, 2 missing, 48 undocumented + bindings pinned), with 16 rows newly bound, Control suspending snapping on macOS and shifted + symbols on other keyboard layouts. - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). @@ -352,6 +355,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 4: keyboard shortcut parity suite per platform (129 documented rows: 126 bound, 1 moved, 2 missing; 48 undocumented bindings pinned) added to the landed list; estimate and ranking unchanged | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Gap 5 31–54 h → 29–51 h, rank unchanged | | 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Gap 5 32–55 h → 31–54 h, rank unchanged | | 2026-10-12 | minor | Gap 15: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes and `proto.evaluate` landed; gap 15 now media plus controllers off Windows, 3–6 h → 2–5 h; ranking unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 27519b8..02fda98 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -201,7 +201,7 @@ Or in a project's `.mcp.json`: | `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. | | `undo` / `redo` | Step through history. | | `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). | -| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. | +| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. | | `type_text {text, id?}` | Type into the text being edited, or replace the contents of text layer `id`. | | `inspect_ui {}` | Tool, view and status (and panels, timings and the app's `windows` when attached). | @@ -239,6 +239,11 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: - `view.designPrototype` (`Shift+E`), `view.devMode` (`Shift+D`) and `view.leftSidebar` (`Shift+Cmd+\`) switch the right panel's tab or hide the left sidebar in the app (no-ops headless). - `doc.nextTab` / `doc.previousTab` (`Ctrl+Tab` / `Ctrl+Shift+Tab`): the next or previous open file. +- `view.pan {dx, dy, big?}`: the arrow keys with nothing selected (`key ArrowRight` does the same) + pan the view by 20 screen pixels a step, 200 with `big` (Shift). +- `view.multiplayerCursors {on?}` (`Alt+Cmd+\`): show or hide others' cursors in a live session. +- `text.listIndent` / `text.listOutdent` (`Cmd+]` / `Cmd+[` while editing text): the selected list + items a level deeper or out, like Tab and Shift+Tab. - `view.nextRegion` / `view.previousRegion` (`F6` / `Shift+F6`, live while editing text too): move the keyboard between the app's regions (toolbar, layers, canvas, properties). In the app, Tab and Shift+Tab then move between the region's controls, Enter or Space activate one, the arrows move diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4bbe9a0..e5ebcad 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,10 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-11 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-12 by `cargo xtask parity`. Do not edit by hand. -FigCraft commands registered for the incumbent's main menu and toolbar (499 commands in the engine). +FigCraft commands registered for the incumbent's main menu and toolbar (528 commands in the engine). -**206 / 206 menu items (100.0%)** +**207 / 207 menu items (100.0%)** Every catalog item has a command. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cec53df..7c3ab2e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -15,7 +15,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Number | Value | Kind | |---|---|---| -| Menu/command breadth | **100%** (204 / 204 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-11) | +| Menu/command breadth | **100%** (207 / 207 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) | | Feature breadth (does each Figma Design feature exist, weighted) | **~84%** (core editor areas 1–16: ~94%) | estimated from the area table below | | Ready for real work | **~53%** | estimated (dimension table below: written weights × written per-dimension scores) | | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | @@ -447,11 +447,17 @@ Hours in this narrative are the builders' estimates; the tables above supersede (Cmd/Ctrl+K: link while typing, Actions on the canvas), Cmd/Ctrl+/ for Actions, `+`/`-` zoom, Shift+0/1/2 from any layout, Cmd/Ctrl+0, hold Z for the zoom tool (Alt zooms out, drag an area), two quick digits for an exact opacity, boolean keys (Alt+Shift+U/S/I/E), remove fill/stroke - (Alt+/, Shift+/), outlines on Shift+Cmd+O, pixel grid on Cmd+', Shift+E / Shift+D and the other panel keys, Ctrl+Tab between files, B for the paint bucket in vector edit mode; the Keyboard + (Alt+/, Shift+/), outlines on Shift+Cmd+O, pixel grid on Shift+' (and Cmd+'), Shift+E / Shift+D and the other panel keys, Ctrl+Tab between files, Shift+B (and B) for the paint bucket in vector edit mode; the Keyboard shortcuts panel docks at the bottom with a tab per group and search, keycaps per platform. + A shortcut parity suite (`shortcut_parity_tests.rs`) checks every documented shortcut on both + platforms (129 rows: 126 bound, 1 moved off a documented conflict, 2 missing: moving the + rotation origin and placing an object with Enter from the toolbar) and pins the 48 undocumented + bindings; others' cursors (`Alt+Cmd+\`), list levels while editing (Cmd/Ctrl+] and [), the + keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, + Control to suspend snapping on macOS and Space/N/C/F while presenting landed with it + ([ui-parity](ui-parity.md#keyboard-shortcuts)). *Left:* tool by tool, panel by panel against the incumbent (cursors, modifier keys while - dragging such as Ctrl to ignore snapping on macOS, hover states, menus, popovers); multiplayer - cursor keys wait for multiplayer. 40–60 h. + dragging, hover states, menus, popovers); per-keyboard-layout shortcut tables. 40–60 h. 8. **Collaboration (local):** comments landed: pins pinned to frames and layers that move with them (to other pages too) and stay where they were when the layer is deleted, your pins dragged onto another frame, crowded pins clustered when zoomed out (a click zooms them apart), Shift+C to show @@ -619,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | | 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | | 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 230934e..8201f8a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -22,7 +22,7 @@ interaction feel not yet compared. | Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | | Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content | -| Keyboard shortcuts panel | done | docked, tabbed by group, searchable, per-platform keycaps | +| Keyboard shortcuts panel | done | docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | | Multiplayer avatars, Share button | missing | waits for multiplayer | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | @@ -56,13 +56,13 @@ interaction feel not yet compared. | Scale tool (K): handles about an anchor, live factor, panel | done | | | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children | | Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's | -| Ctrl to suspend snapping while dragging (macOS) | missing | | +| Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | | Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | partial | check scrub speed and Shift/Alt steps | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | -| Keyboard box selection | done | F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Shift+arrows size it, Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested); our addition for keyboard-only users | +| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools @@ -103,6 +103,48 @@ interaction feel not yet compared. | Cursors per tool and modifier | partial | not compared | | Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions | +## Keyboard shortcuts + +One keymap (`figcraft_engine::keymap`) drives the menus, the canvas, the shortcuts panel, the +control channel and MCP `key`. `crates/engine/src/shortcut_parity_tests.rs` holds one row per +shortcut in the public documentation (the help centre's feature articles: keyboard layouts, text +properties, alignment, frames, components, prototypes, plugins, keyboard navigation…), with the +macOS and Windows/Linux keys, what does it here and a note in our words, and checks every row on +both platforms; rows the documentation is silent on pin today's keys, so every default binding is +in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a lookup can't prove. + +| | Count | Notes | +|---|---|---| +| Documented shortcuts (rows) | 129 | each with macOS and Windows/Linux keys | +| Bound as documented | 126 (110 before this suite) | 107 keymap bindings, 13 held keys, gestures and presentation keys, 6 vector edit tool keys | +| Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there | +| Missing | 2 | moving the rotation origin (Alt+R); placing an object with Enter after choosing a tool from the toolbar | +| Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build | +| Undocumented bindings pinned | 48 | of which Cmd/Ctrl+W, Cmd/Ctrl+N and Ctrl+Tab are taken by browsers (web build) and Ctrl+Alt+T / Ctrl+Alt+L by some Linux desktops | + +- **Newly bound:** A for the Frame tool, Shift+' for the pixel grid (Cmd+' kept: the articles + disagree), Alt+Cmd+\ / Ctrl+Alt+\ for others' cursors (`view.multiplayerCursors`, new), Ctrl+F6 + to the toolbar on Windows/Linux, Shift+Cmd+U to link text (Cmd+K kept), Cmd/Ctrl+] and [ to + nest list items while editing (`text.listIndent` / `text.listOutdent`, new), Cmd/Ctrl+Backspace + to ungroup, Alt+Space / Ctrl+Space for the keyboard box selection (F8 kept) with Cmd/Ctrl+arrows + drawing it out, Ctrl+Shift+A / Ctrl+Alt+Shift+A to suggest auto layout, Ctrl+Alt+Cmd+K / + Ctrl+Alt+Shift+K to go to the main component, the arrows panning the view when nothing is + selected (`view.pan`, new), Control suspending snapping on macOS, and Space, N, C and F while + presenting. The paint bucket's tooltip shows Shift+B (B still works). +- **Where the documentation disagrees with itself** (noted per row): pixel grid (Shift+' or + Cmd+'), letter spacing (Alt or Cmd with < >; Cmd would be the font size keys, Alt bound), + Windows remove fill (Alt+/ or Alt+,; Alt+, is letter spacing), the Layers panel on Windows (Alt+1 + or Ctrl+1), send to back / bring to front (one table shows only the bracket). +- **Platform keys:** macOS rows use Cmd and Option, Windows/Linux rows Ctrl and Alt (tested: no + macOS glyph on Windows/Linux keycaps, no "Ctrl"/"Alt" words on macOS); no default binding uses a + chord the system keeps (Cmd+H/M/Q/Tab/Space, Ctrl+Space and the screenshot keys on macOS; + Alt+Space/Tab/F4, Ctrl+Esc, Ctrl+Alt+Delete and Linux workspace and terminal-switch keys). +- **Keyboard layouts:** keys follow the character typed, named as on a US keyboard. Characters + egui has no key for (`ü`, `ж`) arrive as the key at that position (egui's fallback); a symbol + another layout types with Shift (`/` is Shift+7 on a German keyboard) now counts without the + Shift. Not done: per-layout tables like the documentation's (a German Shift+' types `"` and + reaches Shift+2). + ## Accessibility (ours vs Figma's) | Item | Status | Notes | @@ -118,6 +160,7 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings: a table of the 129 documented shortcuts per platform (126 bound, 1 moved for a documented conflict, 2 missing, 2 kept by a platform) plus 48 pinned undocumented bindings; 16 rows newly bound (see Keyboard shortcuts); Ctrl to suspend snapping on macOS done; keyboard box selection on Alt+Space / Ctrl+Space | | 2026-10-12 | minor | Multiplayer cursors, cursor chat and spotlight: missing → partial (live sessions: chat bubbles at the pointer that fade, `/` to chat, spotlight from your avatar's menu with a banner to stop or follow again) | | 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes: several layers dropped together land together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers in one step, the toggles stay under the pointer, Rename layers dialog for several layers, pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names | | 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes |