From 94aab893b0eb4873edceb0b76b4a2414e015c770 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 18:15:16 +0700 Subject: [PATCH] M7: keyboard shortcut parity (#88) One keymap (figcraft_engine::keymap) now holds every default shortcut, grouped like the public shortcut reference, with macOS and Windows/Linux keys where they differ and canvas / text-editing contexts. The menus, canvas, control channel, MCP key tool and Keyboard shortcuts panel all resolve keys there; the catalog keeps only the menu tree. - Fixed: Cmd/Ctrl+K made a link on the canvas (now Actions; link only while typing), Shift+1 and Ctrl+Shift+? never matched (key names are normalised), mask / bring to front / send to back / tidy up / layout grids on Windows and Linux, distribute on macOS, outlines (Shift+Cmd+O), pixel grid (Cmd+'), paint bucket B in vector edit mode. - Added: Cmd/Ctrl+/, + and - zoom, Cmd/Ctrl+0, hold Z zoom tool (view.zoomAt), two quick digits for exact opacity (object.opacityKeys), Alt+Shift+U/S/I/E booleans, Alt+/ and Shift+/ remove fill/stroke, pixel preview, Shift+E / Shift+D / Shift+Cmd+\ panel keys, Ctrl+Tab between files (doc.nextTab / doc.previousTab), Shift+I, Shift+Space preview, Home/End frames, Ctrl+C pick colour on macOS. - Keyboard shortcuts panel: docked at the bottom, a tab per group, search, keycaps per platform; tooltips show the platform's keys. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 17 +- crates/engine/src/catalog.rs | 465 ++++++++++++------------ crates/engine/src/cmd/edit.rs | 33 ++ crates/engine/src/cmd/file.rs | 12 + crates/engine/src/cmd/view.rs | 29 ++ crates/engine/src/keymap.rs | 499 ++++++++++++++++++++++++++ crates/engine/src/keymap_tests.rs | 271 ++++++++++++++ crates/engine/src/lib.rs | 3 + crates/engine/src/tools.rs | 27 +- crates/engine/src/vectoredit.rs | 7 +- crates/mcp/src/headless.rs | 102 +++--- crates/ui-egui/src/assets_panel.rs | 7 +- crates/ui-egui/src/canvas.rs | 43 +++ crates/ui-egui/src/component_panel.rs | 2 +- crates/ui-egui/src/control.rs | 90 ++--- crates/ui-egui/src/dialogs.rs | 38 +- crates/ui-egui/src/layers.rs | 2 +- crates/ui-egui/src/layout_panel.rs | 4 +- crates/ui-egui/src/lib.rs | 18 + crates/ui-egui/src/menus.rs | 19 +- crates/ui-egui/src/present.rs | 3 - crates/ui-egui/src/right.rs | 42 ++- crates/ui-egui/src/section_panel.rs | 4 +- crates/ui-egui/src/shortcuts.rs | 248 +++++++------ crates/ui-egui/src/shortcuts_panel.rs | 138 +++++++ crates/ui-egui/src/shortcuts_tests.rs | 190 ++++++++++ crates/ui-egui/src/tabs.rs | 4 +- crates/ui-egui/src/toolbar.rs | 34 +- docs/control-protocol.md | 10 +- docs/mcp.md | 20 +- docs/parity.md | 2 +- 31 files changed, 1793 insertions(+), 590 deletions(-) create mode 100644 crates/engine/src/keymap.rs create mode 100644 crates/engine/src/keymap_tests.rs create mode 100644 crates/ui-egui/src/shortcuts_panel.rs create mode 100644 crates/ui-egui/src/shortcuts_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4774c7d0..e4baf182 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -174,8 +174,18 @@ dimension measured side by side. background blurs as images; checked by rendering the PDFs back against PNG exports. *Left:* .fig import (best effort), variable mode lookups in prototype expressions, PDF print options (bleed, CMYK). 10–20 h. -7. **Look & feel and interaction-fidelity pass**: tool by tool, panel by panel against the incumbent - (cursors, modifier keys, hover states, menus, popovers). 40–60 h. +7. **Look & feel and interaction-fidelity pass**: *landed:* keyboard shortcuts audited against the + public shortcut reference: one keymap (`figcraft_engine::keymap`) drives the menus, the canvas, + the control channel and MCP, with macOS and Windows/Linux keys where they differ (mask, bring to + front/back, distribute, tidy up, layout grids, pixel preview), Text-only and canvas-only bindings + (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 / Shift+Cmd+ panel keys, Ctrl+Tab between files, 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. + *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. 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 @@ -261,7 +271,8 @@ dimension measured side by side. with pinned properties drawn as callouts on the canvas, a ready-for-dev list, CSS/HTML/React/SwiftUI/Compose code where components take their properties as parameters and instances are calls), colour picker (HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog, - context menu, Actions palette (⌘K), keyboard shortcuts from the catalog, presentation player with + context menu, Actions palette (⌘K), keyboard shortcuts from one keymap per platform (a tabbed, + searchable Keyboard shortcuts panel), presentation player with transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs. - **Automation:** desktop control channel (`--control `: execute commands, inspect document/UI, synthetic pointer/keys/typing, menu invocation, screenshots, renders). diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 427f26a1..1a703f33 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -1,19 +1,18 @@ //! The incumbent's main-menu tree with stable FigCraft command ids. A command registered under a //! catalog id lights up its menu item, and `cargo xtask parity` measures coverage against it. //! -//! Entries: (menu path, label, command id, default shortcut). `/` separates submenus; a label of -//! `-` is a separator. +//! Entries: (menu path, label, command id). `/` separates submenus; a label of `-` is a +//! separator. Shortcuts live in [`crate::keymap`]. pub struct MenuItem { pub menu: &'static str, pub label: &'static str, pub id: &'static str, - pub shortcut: &'static str, } macro_rules! m { - ($menu:expr, $label:expr, $id:expr, $sc:expr) => { - MenuItem { menu: $menu, label: $label, id: $id, shortcut: $sc } + ($menu:expr, $label:expr, $id:expr) => { + MenuItem { menu: $menu, label: $label, id: $id } }; } @@ -21,243 +20,243 @@ pub const SEP: &str = "-"; pub static MENU: &[MenuItem] = &[ // File - m!("File", "New design file", "file.new", "Cmd+N"), - m!("File", "Open…", "file.open", "Cmd+O"), - m!("File", "Import from file…", "file.import", "Shift+Cmd+I"), - m!("File", "Place image…", "file.placeImage", "Shift+Cmd+K"), - m!("File", SEP, "", ""), - m!("File", "Save", "file.save", "Cmd+S"), - m!("File", "Save as…", "file.saveAs", "Shift+Cmd+S"), - m!("File", "Save to version history…", "file.saveVersion", "Alt+Cmd+S"), - m!("File", "Show version history", "file.versionHistory", ""), - m!("File", SEP, "", ""), - m!("File", "Export…", "file.export", "Shift+Cmd+E"), - m!("File", "Export frames to PDF…", "file.exportFramesPdf", ""), - m!("File", "Export selection as SVG…", "file.exportSvg", ""), - m!("File", SEP, "", ""), - m!("File", "Duplicate", "file.duplicate", ""), - m!("File", "Rename", "file.rename", ""), - m!("File", "Color profile…", "file.colorProfile", ""), - m!("File", "Revert to saved", "file.revert", ""), - m!("File", "Close tab", "file.close", "Cmd+W"), - m!("File", "Open recent", "file.openRecent", ""), + m!("File", "New design file", "file.new"), + m!("File", "Open…", "file.open"), + m!("File", "Import from file…", "file.import"), + m!("File", "Place image…", "file.placeImage"), + m!("File", SEP, ""), + m!("File", "Save", "file.save"), + m!("File", "Save as…", "file.saveAs"), + m!("File", "Save to version history…", "file.saveVersion"), + m!("File", "Show version history", "file.versionHistory"), + m!("File", SEP, ""), + m!("File", "Export…", "file.export"), + m!("File", "Export frames to PDF…", "file.exportFramesPdf"), + m!("File", "Export selection as SVG…", "file.exportSvg"), + m!("File", SEP, ""), + m!("File", "Duplicate", "file.duplicate"), + m!("File", "Rename", "file.rename"), + m!("File", "Color profile…", "file.colorProfile"), + m!("File", "Revert to saved", "file.revert"), + m!("File", "Close tab", "file.close"), + m!("File", "Open recent", "file.openRecent"), // Edit - m!("Edit", "Undo", "edit.undo", "Cmd+Z"), - m!("Edit", "Redo", "edit.redo", "Shift+Cmd+Z"), - m!("Edit", SEP, "", ""), - m!("Edit/Copy as", "Copy as text", "edit.copyAsText", ""), - m!("Edit/Copy as", "Copy as CSS", "edit.copyAsCss", ""), - m!("Edit/Copy as", "Copy as SVG", "edit.copyAsSvg", ""), - m!("Edit/Copy as", "Copy as PNG", "edit.copyAsPng", "Shift+Cmd+C"), - m!("Edit/Copy as", "Copy link to selection", "edit.copyLink", ""), - m!("Edit", "Cut", "edit.cut", "Cmd+X"), - m!("Edit", "Copy", "edit.copy", "Cmd+C"), - m!("Edit", "Paste", "edit.paste", "Cmd+V"), - m!("Edit", "Paste over selection", "edit.pasteOver", "Shift+Cmd+V"), - m!("Edit", "Paste to replace", "edit.pasteReplace", "Shift+Cmd+R"), - m!("Edit", "Duplicate", "edit.duplicate", "Cmd+D"), - m!("Edit", "Delete", "edit.delete", "Delete"), - m!("Edit", SEP, "", ""), - m!("Edit", "Find and replace…", "edit.findReplace", "Cmd+F"), - m!("Edit", "Copy properties", "edit.copyProperties", "Alt+Cmd+C"), - m!("Edit", "Paste properties", "edit.pasteProperties", "Alt+Cmd+V"), - m!("Edit", "Pick color", "edit.pickColor", "I"), - m!("Edit", "Set default properties", "edit.setDefaultProperties", ""), - m!("Edit", SEP, "", ""), - m!("Edit", "Select all", "select.all", "Cmd+A"), - m!("Edit", "Select matching layers", "select.matching", "Alt+Cmd+A"), - m!("Edit", "Select none", "select.none", "Escape"), - m!("Edit", "Select inverse", "select.inverse", "Shift+Cmd+A"), - m!("Edit/Select all with same", "Fill", "select.sameFill", ""), - m!("Edit/Select all with same", "Stroke", "select.sameStroke", ""), - m!("Edit/Select all with same", "Effect", "select.sameEffect", ""), - m!("Edit/Select all with same", "Text properties", "select.sameText", ""), - m!("Edit/Select all with same", "Font", "select.sameFont", ""), - m!("Edit/Select all with same", "Instance", "select.sameInstance", ""), - m!("Edit/Select all with same", "Properties", "select.sameProperties", ""), + m!("Edit", "Undo", "edit.undo"), + m!("Edit", "Redo", "edit.redo"), + m!("Edit", SEP, ""), + m!("Edit/Copy as", "Copy as text", "edit.copyAsText"), + m!("Edit/Copy as", "Copy as CSS", "edit.copyAsCss"), + m!("Edit/Copy as", "Copy as SVG", "edit.copyAsSvg"), + m!("Edit/Copy as", "Copy as PNG", "edit.copyAsPng"), + m!("Edit/Copy as", "Copy link to selection", "edit.copyLink"), + m!("Edit", "Cut", "edit.cut"), + m!("Edit", "Copy", "edit.copy"), + m!("Edit", "Paste", "edit.paste"), + m!("Edit", "Paste over selection", "edit.pasteOver"), + m!("Edit", "Paste to replace", "edit.pasteReplace"), + m!("Edit", "Duplicate", "edit.duplicate"), + m!("Edit", "Delete", "edit.delete"), + m!("Edit", SEP, ""), + m!("Edit", "Find and replace…", "edit.findReplace"), + m!("Edit", "Copy properties", "edit.copyProperties"), + m!("Edit", "Paste properties", "edit.pasteProperties"), + m!("Edit", "Pick color", "edit.pickColor"), + m!("Edit", "Set default properties", "edit.setDefaultProperties"), + m!("Edit", SEP, ""), + m!("Edit", "Select all", "select.all"), + m!("Edit", "Select matching layers", "select.matching"), + m!("Edit", "Select none", "select.none"), + m!("Edit", "Select inverse", "select.inverse"), + m!("Edit/Select all with same", "Fill", "select.sameFill"), + m!("Edit/Select all with same", "Stroke", "select.sameStroke"), + m!("Edit/Select all with same", "Effect", "select.sameEffect"), + m!("Edit/Select all with same", "Text properties", "select.sameText"), + m!("Edit/Select all with same", "Font", "select.sameFont"), + m!("Edit/Select all with same", "Instance", "select.sameInstance"), + m!("Edit/Select all with same", "Properties", "select.sameProperties"), // View - m!("View", "Pixel grid", "view.pixelGrid", "Shift+'"), - m!("View", "Layout grids", "view.layoutGrids", "Ctrl+G"), - m!("View", "Rulers", "view.rulers", "Shift+R"), - m!("View", "Slices", "view.slices", ""), - m!("View", "Comments", "view.comments", "Shift+C"), - m!("View", SEP, "", ""), - m!("View/Outlines", "Show outlines", "view.outlines", "Cmd+Y"), - m!("View/Outlines", "Show outlines for selection", "view.outlinesSelection", ""), - m!("View", "Pixel preview", "view.pixelPreview", ""), - m!("View", "Mask outlines", "view.maskOutlines", ""), - m!("View", "Frame outlines", "view.frameOutlines", ""), - m!("View", SEP, "", ""), - m!("View/Panels", "Show/Hide UI", "view.toggleUi", "Cmd+\\"), - m!("View/Panels", "Layers", "view.layers", "Alt+1"), - m!("View/Panels", "Assets", "view.assets", "Alt+2"), - m!("View/Panels", "Design", "view.design", "Alt+8"), - m!("View/Panels", "Prototype", "view.prototype", "Alt+9"), - m!("View", SEP, "", ""), - m!("View", "Zoom in", "view.zoomIn", "Cmd+="), - m!("View", "Zoom out", "view.zoomOut", "Cmd+-"), - m!("View", "Zoom to 100%", "view.zoom100", "Shift+0"), - m!("View", "Zoom to 50%", "view.zoom50", ""), - m!("View", "Zoom to 200%", "view.zoom200", ""), - m!("View", "Zoom to fit", "view.zoomFit", "Shift+1"), - m!("View", "Zoom to selection", "view.zoomSelection", "Shift+2"), - m!("View", "Zoom to previous frame", "view.prevFrame", "Shift+N"), - m!("View", "Zoom to next frame", "view.nextFrame", "N"), - m!("View", SEP, "", ""), - m!("View", "Previous page", "view.prevPage", "PageUp"), - m!("View", "Next page", "view.nextPage", "PageDown"), - m!("View/Theme", "Light", "view.themeLight", ""), - m!("View/Theme", "Dark", "view.themeDark", ""), - m!("View/Theme", "Follow system", "view.themeSystem", ""), + m!("View", "Pixel grid", "view.pixelGrid"), + m!("View", "Layout grids", "view.layoutGrids"), + m!("View", "Rulers", "view.rulers"), + m!("View", "Slices", "view.slices"), + m!("View", "Comments", "view.comments"), + m!("View", SEP, ""), + m!("View/Outlines", "Show outlines", "view.outlines"), + m!("View/Outlines", "Show outlines for selection", "view.outlinesSelection"), + m!("View", "Pixel preview", "view.pixelPreview"), + m!("View", "Mask outlines", "view.maskOutlines"), + m!("View", "Frame outlines", "view.frameOutlines"), + m!("View", SEP, ""), + m!("View/Panels", "Show/Hide UI", "view.toggleUi"), + m!("View/Panels", "Layers", "view.layers"), + m!("View/Panels", "Assets", "view.assets"), + m!("View/Panels", "Design", "view.design"), + m!("View/Panels", "Prototype", "view.prototype"), + m!("View", SEP, ""), + m!("View", "Zoom in", "view.zoomIn"), + m!("View", "Zoom out", "view.zoomOut"), + m!("View", "Zoom to 100%", "view.zoom100"), + m!("View", "Zoom to 50%", "view.zoom50"), + m!("View", "Zoom to 200%", "view.zoom200"), + m!("View", "Zoom to fit", "view.zoomFit"), + m!("View", "Zoom to selection", "view.zoomSelection"), + m!("View", "Zoom to previous frame", "view.prevFrame"), + m!("View", "Zoom to next frame", "view.nextFrame"), + m!("View", SEP, ""), + m!("View", "Previous page", "view.prevPage"), + m!("View", "Next page", "view.nextPage"), + m!("View/Theme", "Light", "view.themeLight"), + m!("View/Theme", "Dark", "view.themeDark"), + m!("View/Theme", "Follow system", "view.themeSystem"), // Object - m!("Object", "Frame selection", "object.frameSelection", "Alt+Cmd+G"), - m!("Object", "Group selection", "object.group", "Cmd+G"), - m!("Object", "Ungroup selection", "object.ungroup", "Shift+Cmd+G"), - m!("Object", "Remove frame", "object.removeFrame", ""), - m!("Object", "Resize to fit", "object.resizeToFit", "Alt+Shift+Cmd+R"), - m!("Object", SEP, "", ""), - m!("Object", "Add auto layout", "layout.add", "Shift+A"), - m!("Object", "Remove auto layout", "layout.remove", "Alt+Shift+A"), - m!("Object", "Suggest auto layout", "layout.suggest", ""), - m!("Object", SEP, "", ""), - m!("Object", "Create component", "component.create", "Alt+Cmd+K"), - m!("Object", "Create multiple components", "component.createMultiple", ""), - m!("Object", "Combine as variants", "component.combineVariants", ""), - m!("Object", "Reset all changes", "component.resetInstance", ""), - m!("Object", "Detach instance", "component.detach", "Alt+Cmd+B"), - m!("Object", "Go to main component", "component.goToMain", ""), - m!("Object", "Push changes to main component", "component.pushChanges", ""), - m!("Object", "Restore main component", "component.restoreMain", ""), - m!("Object", SEP, "", ""), - m!("Object", "Use as mask", "object.mask", "Ctrl+Cmd+M"), - m!("Object", "Release mask", "object.releaseMask", ""), - m!("Object", "Set as thumbnail", "object.setThumbnail", ""), - m!("Object", SEP, "", ""), - m!("Object", "Bring to front", "arrange.front", "Alt+Cmd+]"), - m!("Object", "Bring forward", "arrange.forward", "Cmd+]"), - m!("Object", "Send backward", "arrange.backward", "Cmd+["), - m!("Object", "Send to back", "arrange.back", "Alt+Cmd+["), - m!("Object", SEP, "", ""), - m!("Object", "Flip horizontal", "object.flipH", "Shift+H"), - m!("Object", "Flip vertical", "object.flipV", "Shift+V"), - m!("Object", "Rotate 180°", "object.rotate180", ""), - m!("Object", "Rotate 90° left", "object.rotateLeft", ""), - m!("Object", "Rotate 90° right", "object.rotateRight", ""), - m!("Object", SEP, "", ""), - m!("Object", "Flatten", "vector.flatten", "Cmd+E"), - m!("Object", "Outline stroke", "vector.outlineStroke", "Alt+Cmd+O"), - m!("Object/Boolean groups", "Union", "bool.union", ""), - m!("Object/Boolean groups", "Subtract", "bool.subtract", ""), - m!("Object/Boolean groups", "Intersect", "bool.intersect", ""), - m!("Object/Boolean groups", "Exclude", "bool.exclude", ""), - m!("Object/Boolean groups", "Flatten", "bool.flatten", ""), - m!("Object", "Rasterize selection", "object.rasterize", ""), - m!("Object", SEP, "", ""), - m!("Object", "Show/Hide selection", "object.toggleVisible", "Shift+Cmd+H"), - m!("Object", "Lock/Unlock selection", "object.toggleLock", "Shift+Cmd+L"), - m!("Object", "Hide other layers", "object.hideOthers", ""), - m!("Object", "Collapse layers", "layers.collapseAll", "Alt+L"), - m!("Object", SEP, "", ""), - m!("Object", "Remove fill", "object.removeFill", ""), - m!("Object", "Remove stroke", "object.removeStroke", ""), - m!("Object", "Swap fill and stroke", "object.swapFillStroke", "Shift+X"), - m!("Object", SEP, "", ""), - m!("Object", "Rename", "object.rename", "Cmd+R"), - m!("Object", "Batch rename…", "object.batchRename", ""), - m!("Object", "Move to page", "object.moveToPage", ""), - m!("Object/Select layer", "Select children", "select.children", "Enter"), - m!("Object/Select layer", "Select parent", "select.parent", "Shift+Enter"), - m!("Object/Select layer", "Select next sibling", "select.next", "Tab"), - m!("Object/Select layer", "Select previous sibling", "select.previous", "Shift+Tab"), + m!("Object", "Frame selection", "object.frameSelection"), + m!("Object", "Group selection", "object.group"), + m!("Object", "Ungroup selection", "object.ungroup"), + m!("Object", "Remove frame", "object.removeFrame"), + m!("Object", "Resize to fit", "object.resizeToFit"), + m!("Object", SEP, ""), + m!("Object", "Add auto layout", "layout.add"), + m!("Object", "Remove auto layout", "layout.remove"), + m!("Object", "Suggest auto layout", "layout.suggest"), + m!("Object", SEP, ""), + m!("Object", "Create component", "component.create"), + m!("Object", "Create multiple components", "component.createMultiple"), + m!("Object", "Combine as variants", "component.combineVariants"), + m!("Object", "Reset all changes", "component.resetInstance"), + m!("Object", "Detach instance", "component.detach"), + m!("Object", "Go to main component", "component.goToMain"), + m!("Object", "Push changes to main component", "component.pushChanges"), + m!("Object", "Restore main component", "component.restoreMain"), + m!("Object", SEP, ""), + m!("Object", "Use as mask", "object.mask"), + m!("Object", "Release mask", "object.releaseMask"), + m!("Object", "Set as thumbnail", "object.setThumbnail"), + m!("Object", SEP, ""), + m!("Object", "Bring to front", "arrange.front"), + m!("Object", "Bring forward", "arrange.forward"), + m!("Object", "Send backward", "arrange.backward"), + m!("Object", "Send to back", "arrange.back"), + m!("Object", SEP, ""), + m!("Object", "Flip horizontal", "object.flipH"), + m!("Object", "Flip vertical", "object.flipV"), + m!("Object", "Rotate 180°", "object.rotate180"), + m!("Object", "Rotate 90° left", "object.rotateLeft"), + m!("Object", "Rotate 90° right", "object.rotateRight"), + m!("Object", SEP, ""), + m!("Object", "Flatten", "vector.flatten"), + m!("Object", "Outline stroke", "vector.outlineStroke"), + m!("Object/Boolean groups", "Union", "bool.union"), + m!("Object/Boolean groups", "Subtract", "bool.subtract"), + m!("Object/Boolean groups", "Intersect", "bool.intersect"), + m!("Object/Boolean groups", "Exclude", "bool.exclude"), + m!("Object/Boolean groups", "Flatten", "bool.flatten"), + m!("Object", "Rasterize selection", "object.rasterize"), + m!("Object", SEP, ""), + m!("Object", "Show/Hide selection", "object.toggleVisible"), + m!("Object", "Lock/Unlock selection", "object.toggleLock"), + m!("Object", "Hide other layers", "object.hideOthers"), + m!("Object", "Collapse layers", "layers.collapseAll"), + m!("Object", SEP, ""), + m!("Object", "Remove fill", "object.removeFill"), + m!("Object", "Remove stroke", "object.removeStroke"), + m!("Object", "Swap fill and stroke", "object.swapFillStroke"), + m!("Object", SEP, ""), + m!("Object", "Rename", "object.rename"), + m!("Object", "Batch rename…", "object.batchRename"), + m!("Object", "Move to page", "object.moveToPage"), + m!("Object/Select layer", "Select children", "select.children"), + m!("Object/Select layer", "Select parent", "select.parent"), + m!("Object/Select layer", "Select next sibling", "select.next"), + m!("Object/Select layer", "Select previous sibling", "select.previous"), // Text - m!("Text", "Bold", "text.bold", "Cmd+B"), - m!("Text", "Italic", "text.italic", "Cmd+I"), - m!("Text", "Underline", "text.underline", "Cmd+U"), - m!("Text", "Strikethrough", "text.strikethrough", "Shift+Cmd+X"), - m!("Text", "Create link", "text.link", "Cmd+K"), - m!("Text", SEP, "", ""), - m!("Text", "Bulleted list", "text.bulletList", "Shift+Cmd+8"), - m!("Text", "Numbered list", "text.numberList", "Shift+Cmd+7"), - m!("Text", SEP, "", ""), - m!("Text/Alignment", "Text align left", "text.alignLeft", "Alt+Cmd+L"), - m!("Text/Alignment", "Text align center", "text.alignCenter", "Alt+Cmd+T"), - m!("Text/Alignment", "Text align right", "text.alignRight", "Alt+Cmd+R"), - m!("Text/Alignment", "Text align justified", "text.alignJustify", "Alt+Cmd+J"), - m!("Text/Adjust", "Increase font size", "text.sizeUp", "Shift+Cmd+."), - m!("Text/Adjust", "Decrease font size", "text.sizeDown", "Shift+Cmd+,"), - m!("Text/Adjust", "Increase font weight", "text.weightUp", "Alt+Cmd+."), - m!("Text/Adjust", "Decrease font weight", "text.weightDown", "Alt+Cmd+,"), - m!("Text/Adjust", "Increase line height", "text.lineHeightUp", "Alt+Shift+."), - m!("Text/Adjust", "Decrease line height", "text.lineHeightDown", "Alt+Shift+,"), - m!("Text/Adjust", "Increase letter spacing", "text.trackingUp", "Alt+."), - m!("Text/Adjust", "Decrease letter spacing", "text.trackingDown", "Alt+,"), - m!("Text/Case", "Original case", "text.caseOriginal", ""), - m!("Text/Case", "Uppercase", "text.caseUpper", ""), - m!("Text/Case", "Lowercase", "text.caseLower", ""), - m!("Text/Case", "Title case", "text.caseTitle", ""), + m!("Text", "Bold", "text.bold"), + m!("Text", "Italic", "text.italic"), + m!("Text", "Underline", "text.underline"), + m!("Text", "Strikethrough", "text.strikethrough"), + m!("Text", "Create link", "text.link"), + m!("Text", SEP, ""), + m!("Text", "Bulleted list", "text.bulletList"), + m!("Text", "Numbered list", "text.numberList"), + m!("Text", SEP, ""), + m!("Text/Alignment", "Text align left", "text.alignLeft"), + m!("Text/Alignment", "Text align center", "text.alignCenter"), + m!("Text/Alignment", "Text align right", "text.alignRight"), + m!("Text/Alignment", "Text align justified", "text.alignJustify"), + m!("Text/Adjust", "Increase font size", "text.sizeUp"), + m!("Text/Adjust", "Decrease font size", "text.sizeDown"), + m!("Text/Adjust", "Increase font weight", "text.weightUp"), + m!("Text/Adjust", "Decrease font weight", "text.weightDown"), + m!("Text/Adjust", "Increase line height", "text.lineHeightUp"), + m!("Text/Adjust", "Decrease line height", "text.lineHeightDown"), + m!("Text/Adjust", "Increase letter spacing", "text.trackingUp"), + m!("Text/Adjust", "Decrease letter spacing", "text.trackingDown"), + m!("Text/Case", "Original case", "text.caseOriginal"), + m!("Text/Case", "Uppercase", "text.caseUpper"), + m!("Text/Case", "Lowercase", "text.caseLower"), + m!("Text/Case", "Title case", "text.caseTitle"), // Arrange - m!("Arrange", "Align left", "align.left", "Alt+A"), - m!("Arrange", "Align horizontal centers", "align.hcenter", "Alt+H"), - m!("Arrange", "Align right", "align.right", "Alt+D"), - m!("Arrange", "Align top", "align.top", "Alt+W"), - m!("Arrange", "Align vertical centers", "align.vcenter", "Alt+V"), - m!("Arrange", "Align bottom", "align.bottom", "Alt+S"), - m!("Arrange", SEP, "", ""), - m!("Arrange", "Distribute horizontal spacing", "distribute.horizontal", "Alt+Shift+H"), - m!("Arrange", "Distribute vertical spacing", "distribute.vertical", "Alt+Shift+V"), - m!("Arrange", "Tidy up", "arrange.tidy", "Ctrl+Alt+T"), - m!("Arrange", SEP, "", ""), - m!("Arrange", "Pack horizontal", "arrange.packH", ""), - m!("Arrange", "Pack vertical", "arrange.packV", ""), - m!("Arrange", SEP, "", ""), - m!("Arrange", "Round to pixels", "arrange.roundToPixels", ""), + m!("Arrange", "Align left", "align.left"), + m!("Arrange", "Align horizontal centers", "align.hcenter"), + m!("Arrange", "Align right", "align.right"), + m!("Arrange", "Align top", "align.top"), + m!("Arrange", "Align vertical centers", "align.vcenter"), + m!("Arrange", "Align bottom", "align.bottom"), + m!("Arrange", SEP, ""), + m!("Arrange", "Distribute horizontal spacing", "distribute.horizontal"), + m!("Arrange", "Distribute vertical spacing", "distribute.vertical"), + m!("Arrange", "Tidy up", "arrange.tidy"), + m!("Arrange", SEP, ""), + m!("Arrange", "Pack horizontal", "arrange.packH"), + m!("Arrange", "Pack vertical", "arrange.packV"), + m!("Arrange", SEP, ""), + m!("Arrange", "Round to pixels", "arrange.roundToPixels"), // Vector - m!("Vector", "Join selection", "vector.join", "Cmd+J"), - m!("Vector", "Smooth join selection", "vector.smoothJoin", "Shift+Cmd+J"), - m!("Vector", "Delete and heal selection", "vector.deleteHeal", "Shift+Delete"), - m!("Vector", "Simplify vector", "vector.simplify", ""), - m!("Vector", "Reverse path direction", "vector.reverse", ""), - m!("Vector", "Offset path", "vector.offset", ""), + m!("Vector", "Join selection", "vector.join"), + m!("Vector", "Smooth join selection", "vector.smoothJoin"), + m!("Vector", "Delete and heal selection", "vector.deleteHeal"), + m!("Vector", "Simplify vector", "vector.simplify"), + m!("Vector", "Reverse path direction", "vector.reverse"), + m!("Vector", "Offset path", "vector.offset"), // Plugins - m!("Plugins", "Run last plugin", "plugins.runLast", "Alt+Cmd+P"), - m!("Plugins", "Manage plugins…", "plugins.manage", ""), + m!("Plugins", "Run last plugin", "plugins.runLast"), + m!("Plugins", "Manage plugins…", "plugins.manage"), // Preferences - m!("Preferences", "Snap to geometry", "view.snapGeometry", ""), - m!("Preferences", "Snap to objects", "view.snapObjects", ""), - m!("Preferences", "Snap to pixel grid", "view.snapPixel", "Shift+Cmd+'"), - m!("Preferences", "Keep tool selected after use", "pref.keepTool", ""), - m!("Preferences", "Highlight layers on hover", "pref.highlightHover", ""), - m!("Preferences", "Rename duplicated layers", "pref.renameDuplicates", ""), - m!("Preferences", "Show dimensions on objects", "pref.showDimensions", ""), - m!("Preferences", "Use number keys for opacity", "pref.numberKeysOpacity", ""), - m!("Preferences", "Nudge amount…", "pref.nudge", ""), - m!("Preferences", "Preferences…", "app.preferences", "Cmd+,"), + m!("Preferences", "Snap to geometry", "view.snapGeometry"), + m!("Preferences", "Snap to objects", "view.snapObjects"), + m!("Preferences", "Snap to pixel grid", "view.snapPixel"), + m!("Preferences", "Keep tool selected after use", "pref.keepTool"), + m!("Preferences", "Highlight layers on hover", "pref.highlightHover"), + m!("Preferences", "Rename duplicated layers", "pref.renameDuplicates"), + m!("Preferences", "Show dimensions on objects", "pref.showDimensions"), + m!("Preferences", "Use number keys for opacity", "pref.numberKeysOpacity"), + m!("Preferences", "Nudge amount…", "pref.nudge"), + m!("Preferences", "Preferences…", "app.preferences"), // Libraries - m!("Libraries", "Open libraries", "libraries.open", "Alt+3"), - m!("Libraries", "Publish library…", "libraries.publish", ""), + m!("Libraries", "Open libraries", "libraries.open"), + m!("Libraries", "Publish library…", "libraries.publish"), // Help - m!("Help", "Keyboard shortcuts", "help.shortcuts", "Ctrl+Shift+?"), - m!("Help", "Help page", "help.page", ""), - m!("Help", "About FigCraft", "help.about", ""), + m!("Help", "Keyboard shortcuts", "help.shortcuts"), + m!("Help", "Help page", "help.page"), + m!("Help", "About FigCraft", "help.about"), // Toolbar - m!("Tools", "Move", "tool.move", "V"), - m!("Tools", "Scale", "tool.scale", "K"), - m!("Tools", "Hand", "tool.hand", "H"), - m!("Tools", "Frame", "tool.frame", "F"), - m!("Tools", "Section", "tool.section", "Shift+S"), - m!("Tools", "Slice", "tool.slice", "S"), - m!("Tools", "Rectangle", "tool.rectangle", "R"), - m!("Tools", "Line", "tool.line", "L"), - m!("Tools", "Arrow", "tool.arrow", "Shift+L"), - m!("Tools", "Ellipse", "tool.ellipse", "O"), - m!("Tools", "Polygon", "tool.polygon", ""), - m!("Tools", "Star", "tool.star", ""), - m!("Tools", "Pen", "tool.pen", "P"), - m!("Tools", "Pencil", "tool.pencil", "Shift+P"), - m!("Tools", "Text", "tool.text", "T"), - m!("Tools", "Comment", "tool.comment", "C"), - m!("Tools", "Shape builder", "tool.shapeBuilder", "Shift+M"), - m!("Tools", "Actions", "app.actions", "Cmd+K"), + m!("Tools", "Move", "tool.move"), + m!("Tools", "Scale", "tool.scale"), + m!("Tools", "Hand", "tool.hand"), + m!("Tools", "Frame", "tool.frame"), + m!("Tools", "Section", "tool.section"), + m!("Tools", "Slice", "tool.slice"), + m!("Tools", "Rectangle", "tool.rectangle"), + m!("Tools", "Line", "tool.line"), + m!("Tools", "Arrow", "tool.arrow"), + m!("Tools", "Ellipse", "tool.ellipse"), + m!("Tools", "Polygon", "tool.polygon"), + m!("Tools", "Star", "tool.star"), + m!("Tools", "Pen", "tool.pen"), + m!("Tools", "Pencil", "tool.pencil"), + m!("Tools", "Text", "tool.text"), + m!("Tools", "Comment", "tool.comment"), + m!("Tools", "Shape builder", "tool.shapeBuilder"), + m!("Tools", "Actions", "app.actions"), ]; /// Items that are real commands (not separators). @@ -281,7 +280,7 @@ pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { (done, total, missing) } -/// The default shortcut for a command id. +/// The default shortcut for a command id on this platform (notation of [`crate::keymap`]). pub fn shortcut(id: &str) -> Option<&'static str> { - MENU.iter().find(|m| m.id == id && !m.shortcut.is_empty()).map(|m| m.shortcut) + crate::keymap::primary(id, crate::keymap::Platform::current()) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d006f893..ac445e4f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -161,6 +161,13 @@ pub fn specs() -> Vec { spec!("object.toggleVisible", "Show/Hide selection", "{ids?, visible?}", true, |s, p| toggle(s, p, "visible")), spec!("object.toggleLock", "Lock/Unlock selection", "{ids?, locked?}", true, |s, p| toggle(s, p, "locked")), spec!("object.hideOthers", "Hide other layers", "{}", true, hide_others), + spec!( + "object.opacityKeys", + "Opacity from number keys", + "{digits (\"1\"…\"9\": 10%…90%, \"0\": 100%; two digits: that percentage, \"45\" is 45%), ids?}", + true, + opacity_keys + ), spec!("object.removeFill", "Remove fill", "{ids?}", true, |s, p| { for i in req_targets("object.removeFill", s, p)? { s.doc_mut()?.get_mut(i)?.fills.clear(); @@ -728,6 +735,32 @@ fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result Option { + let mut n = 0u32; + let mut count = 0; + for c in digits.chars() { + n = n * 10 + c.to_digit(10)?; + count += 1; + } + match count { + 1 if n == 0 => Some(1.0), + 1 => Some(n as f32 / 10.0), + 2 => Some(n as f32 / 100.0), + _ => None, + } +} + +fn opacity_keys(s: &mut Session, p: &Value) -> Result { + let digits = req_string("object.opacityKeys", p, "digits")?; + let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; + for i in req_targets("object.opacityKeys", s, p)? { + s.doc_mut()?.get_mut(i)?.opacity = o; + } + Ok(json!({"opacity": o})) +} + fn nudge(s: &mut Session, p: &Value) -> Result { let big = boolean(p, "big").unwrap_or(false); let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6824a251..df2b923e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -194,6 +194,8 @@ pub fn specs() -> Vec { s.active = i; ok() }), + spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)), + spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)), spec!("doc.list", "List documents", "{}", false, |s, _| { Ok(Value::Array( s.docs @@ -921,6 +923,16 @@ fn close(s: &mut Session, p: &Value) -> Result { ok() } +/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around. +fn cycle_doc(s: &mut Session, dir: i64) -> Result { + let n = s.docs.len() as i64; + if n == 0 { + return Err(crate::EngineError::NoDocument); + } + s.active = (s.active as i64 + dir).rem_euclid(n) as usize; + Ok(json!({"index": s.active})) +} + #[cfg(test)] mod rest_import_tests { use super::*; diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index a45428aa..852703b7 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -54,6 +54,25 @@ fn step(z: f64, up: bool) -> f64 { } } +/// The zoom tool: a click zooms a step in (or out) about the clicked point, a dragged area fills +/// the view. +fn zoom_at(s: &mut Session, p: &Value) -> Result { + let (Some(x), Some(y)) = (num(p, "x").filter(|v| v.is_finite()), num(p, "y").filter(|v| v.is_finite())) else { + return Err(bad("view.zoomAt", "`x` and `y` (page coordinates) are required")); + }; + let v = view(s)?; + match (num(p, "width"), num(p, "height")) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => { + v.fit(figcraft_geom::Rect::new(x, y, x + w, y + h), ViewState::MAX_ZOOM); + } + _ => { + let z = step(v.zoom, !boolean(p, "out").unwrap_or(false)); + v.set_zoom_at(z, (x, y)); + } + } + Ok(json!({"zoom": v.zoom, "cx": v.cx, "cy": v.cy})) +} + fn zoom_frame(s: &mut Session, dir: i64) -> Result { let ds = s.ds()?; let frames = ds.doc.top_frames(ds.page); @@ -146,6 +165,13 @@ pub fn specs() -> Vec { } Ok(serde_json::to_value(&*v).unwrap_or(Value::Null)) }), + spec!( + "view.zoomAt", + "Zoom tool", + "{x, y (page point kept in place), out? (zoom out a step), width?, height? (an area to fit instead)}", + false, + zoom_at + ), spec!("view.get", "Get view", "{}", false, |s, _| Ok(serde_json::to_value(&s.ds()?.view).unwrap_or(Value::Null))), spec!("view.prevFrame", "Zoom to previous frame", "{}", false, |s, _| zoom_frame(s, -1)), spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)), @@ -167,6 +193,9 @@ pub fn specs() -> Vec { spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")), spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")), spec!("view.prototype", "Prototype panel", "{}", false, |s, _| ui(s, "panel.prototype")), + spec!("view.designPrototype", "Switch between Design and Prototype", "{}", false, |s, _| ui(s, "panel.designPrototype")), + spec!("view.devMode", "Dev Mode", "{}", false, |s, _| ui(s, "panel.devMode")), + spec!("view.leftSidebar", "Show/Hide left sidebar", "{}", false, |s, _| ui(s, "panel.leftSidebar")), spec!("view.themeLight", "Light theme", "{}", false, |s, _| { s.prefs.theme = "light".into(); ok() diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs new file mode 100644 index 00000000..f4d5cbb8 --- /dev/null +++ b/crates/engine/src/keymap.rs @@ -0,0 +1,499 @@ +//! Default keyboard shortcuts, grouped like the public shortcut reference, with the macOS keys and +//! the Windows/Linux keys where they differ. The menus, the Keyboard shortcuts panel, the canvas, +//! the control channel and the MCP `key` tool all resolve keys here, so a binding is defined once. +//! +//! Notation: modifiers then the key, joined by `+` (`Shift+Cmd+K`). `Cmd` is the platform's +//! command key (⌘ on macOS, Ctrl elsewhere), `Ctrl` the Control key (on Windows and Linux the same +//! key as `Cmd`), `Alt` is ⌥ Option on macOS. Keys: letters, digits, `= - [ ] \ ' , . / ;` and +//! the names `Enter`, `Escape`, `Tab`, `Delete` (Backspace or Delete), `Space`, `PageUp`, +//! `PageDown`, `Home`, `End`, `ArrowLeft`/`Right`/`Up`/`Down`, `F1`…`F12`. Shifted symbols +//! (`?`, `+`, `<`, `>`, `{`, `}`, `|`, `!`) stand for the unshifted key with Shift held. + +/// Which platform's keys to use. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize)] +pub enum Platform { + Mac, + /// Windows and Linux. + Other, +} + +impl Platform { + /// The platform this build runs on (the web build asks the browser instead, in the UI). + pub const fn current() -> Self { + if cfg!(target_os = "macos") { Self::Mac } else { Self::Other } + } +} + +/// The tabs of the Keyboard shortcuts panel. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Group { + Essential, + Tools, + View, + Zoom, + Text, + Shape, + Selection, + Edit, + Transform, + Arrange, + Components, + Prototype, +} + +impl Group { + pub const ALL: [Group; 12] = [ + Group::Essential, + Group::Tools, + Group::View, + Group::Zoom, + Group::Text, + Group::Shape, + Group::Selection, + Group::Edit, + Group::Transform, + Group::Arrange, + Group::Components, + Group::Prototype, + ]; + + pub fn label(self) -> &'static str { + match self { + Group::Essential => "Essential", + Group::Tools => "Tools", + Group::View => "View", + Group::Zoom => "Zoom", + Group::Text => "Text", + Group::Shape => "Shape", + Group::Selection => "Selection", + Group::Edit => "Edit", + Group::Transform => "Transform", + Group::Arrange => "Arrange", + Group::Components => "Components", + Group::Prototype => "Prototype", + } + } +} + +/// Where a binding is live. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Context { + /// On the canvas (not while editing text). + Canvas, + /// Only while editing text. + Text, + /// On the canvas and while editing text. + Both, +} + +/// One command's default keys. +#[derive(Clone, Copy, Debug)] +pub struct Binding { + pub group: Group, + /// The command id (an engine command or one the UI handles). + pub id: &'static str, + /// The label in the shortcuts panel; empty: the command's menu label. + label: &'static str, + /// macOS keys, the first shown in menus. + pub mac: &'static [&'static str], + /// Windows/Linux keys; empty: the macOS keys with `Cmd` read as Ctrl. + pub other: &'static [&'static str], + pub context: Context, +} + +impl Binding { + const fn new(group: Group, id: &'static str, mac: &'static [&'static str]) -> Self { + Self { group, id, label: "", mac, other: &[], context: Context::Canvas } + } + /// Different keys on Windows and Linux. + const fn win(mut self, keys: &'static [&'static str]) -> Self { + self.other = keys; + self + } + const fn label(mut self, label: &'static str) -> Self { + self.label = label; + self + } + /// Live while editing text too. + const fn both(mut self) -> Self { + self.context = Context::Both; + self + } + /// Live only while editing text. + const fn text(mut self) -> Self { + self.context = Context::Text; + self + } + + /// The keys on `platform`, the first being the one menus show. + pub fn keys(&self, platform: Platform) -> &'static [&'static str] { + match platform { + Platform::Other if !self.other.is_empty() => self.other, + _ => self.mac, + } + } + + /// The label for the shortcuts panel: its own, else the menu's, else the command's. + pub fn title(&self) -> &'static str { + if !self.label.is_empty() { + return self.label; + } + crate::catalog::items() + .find(|m| m.id == self.id) + .map(|m| m.label) + .or_else(|| crate::find_command(self.id).map(|c| c.label)) + .unwrap_or(self.id) + } + + fn live_in(&self, context: Context) -> bool { + self.context == context || self.context == Context::Both + } +} + +use Group::*; + +const fn k(group: Group, id: &'static str, mac: &'static [&'static str]) -> Binding { + Binding::new(group, id, mac) +} + +/// Every default shortcut, in panel order within each group. +pub static KEYS: &[Binding] = &[ + // Essential + k(Essential, "app.actions", &["Cmd+K", "Cmd+/"]).label("Actions…"), + k(Essential, "help.shortcuts", &["Ctrl+Shift+?"]).both(), + k(Essential, "view.toggleUi", &["Cmd+\\"]), + k(Essential, "edit.pickColor", &["I", "Ctrl+C"]).win(&["I"]), + k(Essential, "app.preferences", &["Cmd+,"]), + k(Essential, "file.new", &["Cmd+N"]), + k(Essential, "file.open", &["Cmd+O"]), + k(Essential, "file.save", &["Cmd+S"]).both(), + k(Essential, "file.saveAs", &["Shift+Cmd+S"]).both(), + k(Essential, "file.saveVersion", &["Alt+Cmd+S"]), + k(Essential, "file.close", &["Cmd+W"]), + k(Essential, "edit.findReplace", &["Cmd+F"]), + // Tools + k(Tools, "tool.move", &["V"]), + k(Tools, "tool.scale", &["K"]), + k(Tools, "tool.hand", &["H"]), + k(Tools, "tool.frame", &["F"]), + k(Tools, "tool.section", &["Shift+S"]), + k(Tools, "tool.slice", &["S"]), + k(Tools, "tool.rectangle", &["R"]), + k(Tools, "tool.line", &["L"]), + k(Tools, "tool.arrow", &["Shift+L"]), + k(Tools, "tool.ellipse", &["O"]), + k(Tools, "file.placeImage", &["Shift+Cmd+K"]).label("Place image or video"), + k(Tools, "tool.pen", &["P"]), + k(Tools, "tool.pencil", &["Shift+P"]), + k(Tools, "tool.text", &["T"]), + k(Tools, "tool.comment", &["C"]), + k(Tools, "tool.shapeBuilder", &["Shift+M"]), + // View + k(View, "view.pixelGrid", &["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.snapPixel", &["Shift+Cmd+'"]), + k(View, "view.layers", &["Alt+1"]).label("Layers panel"), + k(View, "view.assets", &["Alt+2"]).label("Assets panel"), + k(View, "libraries.open", &["Alt+3"]), + k(View, "view.design", &["Alt+8"]).label("Design panel"), + k(View, "view.prototype", &["Alt+9"]).label("Prototype panel"), + 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, "layers.collapseAll", &["Alt+L"]), + k(View, "doc.nextTab", &["Ctrl+Tab"]), + k(View, "doc.previousTab", &["Ctrl+Shift+Tab"]), + k(View, "view.prevPage", &["PageUp"]), + k(View, "view.nextPage", &["PageDown"]), + // Zoom + k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+="]), + k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-"]), + k(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"]), + k(Zoom, "view.zoomFit", &["Shift+1"]), + k(Zoom, "view.zoomSelection", &["Shift+2"]), + k(Zoom, "view.prevFrame", &["Shift+N", "Home"]), + k(Zoom, "view.nextFrame", &["N", "End"]), + // Text (on selected text layers, and on the selected characters while editing) + k(Text, "text.bold", &["Cmd+B"]).both(), + 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.bulletList", &["Shift+Cmd+8"]).both(), + k(Text, "text.numberList", &["Shift+Cmd+7"]).both(), + k(Text, "text.alignLeft", &["Alt+Cmd+L"]).both(), + k(Text, "text.alignCenter", &["Alt+Cmd+T"]).both(), + k(Text, "text.alignRight", &["Alt+Cmd+R"]).both(), + k(Text, "text.alignJustify", &["Alt+Cmd+J"]).both(), + k(Text, "text.sizeUp", &["Shift+Cmd+."]).both(), + k(Text, "text.sizeDown", &["Shift+Cmd+,"]).both(), + k(Text, "text.weightUp", &["Alt+Cmd+."]).both(), + k(Text, "text.weightDown", &["Alt+Cmd+,"]).both(), + k(Text, "text.lineHeightUp", &["Alt+Shift+."]).both(), + k(Text, "text.lineHeightDown", &["Alt+Shift+,"]).both(), + k(Text, "text.trackingUp", &["Alt+."]).both(), + k(Text, "text.trackingDown", &["Alt+,"]).both(), + // Shape + k(Shape, "vector.flatten", &["Cmd+E"]), + k(Shape, "vector.outlineStroke", &["Alt+Cmd+O"]), + k(Shape, "bool.union", &["Alt+Shift+U"]), + k(Shape, "bool.subtract", &["Alt+Shift+S"]), + k(Shape, "bool.intersect", &["Alt+Shift+I"]), + k(Shape, "bool.exclude", &["Alt+Shift+E"]), + k(Shape, "object.removeFill", &["Alt+/"]), + k(Shape, "object.removeStroke", &["Shift+/"]), + k(Shape, "object.swapFillStroke", &["Shift+X"]), + k(Shape, "vector.join", &["Cmd+J"]), + k(Shape, "vector.smoothJoin", &["Shift+Cmd+J"]), + k(Shape, "vector.deleteHeal", &["Shift+Delete"]), + // Selection + k(Selection, "select.all", &["Cmd+A"]), + k(Selection, "select.inverse", &["Shift+Cmd+A"]), + k(Selection, "select.matching", &["Alt+Cmd+A"]), + k(Selection, "select.none", &["Escape"]), + k(Selection, "select.children", &["Enter"]), + k(Selection, "select.parent", &["Shift+Enter", "\\"]), + 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.frameSelection", &["Alt+Cmd+G"]), + k(Selection, "object.toggleVisible", &["Shift+Cmd+H"]), + k(Selection, "object.toggleLock", &["Shift+Cmd+L"]), + k(Selection, "object.rename", &["Cmd+R"]), + // Edit + k(Edit, "edit.undo", &["Cmd+Z"]), + k(Edit, "edit.redo", &["Shift+Cmd+Z"]), + k(Edit, "edit.copy", &["Cmd+C"]), + k(Edit, "edit.cut", &["Cmd+X"]), + k(Edit, "edit.paste", &["Cmd+V"]), + k(Edit, "edit.pasteOver", &["Shift+Cmd+V"]), + k(Edit, "edit.pasteReplace", &["Shift+Cmd+R"]), + k(Edit, "edit.duplicate", &["Cmd+D"]), + k(Edit, "edit.delete", &["Delete"]), + k(Edit, "edit.copyProperties", &["Alt+Cmd+C"]), + k(Edit, "edit.pasteProperties", &["Alt+Cmd+V"]), + k(Edit, "edit.copyAsPng", &["Shift+Cmd+C"]), + k(Edit, "file.export", &["Shift+Cmd+E"]), + k(Edit, "file.import", &["Shift+Cmd+I"]), + // Transform + k(Transform, "object.flipH", &["Shift+H"]), + k(Transform, "object.flipV", &["Shift+V"]), + k(Transform, "object.mask", &["Ctrl+Cmd+M"]).win(&["Ctrl+Alt+M"]), + k(Transform, "object.resizeToFit", &["Alt+Shift+Cmd+R"]), + // Arrange + k(Arrange, "arrange.front", &["Alt+Cmd+]"]).win(&["Shift+Cmd+]"]), + k(Arrange, "arrange.forward", &["Cmd+]"]), + k(Arrange, "arrange.backward", &["Cmd+["]), + k(Arrange, "arrange.back", &["Alt+Cmd+["]).win(&["Shift+Cmd+["]), + k(Arrange, "align.left", &["Alt+A"]), + k(Arrange, "align.hcenter", &["Alt+H"]), + k(Arrange, "align.right", &["Alt+D"]), + k(Arrange, "align.top", &["Alt+W"]), + k(Arrange, "align.vcenter", &["Alt+V"]), + k(Arrange, "align.bottom", &["Alt+S"]), + k(Arrange, "distribute.horizontal", &["Ctrl+Alt+H"]).win(&["Alt+Shift+H"]), + k(Arrange, "distribute.vertical", &["Ctrl+Alt+V"]).win(&["Alt+Shift+V"]), + 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"]), + // Components + k(Components, "component.create", &["Alt+Cmd+K"]), + k(Components, "component.detach", &["Alt+Cmd+B"]), + k(Components, "view.assets", &["Shift+I"]).label("Insert a component (Assets)"), + // Prototype + k(Prototype, "proto.present", &["Alt+Cmd+Enter"]).label("Present"), + k(Prototype, "proto.preview", &["Shift+Space"]).label("Preview in the canvas"), +]; + +/// Pointer gestures and held keys the panel lists next to the bindings (handled by the canvas, not +/// 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"), + (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"), + (Selection, "Deep select", "⌘ click", "Ctrl+click"), + (Selection, "Measure to the selection", "Hold ⌥", "Hold Alt"), + (Edit, "Duplicate by dragging", "⌥ drag", "Alt+drag"), + (Transform, "Nudge (Shift: big nudge)", "← ↑ → ↓", "← ↑ → ↓"), + (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"), +]; + +/// Modifier keys held (as pressed: on Windows and Linux Ctrl may come as `cmd`, `ctrl` or both). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Mods { + pub shift: bool, + pub alt: bool, + pub cmd: bool, + pub ctrl: bool, +} + +impl Mods { + pub fn is_none(self) -> bool { + self == Mods::default() + } +} + +/// A key name as canonical (`=` for `Equals`, `ArrowLeft` for `Left`, `Delete` for `Backspace`), +/// and whether the name implies Shift (`?` is Shift+`/`). `None` for names that aren't keys. +pub fn normalize(name: &str) -> Option<(&str, bool)> { + let shifted = |k: &'static str| Some((k, true)); + let plain = |k: &'static str| Some((k, false)); + match name { + "" => None, + "Plus" | "+" => shifted("="), + "Questionmark" | "?" => shifted("/"), + "<" => shifted(","), + ">" => shifted("."), + "OpenCurlyBracket" | "{" => shifted("["), + "CloseCurlyBracket" | "}" => shifted("]"), + "Pipe" | "|" => shifted("\\"), + "Exclamationmark" | "!" => shifted("1"), + "Colon" | ":" => shifted(";"), + "\"" => shifted("'"), + "_" => shifted("-"), + "Equals" => plain("="), + "Minus" => plain("-"), + "OpenBracket" => plain("["), + "CloseBracket" => plain("]"), + "Backslash" => plain("\\"), + "Quote" => plain("'"), + "Comma" => plain(","), + "Period" => plain("."), + "Slash" => plain("/"), + "Semicolon" => plain(";"), + "Backtick" => plain("`"), + "Backspace" | "Delete" | "Del" => plain("Delete"), + "Return" | "Enter" => plain("Enter"), + "Esc" | "Escape" => plain("Escape"), + "Left" | "ArrowLeft" => plain("ArrowLeft"), + "Right" | "ArrowRight" => plain("ArrowRight"), + "Up" | "ArrowUp" => plain("ArrowUp"), + "Down" | "ArrowDown" => plain("ArrowDown"), + n if n.chars().count() == 1 => Some((n, false)), + n if NAMED.iter().any(|k| k.eq_ignore_ascii_case(n)) => Some((n, false)), + _ => None, + } +} + +const NAMED: &[&str] = + &["Tab", "Space", "PageUp", "PageDown", "Home", "End", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]; + +/// Parse a binding (`Shift+Cmd+K`) into its modifiers and canonical key. +pub fn parse(spec: &str) -> Option<(Mods, &str)> { + let mut m = Mods::default(); + let mut key = None; + for part in spec.split('+') { + match part { + "Shift" => m.shift = true, + "Cmd" => m.cmd = true, + "Alt" | "Option" => m.alt = true, + "Ctrl" => m.ctrl = true, + k => { + if key.is_some() { + return None; + } + let (k, shift) = normalize(k)?; + m.shift |= shift; + key = Some(k); + } + } + } + key.map(|k| (m, k)) +} + +fn same_mods(platform: Platform, want: Mods, got: Mods) -> bool { + match platform { + Platform::Mac => want == got, + // Cmd and Ctrl are one key here. + Platform::Other => want.shift == got.shift && want.alt == got.alt && (want.cmd || want.ctrl) == (got.cmd || got.ctrl), + } +} + +/// Whether a binding fires for `key` with these modifiers on `platform`. +pub fn matches(spec: &str, platform: Platform, key: &str, mods: Mods) -> bool { + let Some((key, shift)) = normalize(key) else { return false }; + let mods = Mods { shift: mods.shift || shift, ..mods }; + parse(spec).is_some_and(|(want, k)| k.eq_ignore_ascii_case(key) && same_mods(platform, want, mods)) +} + +/// The binding `key` with these modifiers fires in `context` on `platform`. +pub fn lookup(platform: Platform, context: Context, key: &str, mods: Mods) -> Option<&'static Binding> { + KEYS.iter().filter(|b| b.live_in(context)).find(|b| b.keys(platform).iter().any(|s| matches(s, platform, key, mods))) +} + +/// A command's keys on `platform` (all its bindings, the menu's first). +pub fn bindings(id: &str, platform: Platform) -> impl Iterator { + KEYS.iter().filter(move |b| b.id == id).flat_map(move |b| b.keys(platform).iter().copied()) +} + +/// The key a menu shows for a command on `platform`. +pub fn primary(id: &str, platform: Platform) -> Option<&'static str> { + bindings(id, platform).next() +} + +/// A binding as the platform writes it: `⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere. +pub fn display(spec: &str, platform: Platform) -> String { + if spec.is_empty() { + return String::new(); + } + let mut m = Mods::default(); + let mut key = ""; + for part in spec.split('+') { + match part { + "Shift" => m.shift = true, + "Cmd" => m.cmd = true, + "Alt" | "Option" => m.alt = true, + "Ctrl" => m.ctrl = true, + k => key = k, + } + } + // The zoom-in key reads as `+` (with Shift it types one). + if key == "=" { + key = "+"; + m.shift = false; + } + let mac = platform == Platform::Mac; + let key = match (key, mac) { + ("Delete", true) => "⌫", + ("Enter", true) => "↵", + ("Tab", true) => "⇥", + ("PageUp", true) => "⇞", + ("PageDown", true) => "⇟", + ("Delete", false) => "Backspace", + ("PageUp", false) => "PgUp", + ("PageDown", false) => "PgDn", + ("Escape", _) => "Esc", + ("ArrowLeft", _) => "←", + ("ArrowRight", _) => "→", + ("ArrowUp", _) => "↑", + ("ArrowDown", _) => "↓", + (k, _) => k, + }; + let mut out = String::new(); + if mac { + for (on, glyph) in [(m.ctrl, "⌃"), (m.alt, "⌥"), (m.shift, "⇧"), (m.cmd, "⌘")] { + if on { + out.push_str(glyph); + } + } + out.push_str(key); + } else { + for (on, name) in [(m.ctrl || m.cmd, "Ctrl"), (m.alt, "Alt"), (m.shift, "Shift"), (!key.is_empty(), key)] { + if on { + if !out.is_empty() { + out.push('+'); + } + out.push_str(name); + } + } + } + out +} diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs new file mode 100644 index 00000000..7ff351bb --- /dev/null +++ b/crates/engine/src/keymap_tests.rs @@ -0,0 +1,271 @@ +//! The keymap: every default shortcut parses, is unique where it's live, and names a command; +//! platform differences, key-name normalisation, display, and the commands that only keys reach +//! (opacity digits, the zoom tool, panel toggles). + +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::edit::opacity_from_digits; +use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup, matches, normalize, parse, primary}; +use crate::params::id_of; +use crate::tools::ToolId; +use figcraft_doc::NodeId; + +const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other]; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn rect(s: &mut Session, x: f64, y: f64) -> NodeId { + id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 10, "height": 10}))["id"]).unwrap() +} + +fn mods(shift: bool, alt: bool, cmd: bool, ctrl: bool) -> Mods { + Mods { shift, alt, cmd, ctrl } +} + +fn id_for(p: Platform, c: Context, key: &str, m: Mods) -> Option<&'static str> { + lookup(p, c, key, m).map(|b| b.id) +} + +#[test] +fn every_binding_parses_and_names_a_command() { + for b in KEYS { + assert!(!b.mac.is_empty(), "{} has no keys", b.id); + for p in PLATFORMS { + for k in b.keys(p) { + assert!(parse(k).is_some(), "{}: `{k}` doesn't parse", b.id); + assert!(!display(k, p).is_empty(), "{}: `{k}`", b.id); + } + } + // An engine command, or a menu item the UI handles (file dialogs, clipboard formats). + let in_menu = crate::catalog::items().any(|m| m.id == b.id); + assert!(crate::find_command(b.id).is_some() || in_menu, "{} is neither a command nor a menu item", b.id); + assert!(!b.title().is_empty()); + } + assert!(Group::ALL.iter().all(|g| KEYS.iter().any(|b| b.group == *g)), "every panel tab has shortcuts"); + assert!(GESTURES.iter().all(|(_, l, m, o)| !l.is_empty() && !m.is_empty() && !o.is_empty())); +} + +/// The chord a binding fires on, as the platform sees it (Ctrl and Cmd are one key off macOS). +fn chord(p: Platform, spec: &str) -> (bool, bool, bool, bool, String) { + let (m, k) = parse(spec).unwrap(); + let (cmd, ctrl) = match p { + Platform::Mac => (m.cmd, m.ctrl), + Platform::Other => (m.cmd || m.ctrl, false), + }; + (m.shift, m.alt, cmd, ctrl, k.to_ascii_uppercase()) +} + +fn overlap(a: Context, b: Context) -> bool { + a == b || a == Context::Both || b == Context::Both +} + +#[test] +fn no_two_commands_share_a_chord_where_both_are_live() { + for p in PLATFORMS { + for (i, a) in KEYS.iter().enumerate() { + for b in KEYS.iter().skip(i + 1).filter(|b| b.id != a.id && overlap(a.context, b.context)) { + for ka in a.keys(p) { + for kb in b.keys(p) { + assert_ne!(chord(p, ka), chord(p, kb), "{p:?}: `{ka}` ({}) and `{kb}` ({}) collide", a.id, b.id); + } + } + } + } + } +} + +#[test] +fn key_names_normalise() { + assert_eq!(normalize("Plus"), Some(("=", true))); + assert_eq!(normalize("Questionmark"), Some(("/", true))); + assert_eq!(normalize("Equals"), Some(("=", false))); + assert_eq!(normalize("Backspace"), Some(("Delete", false))); + assert_eq!(normalize("Left"), Some(("ArrowLeft", false))); + assert_eq!(normalize("Exclamationmark"), Some(("1", true))); + assert_eq!(normalize("PageDown"), Some(("PageDown", false))); + assert_eq!(normalize("Bogus"), None); + assert_eq!(normalize(""), None); + assert_eq!(parse("Ctrl+Shift+?").map(|(m, k)| (m.shift, m.ctrl, k)), Some((true, true, "/"))); + assert!(parse("Cmd+").is_none()); + assert!(parse("A+B").is_none()); +} + +#[test] +fn lookups_follow_the_platform_and_context() { + let none = Mods::default(); + let shift = mods(true, false, false, false); + for p in PLATFORMS { + assert_eq!(id_for(p, Context::Canvas, "0", shift), Some("view.zoom100")); + assert_eq!(id_for(p, Context::Canvas, "Exclamationmark", shift), Some("view.zoomFit")); + assert_eq!(id_for(p, Context::Canvas, "2", shift), Some("view.zoomSelection")); + // `+` comes as its own key, with or without Shift held. + assert_eq!(id_for(p, Context::Canvas, "Plus", none), Some("view.zoomIn")); + assert_eq!(id_for(p, Context::Canvas, "Plus", shift), Some("view.zoomIn")); + assert_eq!(id_for(p, Context::Canvas, "Minus", none), Some("view.zoomOut")); + assert_eq!(id_for(p, Context::Canvas, "A", mods(false, true, false, false)), Some("align.left")); + assert_eq!(id_for(p, Context::Canvas, "Slash", mods(false, true, false, false)), Some("object.removeFill")); + assert_eq!(id_for(p, Context::Canvas, "Questionmark", shift), Some("object.removeStroke")); + assert_eq!(id_for(p, Context::Canvas, "U", mods(true, true, false, false)), Some("bool.union")); + assert_eq!(id_for(p, Context::Canvas, "V", none), Some("tool.move")); + // Cmd+K opens Actions on the canvas and makes a link while editing text. + assert_eq!(id_for(p, Context::Canvas, "K", mods(false, false, true, false)), Some("app.actions")); + assert_eq!(id_for(p, Context::Text, "K", mods(false, false, true, false)), Some("text.link")); + // Formatting works in both; tools and canvas commands don't fire while typing. + assert_eq!(id_for(p, Context::Text, "B", mods(false, false, true, false)), Some("text.bold")); + assert_eq!(id_for(p, Context::Canvas, "B", mods(false, false, true, false)), Some("text.bold")); + assert_eq!(id_for(p, Context::Text, "V", none), None); + assert_eq!(id_for(p, Context::Text, "A", mods(false, true, false, false)), None); + } + let mac = Platform::Mac; + let other = Platform::Other; + // Use as mask: ⌃⌘M on macOS, Ctrl+Alt+M elsewhere. + assert_eq!(id_for(mac, Context::Canvas, "M", mods(false, false, true, true)), Some("object.mask")); + assert_eq!(id_for(other, Context::Canvas, "M", mods(false, true, true, true)), Some("object.mask")); + // Bring to front: ⌥⌘] on macOS, Ctrl+Shift+] elsewhere (`}` with Shift). + assert_eq!(id_for(mac, Context::Canvas, "CloseBracket", mods(false, true, true, false)), Some("arrange.front")); + assert_eq!(id_for(other, Context::Canvas, "CloseCurlyBracket", mods(true, false, true, true)), Some("arrange.front")); + // Distribute and tidy up differ too. + assert_eq!(id_for(mac, Context::Canvas, "H", mods(false, true, false, true)), Some("distribute.horizontal")); + assert_eq!(id_for(other, Context::Canvas, "H", mods(true, true, false, false)), Some("distribute.horizontal")); + assert_eq!(id_for(mac, Context::Canvas, "T", mods(false, true, false, true)), Some("arrange.tidy")); + assert_eq!(id_for(other, Context::Canvas, "T", mods(true, true, true, true)), Some("arrange.tidy")); + assert_eq!(id_for(other, Context::Canvas, "T", mods(false, true, true, true)), Some("text.alignCenter")); + // macOS tells ⌃ from ⌘: ⌃G is layout grids, ⌘G groups; ⌃C picks a colour, ⌘C copies. + assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, false, true)), Some("view.layoutGrids")); + assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, true, false)), Some("object.group")); + assert_eq!(id_for(mac, Context::Canvas, "C", mods(false, false, false, true)), Some("edit.pickColor")); + assert_eq!(id_for(other, Context::Canvas, "C", mods(false, false, true, true)), Some("edit.copy")); + assert_eq!(id_for(other, Context::Canvas, "4", mods(true, false, true, true)), Some("view.layoutGrids")); + // Off macOS Ctrl may come as cmd, ctrl or both. + for (cmd, ctrl) in [(true, false), (false, true), (true, true)] { + assert_eq!(id_for(other, Context::Canvas, "Z", mods(false, false, cmd, ctrl)), Some("edit.undo")); + } + assert!(matches("Shift+Cmd+O", mac, "O", mods(true, false, true, false))); + assert!(!matches("Shift+Cmd+O", mac, "O", mods(true, false, true, true)), "an extra ⌃ is another chord on macOS"); +} + +#[test] +fn display_per_platform() { + let (mac, other) = (Platform::Mac, Platform::Other); + assert_eq!(display("Shift+Cmd+K", mac), "⇧⌘K"); + assert_eq!(display("Shift+Cmd+K", other), "Ctrl+Shift+K"); + assert_eq!(display("Ctrl+Cmd+M", mac), "⌃⌘M"); + assert_eq!(display("Ctrl+Alt+M", other), "Ctrl+Alt+M"); + assert_eq!(display("Alt+Cmd+Enter", mac), "⌥⌘↵"); + assert_eq!(display("Alt+Cmd+Enter", other), "Ctrl+Alt+Enter"); + assert_eq!(display("Cmd+=", mac), "⌘+"); + assert_eq!(display("Shift+=", other), "+"); + assert_eq!(display("Delete", mac), "⌫"); + assert_eq!(display("Alt", other), "Alt"); + assert_eq!(display("Cmd", mac), "⌘"); + assert_eq!(display("", mac), ""); + assert_eq!(primary("object.mask", other), Some("Ctrl+Alt+M")); + assert_eq!(primary("object.mask", mac), Some("Ctrl+Cmd+M")); + assert_eq!(primary("view.outlines", mac), Some("Shift+Cmd+O")); + assert_eq!(primary("polygon.none", mac), None); +} + +#[test] +fn tool_keys_come_from_the_keymap() { + assert_eq!(ToolId::Move.shortcut(), "V"); + assert_eq!(ToolId::Section.shortcut(), "Shift+S"); + assert_eq!(ToolId::Eyedropper.shortcut(), "I"); + assert_eq!(ToolId::Polygon.shortcut(), ""); + assert_eq!(crate::catalog::shortcut("tool.text"), Some("T")); +} + +#[test] +fn opacity_digits() { + assert_eq!(opacity_from_digits("5"), Some(0.5)); + assert_eq!(opacity_from_digits("0"), Some(1.0)); + assert_eq!(opacity_from_digits("45"), Some(0.45)); + assert_eq!(opacity_from_digits("05"), Some(0.05)); + assert_eq!(opacity_from_digits("00"), Some(0.0)); + assert_eq!(opacity_from_digits(""), None); + assert_eq!(opacity_from_digits("123"), None); + assert_eq!(opacity_from_digits("4x"), None); + assert_eq!(opacity_from_digits("٣"), None, "ASCII digits only"); + + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + let b = rect(&mut s, 20.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a, b]})); + ex(&mut s, "object.opacityKeys", json!({"digits": "3"})); + let op = |s: &Session, id| s.doc().unwrap().node(id).unwrap().opacity; + assert_eq!((op(&s, a), op(&s, b)), (0.3, 0.3)); + ex(&mut s, "object.opacityKeys", json!({"digits": "45", "ids": [a]})); + assert_eq!((op(&s, a), op(&s, b)), (0.45, 0.3)); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(op(&s, a), 0.3, "one undo step"); + assert!(s.execute("object.opacityKeys", &json!({"digits": "123"})).is_err()); + assert!(s.execute("object.opacityKeys", &json!({})).is_err()); + ex(&mut s, "select.none", json!({})); + assert!(s.execute("object.opacityKeys", &json!({"digits": "1"})).is_err(), "nothing selected"); +} + +#[test] +fn zoom_tool_steps_about_a_point_and_fits_an_area() { + let mut s = Session::new(); + ex(&mut s, "view.set", json!({"zoom": 1.0, "cx": 0, "cy": 0, "vw": 1000, "vh": 800})); + // A click at (100, 50) zooms in a step and keeps that point where it was on screen. + let before = s.ds().unwrap().view.page_to_screen(100.0, 50.0); + let v = ex(&mut s, "view.zoomAt", json!({"x": 100, "y": 50})); + assert_eq!(v["zoom"], 2.0); + let after = s.ds().unwrap().view.page_to_screen(100.0, 50.0); + assert!((before.0 - after.0).abs() < 1e-6 && (before.1 - after.1).abs() < 1e-6); + let v = ex(&mut s, "view.zoomAt", json!({"x": 100, "y": 50, "out": true})); + assert_eq!(v["zoom"], 1.0); + // A dragged area fills the view. + let v = ex(&mut s, "view.zoomAt", json!({"x": 0, "y": 0, "width": 100, "height": 50})); + assert!(v["zoom"].as_f64().unwrap() > 5.0); + assert_eq!((v["cx"].as_f64(), v["cy"].as_f64()), (Some(50.0), Some(25.0))); + // Degenerate areas zoom a step instead; junk is refused. + let z = s.ds().unwrap().view.zoom; + ex(&mut s, "view.zoomAt", json!({"x": 0, "y": 0, "width": 0, "height": 50})); + assert!(s.ds().unwrap().view.zoom > z); + assert!(s.execute("view.zoomAt", &json!({"x": "a", "y": 0})).is_err()); + assert!(s.execute("view.zoomAt", &json!({"y": 0})).is_err()); +} + +#[test] +fn panel_toggles_request_the_ui() { + let mut s = Session::new(); + for (cmd, kind) in + [("view.designPrototype", "panel.designPrototype"), ("view.devMode", "panel.devMode"), ("view.leftSidebar", "panel.leftSidebar")] + { + s.ui_requests.clear(); + ex(&mut s, cmd, json!({})); + assert!(s.ui_requests.iter().any(|r| r.kind == kind), "{cmd}"); + } +} + +#[test] +fn vector_edit_paint_bucket_is_b() { + use crate::vectoredit::VTool; + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0); + ex(&mut s, "vector.edit.begin", json!({"id": r})); + assert!(crate::vectoredit::key(&mut s, "B", false, false, false).unwrap()); + assert_eq!(s.ds().unwrap().vector_edit.as_ref().unwrap().tool, VTool::Paint); + assert_eq!(VTool::Paint.shortcut(), "B"); +} + +#[test] +fn ctrl_tab_cycles_documents() { + let mut s = Session::new(); + ex(&mut s, "file.new", json!({})); + ex(&mut s, "file.new", json!({})); + let n = s.docs.len(); + assert!(n >= 3); + let start = s.active; + ex(&mut s, "doc.nextTab", json!({})); + assert_eq!(s.active, (start + 1) % n); + ex(&mut s, "doc.previousTab", json!({})); + ex(&mut s, "doc.previousTab", json!({})); + assert_eq!(s.active, (start + n - 1) % n, "wraps around"); + assert_eq!(lookup(Platform::Other, Context::Canvas, "Tab", mods(false, false, true, true)).map(|b| b.id), Some("doc.nextTab")); + assert_eq!(lookup(Platform::Mac, Context::Canvas, "Tab", mods(true, false, false, true)).map(|b| b.id), Some("doc.previousTab")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 92474109..1c6c2e6a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -21,6 +21,7 @@ pub mod drop; pub mod guard; pub mod guides; pub mod inspect; +pub mod keymap; pub mod library; pub mod measure; pub mod params; @@ -970,6 +971,8 @@ mod html_codegen_tests; #[cfg(test)] mod import_export_tests; #[cfg(test)] +mod keymap_tests; +#[cfg(test)] mod library_variables_tests; #[cfg(test)] mod mask_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0faf7e8..3abad4e6 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -108,27 +108,16 @@ impl ToolId { ToolId::ShapeBuilder => "Shape builder", } } + /// The tool's key (from [`crate::keymap`]; empty when it has none). pub fn shortcut(self) -> &'static str { - match self { - ToolId::Move => "V", - ToolId::Scale => "K", - ToolId::Hand => "H", - ToolId::Frame => "F", - ToolId::Section => "Shift+S", - ToolId::Slice => "S", - ToolId::Rectangle => "R", - ToolId::Line => "L", - ToolId::Arrow => "Shift+L", - ToolId::Ellipse => "O", - ToolId::Pen => "P", - ToolId::Pencil => "Shift+P", - ToolId::Text => "T", - ToolId::Comment => "C", - ToolId::Eyedropper => "I", - ToolId::ShapeBuilder => "Shift+M", - _ => "", - } + let id = self.id(); + crate::keymap::KEYS + .iter() + .find(|b| if self == ToolId::Eyedropper { b.id == "edit.pickColor" } else { b.id.strip_prefix("tool.") == Some(id) }) + .and_then(|b| b.keys(crate::keymap::Platform::current()).first().copied()) + .unwrap_or("") } + fn draws_shape(self) -> bool { matches!( self, diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index 0459c94f..072b7c6f 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -74,13 +74,13 @@ impl VTool { VTool::Width => "Variable width", } } - /// Catalog-style shortcut ("Shift+B"); the Bend tool is held with Cmd (Ctrl elsewhere). + /// Keymap-style shortcut ("Shift+E"); the Bend tool is held with Cmd (Ctrl elsewhere). pub fn shortcut(self) -> &'static str { match self { VTool::Move => "V", VTool::Pen => "P", VTool::Bend => "Cmd", - VTool::Paint => "Shift+B", + VTool::Paint => "B", VTool::Lasso => "Q", VTool::Cut => "X", VTool::Eraser => "Shift+E", @@ -496,7 +496,8 @@ pub fn key(s: &mut Session, name: &str, shift: bool, alt: bool, cmd: bool) -> Re } ("V", false, false, false) => set_tool(s, VTool::Move)?, ("P", false, false, false) => set_tool(s, VTool::Pen)?, - ("B", true, false, false) => set_tool(s, VTool::Paint)?, + // B (Shift+B still works, as it did before). + ("B", _, false, false) => set_tool(s, VTool::Paint)?, ("Q", false, false, false) => set_tool(s, VTool::Lasso)?, ("X", false, false, false) => set_tool(s, VTool::Cut)?, ("E", true, false, false) => set_tool(s, VTool::Eraser)?, diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index e78dc07a..5ab047c8 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -3,7 +3,7 @@ use base64::Engine as _; use figcraft_doc::{ExportFormat, NodeId}; -use figcraft_engine::{PointerEvent, PointerKind, Session, ToolId, textedit}; +use figcraft_engine::{PointerEvent, PointerKind, Session, textedit}; use serde_json::{Value, json}; use crate::backend::Backend; @@ -34,14 +34,6 @@ fn ids_of(p: &Value) -> Vec { p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(figcraft_engine::params::id_of).collect()).unwrap_or_default() } -/// Does a catalog shortcut like `Shift+Cmd+I` match `key` with these modifiers (any order)? -fn shortcut_matches(shortcut: &str, key: &str, shift: bool, alt: bool, cmd: bool, ctrl: bool) -> bool { - let parts: Vec<&str> = shortcut.split('+').collect(); - let Some((k, mods)) = parts.split_last() else { return false }; - let has = |m: &str| mods.iter().any(|x| x.eq_ignore_ascii_case(m)); - k.eq_ignore_ascii_case(key) && has("shift") == shift && (has("alt") || has("option")) == alt && has("cmd") == cmd && has("ctrl") == ctrl -} - impl Headless { /// A session with one new, empty document. pub fn new() -> Self { @@ -118,63 +110,67 @@ impl Headless { } fn key(&mut self, p: &Value) -> Result { + use figcraft_engine::keymap::{Context, Mods, Platform, lookup, normalize}; let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?; - let (shift, alt, cmd, ctrl) = (b(p, "shift"), b(p, "alt"), b(p, "cmd"), b(p, "ctrl")); - // Text editing keys go to the editor. - if self.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) && !(cmd && key.len() == 1) { - textedit::key(&mut self.session, key, shift, alt, cmd).map_err(|e| e.to_string())?; - return Ok(json!({"ok": true, "textEdit": true})); - } - if let Some(item) = figcraft_engine::catalog::MENU - .iter() - .find(|m| !m.shortcut.is_empty() && !m.id.is_empty() && shortcut_matches(m.shortcut, key, shift, alt, cmd, ctrl)) - { - if item.id == "select.none" { - self.session.escape().map_err(|e| e.to_string())?; - } else { - self.execute(item.id, &json!({}))?; + let m = Mods { shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl") }; + let platform = Platform::current(); + // Text editing keys go to the editor, after the shortcuts live while editing; other + // Cmd+letter shortcuts (undo…) still reach the canvas. + if self.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) { + if let Some(bd) = lookup(platform, Context::Text, key, m).filter(|_| m.cmd || m.alt || m.ctrl) { + self.execute(bd.id, &json!({}))?; + return Ok(json!({"command": bd.id})); + } + if !(m.cmd && key.len() == 1) { + textedit::key(&mut self.session, key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())?; + return Ok(json!({"ok": true, "textEdit": true})); } - return Ok(json!({"command": item.id})); } - if !cmd - && !alt - && !ctrl - && let Some(t) = - ToolId::ALL.into_iter().find(|t| !t.shortcut().is_empty() && shortcut_matches(t.shortcut(), key, shift, false, false, false)) - { - self.session.set_tool(t); - return Ok(json!({"tool": t.id()})); - } - match key { - "Escape" | "Esc" => self.session.escape().map(|_| json!({"ok": true})).map_err(|e| e.to_string()), - "Delete" | "Backspace" => self.execute("edit.delete", &json!({})), - "Enter" | "Return" => { - // Enter on a text layer starts editing it; otherwise finish the pen path. - let sel = self.session.selection(); - let is_text = sel - .first() - .and_then(|i| self.session.doc().ok().and_then(|d| d.node(*i).map(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))))) - .unwrap_or(false); - if sel.len() == 1 && is_text { - self.execute("text.edit.begin", &json!({"id": sel.first()})) - } else { + if let Some(bd) = lookup(platform, Context::Canvas, key, m) { + match bd.id { + "select.none" => self.session.escape().map_err(|e| e.to_string())?, + // Enter on a text layer starts editing it. + "select.children" if self.single_text().is_some() => { + self.execute("text.edit.begin", &json!({"id": self.single_text()}))?; + } + "select.children" if self.session.tool.pen.is_some() => { let _ = self.session.pen_finish(); - Ok(json!({"ok": true})) + } + id => { + self.execute(id, &json!({}))?; } } - "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" | "Left" | "Right" | "Up" | "Down" => { - let (dx, dy) = match key { - "ArrowLeft" | "Left" => (-1, 0), - "ArrowRight" | "Right" => (1, 0), - "ArrowUp" | "Up" => (0, -1), + return Ok(json!({"command": bd.id, "tool": self.session.tool.tool.id()})); + } + let (name, _) = normalize(key).ok_or_else(|| format!("unknown key `{key}`"))?; + match name { + "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => { + let (dx, dy) = match name { + "ArrowLeft" => (-1, 0), + "ArrowRight" => (1, 0), + "ArrowUp" => (0, -1), _ => (0, 1), }; - self.execute("transform.nudge", &json!({"dx": dx, "dy": dy, "big": shift})) + self.execute("transform.nudge", &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 => { + self.execute("object.opacityKeys", &json!({"digits": d})) } _ => Err(format!("no shortcut for `{key}` with these modifiers")), } } + /// The selected layer when exactly one text layer is selected. + fn single_text(&self) -> Option { + let sel = self.session.selection(); + let doc = self.session.doc().ok()?; + match sel.as_slice() { + [id] if doc.node(*id).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))) => Some(*id), + _ => None, + } + } + fn render_to(&mut self, p: &Value) -> Result { let scale = f(p, "scale").unwrap_or(1.0); let ids: Vec = ids_of(p).into_iter().map(|i| i.0).collect(); diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 93e03c1a..3b158e52 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -460,7 +460,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { title(ui, w, "Local components"); if listing.local.is_empty() { - hint(ui, if q.is_empty() { "No components yet. Select layers and press ⌥⌘K." } else { "No matching components" }); + if q.is_empty() { + let keys = crate::toolbar::pretty_shortcut(crate::shortcuts::shortcut("component.create").unwrap_or("")); + hint(ui, &format!("No components yet. Select layers and press {keys}.")); + } else { + hint(ui, "No matching components"); + } } tiles(ui, w, &Source::Local, &listing.local, &thumbs, &mut act); crate::widgets::separator(ui); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index aeea7ce0..a9df628e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -24,6 +24,9 @@ pub struct CanvasState { dragging: bool, space_pan: bool, middle_pan: Option<(Pos2, f64, f64)>, + /// The zoom tool (Z held): zooming out (Alt) or in, and where its drag started. + zoom_tool: Option, + zoom_drag: Option, pub last_pointer: Option, preedit: Option, } @@ -40,6 +43,8 @@ impl Default for CanvasState { dragging: false, space_pan: false, middle_pan: None, + zoom_tool: None, + zoom_drag: None, last_pointer: None, preedit: None, } @@ -175,6 +180,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { painter.image(tex.id(), Rect::from_min_size(tl, vec2(w, h)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } overlays(app, &painter, &t); + // The zoom tool's area. + if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) { + let area = Rect::from_two_pos(start, p); + if area.width() > 4.0 || area.height() > 4.0 { + painter.rect_filled(area, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); + painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + } + } crate::dev_mode::draw(app, &painter); crate::handles::draw(app, &painter); crate::comments::draw(app, &painter); @@ -184,6 +197,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { if resp.hovered() { let c = if app.canvas.space_pan || app.canvas.middle_pan.is_some() { if ctx.input(|i| i.pointer.any_down()) { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } + } else if let Some(out) = app.canvas.zoom_tool { + if out { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn } } else { match app.session.tool.overlay.cursor { Cursor::Default => egui::CursorIcon::Default, @@ -259,6 +274,31 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { app.canvas.submitted = Some(key); } +/// The zoom tool while Z is held (Alt: zoom out): a click zooms a step about the pointer, a drag +/// zooms to the dragged area. Takes the pointer from the other tools while it's active. +fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: egui::Modifiers, typing: bool) -> bool { + let held = !typing && !mods.command && ui.input(|i| i.key_down(egui::Key::Z)); + app.canvas.zoom_tool = held.then_some(mods.alt); + let Some(start) = app.canvas.zoom_drag.or_else(|| (held && resp.hovered() && ui.input(|i| i.pointer.primary_pressed())).then_some(pos)) else { + return held; + }; + app.canvas.zoom_drag = Some(start); + let area = Rect::from_two_pos(start, pos); + if ui.input(|i| i.pointer.primary_down()) { + return true; + } + app.canvas.zoom_drag = None; + let (a, b) = (app.to_page(area.min), app.to_page(area.max)); + let params = if area.width() > 4.0 && area.height() > 4.0 { + json!({"x": a.x, "y": a.y, "width": b.x - a.x, "height": b.y - a.y}) + } else { + let p = app.to_page(pos); + json!({"x": p.x, "y": p.y, "out": mods.alt}) + }; + app.run("view.zoomAt", params); + true +} + fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { if crate::proto_canvas::input(app, ui, resp) { return; @@ -309,6 +349,9 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { ds.revision += 1; } } + if zoom_tool(app, ui, resp, pos, mods, editing_text || typing_elsewhere) { + return; + } // Middle-button / space panning. let middle_down = ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle)); if (middle_down || (app.canvas.space_pan && ctx.input(|i| i.pointer.primary_down()))) && resp.hovered() || app.canvas.middle_pan.is_some() { diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 7b8ce8a5..84a51953 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -504,7 +504,7 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { ui.close(); } }); - if !nested && icons::button(ui, Icon::Detach, 24.0, false, "Detach instance ⌥⌘B").clicked() { + if !nested && icons::button(ui, Icon::Detach, 24.0, false, crate::shortcuts::tip("Detach instance", "component.detach")).clicked() { app.run("component.detach", json!({"ids": [id]})); } if icons::button(ui, Icon::Reset, 24.0, false, "Reset all changes").clicked() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c8f60b0a..837cde89 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -126,69 +126,55 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul "ui.pointer" => pointer(app, p), "ui.key" => { let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?; - let mut spec = String::new(); - for (k, m) in [("shift", "Shift"), ("alt", "Alt"), ("cmd", "Cmd"), ("ctrl", "Ctrl")] { - if b(p, k) { - spec.push_str(m); - spec.push('+'); - } - } - spec.push_str(key); - // Text editing keys go to the editor. + let m = figcraft_engine::keymap::Mods { shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl") }; + let platform = crate::shortcuts::platform(); + use figcraft_engine::keymap::{Context, lookup, normalize}; + // Text editing keys go to the editor (after the shortcuts live while editing). if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) { - figcraft_engine::textedit::key(&mut app.session, key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())?; + if let Some(bd) = lookup(platform, Context::Text, key, m).filter(|_| m.cmd || m.alt || m.ctrl) { + crate::menus::invoke(app, bd.id); + return Ok(json!({"command": bd.id})); + } + figcraft_engine::textedit::key(&mut app.session, key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())?; return Ok(json!({"ok": true})); } // Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter). - if app.session.tool_key(key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())? { + if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? { return Ok(json!({"ok": true})); } // The Comment tool's arrows step through the comment threads. - if app.session.tool.tool == figcraft_engine::ToolId::Comment && matches!(spec.as_str(), "ArrowUp" | "ArrowDown") { - let r = crate::comments::step(app, ctx, if spec == "ArrowUp" { -1 } else { 1 }); + if app.session.tool.tool == figcraft_engine::ToolId::Comment && m.is_none() && matches!(key, "ArrowUp" | "ArrowDown" | "Up" | "Down") { + let r = crate::comments::step(app, ctx, if matches!(key, "ArrowUp" | "Up") { -1 } else { 1 }); return Ok(json!({"comment": r.and_then(|v| v.get("id").cloned())})); } - match crate::shortcuts::parse(&spec) { - Some(want) => { - let id = figcraft_engine::catalog::MENU - .iter() - .find(|m| !m.shortcut.is_empty() && crate::shortcuts::parse(m.shortcut) == Some(want)) - .map(|m| m.id); - match id { - Some(id) => { - if id == "select.none" { - app.session.escape().map_err(|e| e.to_string())?; - } else { - crate::menus::invoke(app, id); - } - Ok(json!({"command": id})) - } - None => match key { - "Escape" => app.session.escape().map(|_| json!({"ok": true})).map_err(|e| e.to_string()), - "Delete" | "Backspace" => app.session.execute("edit.delete", &json!({})).map_err(|e| e.to_string()), - "Enter" => { - let _ = app.session.pen_finish(); - Ok(json!({"ok": true})) - } - "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => { - let (dx, dy) = match key { - "ArrowLeft" => (-1, 0), - "ArrowRight" => (1, 0), - "ArrowUp" => (0, -1), - _ => (0, 1), - }; - let (cmd, params) = if b(p, "cmd") { - ("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": b(p, "shift")})) - } else { - ("transform.nudge", json!({"dx": dx, "dy": dy, "big": b(p, "shift")})) - }; - app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()) - } - _ => Err(format!("no shortcut `{spec}`")), - }, + if let Some(bd) = lookup(platform, Context::Canvas, key, m) { + crate::shortcuts::run_binding(app, bd.id); + return Ok(json!({"command": bd.id})); + } + let (name, _) = normalize(key).ok_or_else(|| format!("unknown key `{key}`"))?; + match name { + "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => { + let (dx, dy) = match name { + "ArrowLeft" => (-1, 0), + "ArrowRight" => (1, 0), + "ArrowUp" => (0, -1), + _ => (0, 1), + }; + let (cmd, params) = if m.cmd || m.ctrl { + ("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift})) + } else { + ("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift})) + }; + app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()) + } + _ => { + // Number keys: opacity (two in quick succession make an exact percentage). + let now = ctx.input(|i| i.time); + match egui::Key::from_name(name) { + Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})), + _ => Err(format!("no shortcut `{key}`")), } } - None => Err(format!("unknown key `{key}`")), } } "ui.input" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 95fce35c..ba7c0830 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3,7 +3,6 @@ use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2}; use figcraft_color::ColorProfile; use figcraft_doc::{ExportFormat, ExportSetting, ExportSize}; -use figcraft_engine::catalog::MENU; use figcraft_engine::cmd::file::{export_jobs, exportable_layers}; use serde_json::json; @@ -11,11 +10,10 @@ use crate::FigApp; use crate::export_panel::{export_layers, save_one, suggested_name}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; -use crate::toolbar::pretty_shortcut; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); -fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> { +pub(crate) fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> { let t = Tokens::get(ctx); egui::Window::new(title) .collapsible(false) @@ -49,11 +47,12 @@ impl AboutTab { pub const ABOUT_WIDTH: f32 = 640.0; /// The About window's tab row, drawn like the Design / Prototype tabs of the right panel. -fn about_tabs(ui: &mut egui::Ui, t: &Tokens, current: &mut AboutTab) { +/// A row of tabs (the About window's, the shortcuts panel's): the current one highlighted. +pub(crate) fn tab_strip(ui: &mut egui::Ui, t: &Tokens, tabs: &[T], label: fn(T) -> &'static str, current: &mut T) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - for tab in AboutTab::ALL { - let label = tab.label(); + for &tab in tabs { + let label = label(tab); let active = *current == tab; let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), if active { t.text } else { t.text_secondary }); let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 16.0, 24.0), Sense::click()); @@ -95,7 +94,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { let mut open = true; window("About FigCraft", ctx).open(&mut open).show(ctx, |ui| { ui.set_width(ABOUT_WIDTH); - about_tabs(ui, &t, &mut app.ui.about_tab); + tab_strip(ui, &t, &AboutTab::ALL, AboutTab::label, &mut app.ui.about_tab); ui.add_space(12.0); match app.ui.about_tab { AboutTab::About => about_body(ui, &t), @@ -159,30 +158,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.ui.preferences = false; } } - if app.ui.shortcuts_panel { - let mut open = true; - egui::Window::new("Keyboard shortcuts").open(&mut open).default_size(vec2(720.0, 420.0)).show(ctx, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - ui.columns(3, |cols| { - let items: Vec<_> = MENU.iter().filter(|m| !m.shortcut.is_empty()).collect(); - let per = items.len().div_ceil(3); - for (ci, col) in cols.iter_mut().enumerate() { - for m in items.iter().skip(ci * per).take(per) { - col.horizontal(|ui| { - ui.label(RichText::new(m.label).font(regular(11.0))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(RichText::new(pretty_shortcut(m.shortcut)).color(t.text_secondary).font(regular(11.0))); - }); - }); - } - } - }); - }); - }); - if !open { - app.ui.shortcuts_panel = false; - } - } + crate::shortcuts_panel::show(app, ctx); if let Some((mut find, mut replace)) = app.ui.find.clone() { let mut open = true; let mut action = None; diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 73611227..4a99ae36 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -163,7 +163,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.pages_open = !app.ui.pages_open; } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).on_hover_text("Find ⌘F"); + let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).on_hover_text(crate::shortcuts::tip("Find", "edit.findReplace")); if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index d5c69bd0..a54c040d 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -27,9 +27,9 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let has_al = matches!(al, Some(Some(_))); let title = if has_al { "Auto layout" } else { "Layout" }; let btns: Vec<(Icon, &str)> = if frames && !has_al { - vec![(Icon::Plus, "Add auto layout ⇧A")] + vec![(Icon::Plus, crate::shortcuts::tip("Add auto layout", "layout.add"))] } else if has_al { - vec![(Icon::Minus, "Remove auto layout ⌥⇧A")] + vec![(Icon::Minus, crate::shortcuts::tip("Remove auto layout", "layout.remove"))] } else { vec![] }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7849b648..3e2b9403 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,7 @@ pub mod right; pub mod scale_panel; pub mod section_panel; pub mod shortcuts; +pub mod shortcuts_panel; pub mod style_picker; pub mod tabs; pub mod theme; @@ -70,6 +71,8 @@ mod proto_editor_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod shortcuts_tests; +#[cfg(test)] mod titlebar_tests; use std::collections::BTreeSet; @@ -162,6 +165,11 @@ pub struct UiState { pub about_tab: dialogs::AboutTab, pub preferences: bool, pub shortcuts_panel: bool, + /// The Keyboard shortcuts panel's tab and search text. + pub shortcuts_tab: figcraft_engine::keymap::Group, + pub shortcuts_query: String, + /// An opacity digit just typed and when (a second one soon after makes an exact percentage). + pub opacity_digit: Option<(u32, f64)>, pub home: bool, /// How the window's title bar is drawn (the desktop app sets it when it opens the window). pub title_bar: TitleBar, @@ -245,6 +253,9 @@ impl Default for UiState { about_tab: dialogs::AboutTab::About, preferences: false, shortcuts_panel: false, + shortcuts_tab: figcraft_engine::keymap::Group::Essential, + shortcuts_query: String::new(), + opacity_digit: None, home: false, title_bar: TitleBar::Native, icon_gallery: false, @@ -394,6 +405,13 @@ impl FigApp { "palette.actions" => self.ui.palette = Some(String::new()), "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, + "panel.designPrototype" => { + self.ui.right_tab = if self.ui.right_tab == RightTab::Prototype { RightTab::Design } else { RightTab::Prototype }; + } + "panel.devMode" => { + self.ui.right_tab = if self.ui.right_tab == RightTab::Inspect { RightTab::Design } else { RightTab::Inspect }; + } + "panel.leftSidebar" => self.ui.left_collapsed = !self.ui.left_collapsed, "panel.layers" => self.ui.left_tab = LeftTab::File, "panel.assets" => self.ui.left_tab = LeftTab::Assets, "panel.versionHistory" => versions::open(self), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7d19eeba..464e1302 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -136,7 +136,8 @@ pub fn invoke(app: &mut FigApp, id: &str) { } "edit.paste" => paste(app, None), "file.close" if app.session.ds().map(|d| d.is_dirty()).unwrap_or(false) => { - app.toast("Unsaved changes: save first (⌘S), or close the tab with ⌥-click to discard"); + let save = pretty_shortcut(crate::shortcuts::shortcut("file.save").unwrap_or("")); + app.toast(format!("Unsaved changes: save first ({save}), or close the tab with {}-click to discard", pretty_shortcut("Alt"))); } _ => { app.run(id, json!({})); @@ -171,7 +172,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { } last_sep = false; let enabled = available(m.id); - if item(ui, m.label, m.shortcut, enabled, false, &t) && enabled { + if item(ui, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled { clicked = Some(m.id); } } else if let Some(rest) = m.menu.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) { @@ -294,7 +295,13 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { t.menu_dim, ); ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, "Actions…", regular(12.0), t.menu_dim); - ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, pretty_shortcut("Cmd+K"), regular(12.0), t.menu_dim); + ui.painter().text( + pos2(r.max.x - 10.0, r.center().y), + Align2::RIGHT_CENTER, + pretty_shortcut(crate::shortcuts::shortcut("app.actions").unwrap_or("")), + regular(12.0), + t.menu_dim, + ); if resp.clicked() { app.ui.palette = Some(String::new()); close = true; @@ -400,7 +407,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { separator(ui); continue; } - let sc = figcraft_engine::catalog::shortcut(id).unwrap_or(""); + let sc = crate::shortcuts::shortcut(id).unwrap_or(""); if item(ui, label, sc, available(id), false, &t) { if *id == "edit.paste" { let p = app.to_page(pos); @@ -514,11 +521,11 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { let mut hits: Vec<(&str, &str, &str)> = figcraft_engine::all_commands() .iter() .filter(|c| ql.is_empty() || c.label.to_lowercase().contains(&ql) || c.id.to_lowercase().contains(&ql)) - .map(|c| (c.id, c.label, c.shortcut().unwrap_or(""))) + .map(|c| (c.id, c.label, crate::shortcuts::shortcut(c.id).unwrap_or(""))) .collect(); for m in MENU.iter().filter(|m| UI_COMMANDS.contains(&m.id)) { if ql.is_empty() || m.label.to_lowercase().contains(&ql) { - hits.push((m.id, m.label, m.shortcut)); + hits.push((m.id, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""))); } } hits.sort_by_key(|h| (!h.1.to_lowercase().starts_with(&ql), h.1.len())); diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index d9f27967..53b27d57 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -1427,9 +1427,6 @@ fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option { Some(Layout { disp, scale, place, screen, clip, device, landscape, color, overlays, closing }) } -/// The keys that present the current frame full-window. -pub const PRESENT_SHORTCUT: &str = "Alt+Cmd+Enter"; - /// Where the player sits: the whole window or the preview pane. #[derive(Clone, Copy, Debug, PartialEq)] struct Chrome { diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 953c5373..2fcae585 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -74,7 +74,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0)); let (tri_r, chev_r) = (Rect::from_min_max(play.min, pos2(play.max.x - 16.0, play.max.y)), Rect::from_min_max(pos2(play.max.x - 16.0, play.min.y), play.max)); - let present_keys = crate::toolbar::pretty_shortcut(crate::present::PRESENT_SHORTCUT); + let present_keys = crate::toolbar::pretty_shortcut(crate::shortcuts::shortcut("proto.present").unwrap_or("")); let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).on_hover_text(format!("Present {present_keys}")); let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).on_hover_text("Present or preview"); if presp.hovered() || mresp.hovered() { @@ -135,7 +135,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { ("Zoom to 200%", "view.zoom200"), ("Zoom to selection", "view.zoomSelection"), ] { - if menu_item(ui, label, figcraft_engine::catalog::shortcut(cmd).unwrap_or("")) { + if menu_item(ui, label, crate::shortcuts::shortcut(cmd).unwrap_or("")) { app.run(cmd, json!({})); ui.close(); } @@ -157,7 +157,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { ("Outlines", "view.outlines", v.outlines), ]; for (label, cmd, on) in toggles { - if menu_check(ui, on, label, figcraft_engine::catalog::shortcut(cmd).unwrap_or("")) { + if menu_check(ui, on, label, crate::shortcuts::shortcut(cmd).unwrap_or("")) { app.run(cmd, json!({})); ui.close(); } @@ -454,13 +454,14 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } // Quick actions on the right. let mut x = r.max.x - 8.0; - let actions: Vec<(Icon, &str, &str)> = vec![ - (Icon::Component, "Create component ⌥⌘K", "component.create"), + let actions = [ + (Icon::Component, "Create component", "component.create"), (Icon::Mask, "Use as mask", "object.mask"), (Icon::Union, "Boolean groups", "bool.union"), ]; - for (icon, tip, cmd) in actions.into_iter().rev() { + for (icon, label, cmd) in actions.into_iter().rev() { let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0)); + let tip = if cmd == "bool.union" { label } else { crate::shortcuts::tip(label, cmd) }; let bresp = ui.interact(br, ui.id().with(("qa", cmd)), Sense::click()).on_hover_text(tip); if bresp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); @@ -475,7 +476,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ("Exclude", "bool.exclude"), ("Flatten", "vector.flatten"), ] { - if menu_item(ui, l, figcraft_engine::catalog::shortcut(c).unwrap_or("")) { + if menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) { app.run(c, json!({})); ui.close(); } @@ -495,15 +496,16 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, "Alignment", None); row(ui, 32.0, |ui| { - let items = [ - (Icon::AlignLeft, "Align left ⌥A"), - (Icon::AlignHCenter, "Align horizontal centers ⌥H"), - (Icon::AlignRight, "Align right ⌥D"), - (Icon::AlignTop, "Align top ⌥W"), - (Icon::AlignVCenter, "Align vertical centers ⌥V"), - (Icon::AlignBottom, "Align bottom ⌥S"), - ]; let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"]; + let tip = crate::shortcuts::tip; + let items = [ + (Icon::AlignLeft, tip("Align left", cmds[0])), + (Icon::AlignHCenter, tip("Align horizontal centers", cmds[1])), + (Icon::AlignRight, tip("Align right", cmds[2])), + (Icon::AlignTop, tip("Align top", cmds[3])), + (Icon::AlignVCenter, tip("Align vertical centers", cmds[4])), + (Icon::AlignBottom, tip("Align bottom", cmds[5])), + ]; let w = col / 3.0; if let Some(i) = widgets::segmented(ui, &items[..3], None, w) { app.run(cmds[i], json!({"ids": ids})); @@ -519,7 +521,7 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ("Distribute vertical spacing", "distribute.vertical"), ("Tidy up", "arrange.tidy"), ] { - if menu_item(ui, l, figcraft_engine::catalog::shortcut(c).unwrap_or("")) { + if menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) { app.run(c, json!({"ids": ids})); ui.close(); } @@ -552,7 +554,11 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { row(ui, 32.0, |ui| { let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius), rot, "°", col, 1.0); app.field(&ids, "rotation", e, |v| json!(v)); - let items = [(Icon::Rotate, "Rotate 90° right"), (Icon::FlipH, "Flip horizontal ⇧H"), (Icon::FlipV, "Flip vertical ⇧V")]; + let items = [ + (Icon::Rotate, "Rotate 90° right"), + (Icon::FlipH, crate::shortcuts::tip("Flip horizontal", "object.flipH")), + (Icon::FlipV, crate::shortcuts::tip("Flip vertical", "object.flipV")), + ]; if let Some(i) = widgets::segmented(ui, &items, None, col / 3.0) { app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids})); } @@ -607,7 +613,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui, "Appearance", &[ - (if visible { Icon::Eye } else { Icon::EyeOff }, "Show/Hide ⇧⌘H"), + (if visible { Icon::Eye } else { Icon::EyeOff }, crate::shortcuts::tip("Show/Hide", "object.toggleVisible")), (Icon::Variable, "Apply variable to visibility"), (Icon::Blend, "Blend mode"), ], diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/section_panel.rs index 13d89eac..4d51d4f6 100644 --- a/crates/ui-egui/src/section_panel.rs +++ b/crates/ui-egui/src/section_panel.rs @@ -120,7 +120,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); } row(ui, 32.0, |ui| { - let fit = ui.add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new("Resize to fit")).on_hover_text("Fit to contents ⌥⇧⌘R"); + let fit = ui + .add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new("Resize to fit")) + .on_hover_text(crate::shortcuts::tip("Fit to contents", "object.resizeToFit")); if fit.clicked() { app.run("object.resizeToFit", json!({"ids": ids})); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 494f093e..787a44bd 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -1,63 +1,82 @@ -//! Keyboard handling: the catalog's shortcuts, tool keys, nudging, and text editing keys. +//! Keyboard handling: the shortcuts of [`figcraft_engine::keymap`], tool keys, nudging, opacity +//! digits and text editing keys. + +use std::sync::Mutex; +use std::sync::atomic::{AtomicU8, Ordering}; use egui::{Context, Event, Key, Modifiers}; -use figcraft_engine::catalog::MENU; +use figcraft_engine::keymap::{self, Platform}; use serde_json::json; use crate::FigApp; -/// Parse "Shift+Cmd+K" into modifiers + key. -pub fn parse(s: &str) -> Option<(Modifiers, Key)> { - let mut m = Modifiers::NONE; - let mut key = None; - for part in s.split('+') { - match part { - "Shift" => m.shift = true, - "Cmd" => m.command = true, - "Alt" => m.alt = true, - "Ctrl" => m.ctrl = true, - "" => {} - k => key = key_of(k), - } +/// Two opacity digits typed within this long make one exact percentage ("4" then "5": 45%). +pub const OPACITY_CHAIN_SECS: f64 = 0.6; + +/// 0: not known yet (use the build's platform), 1: macOS, 2: Windows/Linux. +static PLATFORM: AtomicU8 = AtomicU8::new(0); + +/// The platform whose keys apply: the build's, or in the browser the one egui detected. +pub fn platform() -> Platform { + match PLATFORM.load(Ordering::Relaxed) { + 1 => Platform::Mac, + 2 => Platform::Other, + _ => Platform::current(), } - key.map(|k| (m, k)) } -fn key_of(k: &str) -> Option { - Some(match k { - "Delete" => Key::Backspace, - "Enter" => Key::Enter, - "Escape" => Key::Escape, - "Tab" => Key::Tab, - "PageUp" => Key::PageUp, - "PageDown" => Key::PageDown, - "=" => Key::Equals, - "-" => Key::Minus, - "[" => Key::OpenBracket, - "]" => Key::CloseBracket, - "\\" => Key::Backslash, - "'" => Key::Quote, - "," => Key::Comma, - "." => Key::Period, - "/" | "?" => Key::Slash, - other => Key::from_name(other)?, - }) +fn note_platform(ctx: &Context) { + PLATFORM.store(if ctx.os() == egui::os::OperatingSystem::Mac { 1 } else { 2 }, Ordering::Relaxed); } -/// Whether the modifiers held match a shortcut's. Off macOS, Ctrl is the command key (egui sets -/// both `ctrl` and `command` for it), so a held command stands for Ctrl too. -fn mods_match(want: Modifiers, got: Modifiers) -> bool { - want.shift == got.shift && want.command == got.command && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd || got.command) +/// The keymap's view of egui's modifiers (Ctrl off macOS sets egui's `command` too). +pub fn mods(m: Modifiers) -> keymap::Mods { + keymap::Mods { shift: m.shift, alt: m.alt, cmd: m.command || m.mac_cmd, ctrl: m.ctrl } +} + +/// The key a menu shows for a command. +pub fn shortcut(id: &str) -> Option<&'static str> { + keymap::primary(id, platform()) +} + +/// A tooltip naming a command's key on this platform ("Align left ⌥A"; just the label unbound). +pub fn tip(label: &'static str, id: &str) -> &'static str { + cached_tip(label, shortcut(id)) +} + +/// A tooltip naming a key ("Resources ⇧I"). +pub fn key_tip(label: &'static str, key: &'static str) -> &'static str { + cached_tip(label, Some(key)) +} + +/// Panels ask for their tooltips every frame: each (label, key, platform) is built once. +fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { + type Entry = (Platform, &'static str, &'static str, &'static str); + static CACHE: Mutex> = Mutex::new(Vec::new()); + let Some(key) = key else { return label }; + let p = platform(); + let Ok(mut cache) = CACHE.lock() else { return label }; + if let Some(e) = cache.iter().find(|e| e.0 == p && e.1 == label && e.2 == key) { + return e.3; + } + // A bounded set: one entry per tooltip in the code. + let text: &'static str = Box::leak(format!("{label} {}", keymap::display(key, p)).into_boxed_str()); + cache.push((p, label, key, text)); + text } /// Run the shortcuts this frame's keys press. `typing`: a field had the keyboard when the frame /// began. pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { + note_platform(ctx); // Text fields in panels own the keyboard (also the key that ends their editing). if typing || ctx.memory(|m| m.focused().is_some()) || app.ui.palette.is_some() { return; } let events: Vec = ctx.input(|i| i.events.clone()); + if events.is_empty() { + return; + } let editing = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false); if editing { text_keys(app, ctx, &events); @@ -69,12 +88,13 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { if paste_key && !paste_event { crate::menus::paste(app, None); } + let now = ctx.input(|i| i.time); for ev in &events { match ev { 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, ctx) => { + Event::Key { key, pressed: true, modifiers, repeat, .. } if key_event(app, *key, *modifiers, *repeat, now, ctx) => { // handled } _ => {} @@ -82,7 +102,25 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { } } -fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, ctx: &Context) -> bool { +/// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out +/// before it deselects, Delete ends a pen path, Enter edits the selected text or vector). +pub fn run_binding(app: &mut FigApp, id: &str) { + match id { + "select.none" => { + if let Err(e) = app.session.escape() { + app.toast(e.to_string()); + } + app.ui.context_menu = None; + } + "edit.delete" if app.session.tool.pen.is_some() => { + let _ = app.session.pen_finish(); + } + "select.children" if enter_text(app) => {} + _ => crate::menus::invoke(app, id), + } +} + +fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool { // A gradient open in the colour picker: Delete removes its selected stop. if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) { return true; @@ -97,43 +135,14 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, ctx: &Conte return true; } } - // Catalog shortcuts (exact modifier match). - for item in MENU.iter().filter(|i| !i.shortcut.is_empty()) { - if let Some((want, k)) = parse(item.shortcut) - && k == key - && mods_match(want, m) - { - // Copy/cut/paste arrive as clipboard events on most platforms. - if matches!(item.id, "edit.copy" | "edit.cut" | "edit.paste") { - return true; - } - if repeat && !matches!(item.id, "edit.undo" | "edit.redo" | "view.zoomIn" | "view.zoomOut") { - return true; - } - if item.id == "select.none" { - if let Err(e) = app.session.escape() { - app.toast(e.to_string()); - } - app.ui.context_menu = None; - return true; - } - if item.id == "edit.delete" { - // Delete key also removes the pen path / ends gestures. - if app.session.tool.pen.is_some() { - let _ = app.session.pen_finish(); - return true; - } - } - if item.id == "select.children" && enter_text(app) { - return true; - } - crate::menus::invoke(app, item.id); + if let Some(b) = keymap::lookup(platform(), keymap::Context::Canvas, key.name(), mods(m)) { + // Copy/cut/paste arrive as clipboard events on most platforms. + if matches!(b.id, "edit.copy" | "edit.cut" | "edit.paste") { return true; } - } - // Delete also via the Delete key. - if key == Key::Delete && m.is_none() { - app.run("edit.delete", json!({})); + if !repeat || matches!(b.id, "edit.undo" | "edit.redo" | "view.zoomIn" | "view.zoomOut") { + run_binding(app, b.id); + } return true; } // Arrows move through the layers panel while it has the keyboard. @@ -159,32 +168,7 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, ctx: &Conte } return true; } - // Number keys: opacity. - if app.session.prefs.number_keys_opacity && m.is_none() && !app.session.selection().is_empty() { - let digit = match key { - Key::Num0 => Some(1.0), - Key::Num1 => Some(0.1), - Key::Num2 => Some(0.2), - Key::Num3 => Some(0.3), - Key::Num4 => Some(0.4), - Key::Num5 => Some(0.5), - Key::Num6 => Some(0.6), - Key::Num7 => Some(0.7), - Key::Num8 => Some(0.8), - Key::Num9 => Some(0.9), - _ => None, - }; - if let Some(o) = digit { - let ids = app.session.selection(); - app.set_props(&ids, json!({"opacity": o})); - return true; - } - } - if let Some((want, k)) = parse(crate::present::PRESENT_SHORTCUT) - && k == key - && mods_match(want, m) - { - app.run("proto.present", json!({})); + if m.is_none() && !repeat && opacity_digit(app, key, now) { return true; } // Enter while the pen is drawing finishes it. @@ -195,6 +179,33 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, ctx: &Conte false } +/// The digit a number key types. +fn digit(key: Key) -> Option { + let name = key.name(); + let mut chars = name.chars(); + let d = chars.next()?.to_digit(10)?; + chars.next().is_none().then_some(d) +} + +/// Number keys set the selection's opacity: one digit is tens of percent (0: 100%), a second digit +/// typed soon after makes the exact percentage. +pub fn opacity_digit(app: &mut FigApp, key: Key, now: f64) -> bool { + let Some(d) = digit(key) else { return false }; + if !app.session.prefs.number_keys_opacity || app.session.selection().is_empty() { + return false; + } + let first = app.ui.opacity_digit.take().filter(|(_, t)| now - t <= OPACITY_CHAIN_SECS).map(|(d, _)| d); + let digits = match first { + Some(f) => format!("{f}{d}"), + None => { + app.ui.opacity_digit = Some((d, now)); + d.to_string() + } + }; + app.run("object.opacityKeys", json!({"digits": digits})); + true +} + /// Enter on a single selected text layer starts editing it. fn enter_text(app: &mut FigApp) -> bool { let sel = app.session.selection(); @@ -252,7 +263,7 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) { } } Event::Key { key, pressed: true, modifiers: m, .. } => { - if (m.command || m.alt) && text_shortcut(app, *key, *m) { + if (m.command || m.alt || m.ctrl) && text_shortcut(app, *key, *m) { skip_text = true; continue; } @@ -303,18 +314,15 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) { ime_output(app, ctx); } -/// Text-menu shortcuts (Bold, lists, alignment, size/weight/spacing steps…) while editing text. +/// Shortcuts live while editing text (Bold, lists, alignment, size/weight/spacing steps, Save…). fn text_shortcut(app: &mut FigApp, key: Key, m: Modifiers) -> bool { - for item in MENU.iter().filter(|i| i.id.starts_with("text.") && !i.shortcut.is_empty()) { - if let Some((want, k)) = parse(item.shortcut) - && k == key - && mods_match(want, m) - { - crate::menus::invoke(app, item.id); - return true; + match keymap::lookup(platform(), keymap::Context::Text, key.name(), mods(m)) { + Some(b) => { + crate::menus::invoke(app, b.id); + true } + None => false, } - false } /// Tell the platform where the caret is (inside the composition while composing) so IME @@ -344,19 +352,3 @@ fn ime_output(app: &FigApp, ctx: &Context) { }) }); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_catalog_shortcuts() { - let (m, k) = parse("Shift+Cmd+Z").unwrap(); - assert!(m.shift && m.command && k == Key::Z); - assert_eq!(parse("Alt+Cmd+]").map(|x| x.1), Some(Key::CloseBracket)); - // Every catalog shortcut parses. - for item in MENU.iter().filter(|i| !i.shortcut.is_empty()) { - assert!(parse(item.shortcut).is_some(), "{}", item.shortcut); - } - } -} diff --git a/crates/ui-egui/src/shortcuts_panel.rs b/crates/ui-egui/src/shortcuts_panel.rs new file mode 100644 index 00000000..d144da18 --- /dev/null +++ b/crates/ui-egui/src/shortcuts_panel.rs @@ -0,0 +1,138 @@ +//! Help ▸ Keyboard shortcuts: a panel along the bottom of the window with a tab per group of the +//! keymap (Essential, Tools, View…), each command's keys as keycaps for this platform, and a search +//! across every group. + +use egui::{Align2, Context, CornerRadius, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_engine::keymap::{GESTURES, Group, KEYS, Platform}; + +use crate::FigApp; +use crate::theme::{Tokens, regular}; + +/// Most keys a row shows (a command's first bindings; the rest stay in the menus' alternates). +const MAX_KEYS: usize = 2; +const ROW_H: f32 = 26.0; +const COLUMNS: usize = 3; +/// Rows a column shows before the panel scrolls. +const MAX_ROWS: usize = 9; + +/// One row: the action and its keys as written on this platform. +struct Row { + label: &'static str, + keys: Vec, +} + +/// Rows per column of the fullest tab, so switching tabs doesn't resize the panel. +fn tallest_column() -> usize { + let count = |g: Group| KEYS.iter().filter(|b| b.group == g).count() + GESTURES.iter().filter(|x| x.0 == g).count(); + Group::ALL.iter().map(|g| count(*g).div_ceil(COLUMNS)).max().unwrap_or(1) +} + +fn rows(group: Option, query: &str, platform: Platform) -> Vec { + let q = query.trim().to_lowercase(); + let wanted = |g: Group, label: &str| group.is_none_or(|x| x == g) && (q.is_empty() || label.to_lowercase().contains(&q)); + let mut out: Vec = KEYS + .iter() + .filter(|b| wanted(b.group, b.title())) + .map(|b| { + // Alternatives that read the same (⌘+ and ⇧⌘+) show once. + let mut keys: Vec = Vec::with_capacity(MAX_KEYS); + for k in b.keys(platform) { + let shown = figcraft_engine::keymap::display(k, platform); + if keys.len() < MAX_KEYS && !keys.contains(&shown) { + keys.push(shown); + } + } + Row { label: b.title(), keys } + }) + .collect(); + out.extend( + GESTURES.iter().filter(|(g, label, ..)| wanted(*g, label)).map(|(_, label, mac, other)| Row { + label, + keys: vec![if platform == Platform::Mac { (*mac).to_string() } else { (*other).to_string() }], + }), + ); + out +} + +/// Paint `text` as a keycap ending at `right`; returns its left edge. +fn keycap(ui: &Ui, t: &Tokens, text: &str, right: f32, cy: f32) -> f32 { + let galley = ui.painter().layout_no_wrap(text.to_string(), regular(11.0), t.text); + let w = galley.size().x + 10.0; + let r = egui::Rect::from_min_size(pos2(right - w, cy - 10.0), vec2(w, 20.0)); + ui.painter().rect_filled(r, CornerRadius::same(4), t.field); + ui.painter().rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside); + ui.painter().galley(pos2(r.min.x + 5.0, cy - galley.size().y / 2.0), galley, t.text); + r.min.x +} + +fn row(ui: &mut Ui, t: &Tokens, row: &Row, width: f32) { + let (r, resp) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); + } + let cy = r.center().y; + let mut right = r.max.x - 6.0; + for (i, k) in row.keys.iter().enumerate().rev() { + right = keycap(ui, t, k, right, cy); + if i > 0 { + let or = ui.painter().layout_no_wrap("or".into(), regular(10.0), t.text_tertiary); + right -= 4.0 + or.size().x; + ui.painter().galley(pos2(right, cy - or.size().y / 2.0), or, t.text_tertiary); + right -= 4.0; + } + } + let label_w = (right - r.min.x - 14.0).max(20.0); + let label = crate::widgets::elide(ui, row.label, label_w); + ui.painter().text(pos2(r.min.x + 6.0, cy), Align2::LEFT_CENTER, label, regular(12.0), t.text); +} + +pub fn show(app: &mut FigApp, ctx: &Context) { + if !app.ui.shortcuts_panel { + return; + } + let t = Tokens::get(ctx); + let platform = crate::shortcuts::platform(); + let width = (ctx.content_rect().width() - 48.0).clamp(480.0, 1040.0); + let mut open = true; + crate::dialogs::window("Keyboard shortcuts", ctx).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -16.0)).open(&mut open).show(ctx, |ui| { + ui.set_width(width); + ui.horizontal(|ui| { + crate::dialogs::tab_strip(ui, &t, &Group::ALL, Group::label, &mut app.ui.shortcuts_tab); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::TextEdit::singleline(&mut app.ui.shortcuts_query).hint_text("Search shortcuts").desired_width(180.0)); + }); + }); + ui.add_space(10.0); + let searching = !app.ui.shortcuts_query.trim().is_empty(); + let list = rows((!searching).then_some(app.ui.shortcuts_tab), &app.ui.shortcuts_query, platform); + let body_h = (ROW_H + 2.0) * tallest_column().min(MAX_ROWS) as f32; + if list.is_empty() { + ui.allocate_ui(vec2(width, body_h), |ui| { + ui.set_min_height(body_h); + ui.label(egui::RichText::new("No shortcuts match").color(t.text_secondary).font(regular(12.0))); + }); + return; + } + let gap = 24.0; + let col_w = (width - gap * (COLUMNS as f32 - 1.0)) / COLUMNS as f32; + let per = list.len().div_ceil(COLUMNS); + egui::ScrollArea::vertical().min_scrolled_height(body_h).max_height(body_h).show(ui, |ui| { + ui.set_min_height(body_h); + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing = vec2(gap, 0.0); + for chunk in list.chunks(per.max(1)) { + ui.vertical(|ui| { + ui.set_width(col_w); + ui.spacing_mut().item_spacing.y = 2.0; + for r in chunk { + row(ui, &t, r, col_w); + } + }); + } + }); + }); + }); + if !open { + app.ui.shortcuts_panel = false; + } +} diff --git a/crates/ui-egui/src/shortcuts_tests.rs b/crates/ui-egui/src/shortcuts_tests.rs new file mode 100644 index 00000000..cd0f0ea5 --- /dev/null +++ b/crates/ui-egui/src/shortcuts_tests.rs @@ -0,0 +1,190 @@ +//! Keyboard shortcuts driven through egui headless: number keys for opacity (two quick digits make +//! an exact percentage), Shift+0/1/2 zoom, alignment and Shift+E/Shift+D panel keys, Cmd/Ctrl+K on +//! the canvas and while editing text, text formatting keys while editing, and the hold-Z zoom tool. +//! Modifiers are Cmd/Alt/Shift only, which mean the same on every platform. + +use egui::{Event, Key, Modifiers, pos2}; +use figcraft_doc::{NodeId, TextDecoration}; +use figcraft_engine::Session; +use figcraft_engine::keymap::KEYS; +use serde_json::json; + +use crate::RightTab; +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn press(u: &mut Ui, key: Key, modifiers: Modifiers) { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn opacity(u: &Ui, id: NodeId) -> f32 { + u.app.session.doc().unwrap().node(id).unwrap().opacity +} + +fn zoom(u: &Ui) -> f64 { + u.app.session.ds().unwrap().view.zoom +} + +/// Longer than the window for a second opacity digit. +fn pause(u: &mut Ui) { + for _ in 0..60 { + u.frame(vec![]); + } +} + +/// Two rectangles, both selected. +fn setup() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()); + let b = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 30, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + (Ui::new(s), a, b) +} + +#[test] +fn every_shortcut_reaches_something_the_app_runs() { + for b in KEYS { + assert!(crate::menus::available(b.id), "{} is bound but nothing runs it", b.id); + } +} + +#[test] +fn number_keys_set_opacity_and_two_quick_digits_an_exact_percentage() { + let (mut u, a, b) = setup(); + press(&mut u, Key::Num5, Modifiers::NONE); + assert_eq!((opacity(&u, a), opacity(&u, b)), (0.5, 0.5)); + pause(&mut u); + // 4 then 5 straight after: 45%. + press(&mut u, Key::Num4, Modifiers::NONE); + assert_eq!(opacity(&u, a), 0.4); + press(&mut u, Key::Num5, Modifiers::NONE); + assert_eq!(opacity(&u, a), 0.45); + // A pause starts over: 0 alone is 100%. + pause(&mut u); + press(&mut u, Key::Num0, Modifiers::NONE); + assert_eq!(opacity(&u, a), 1.0); + // Off in Preferences: digits do nothing. + u.app.run("pref.numberKeysOpacity", json!({"on": false})); + pause(&mut u); + press(&mut u, Key::Num3, Modifiers::NONE); + assert_eq!(opacity(&u, a), 1.0); +} + +#[test] +fn shift_digits_zoom() { + let (mut u, _, b) = setup(); + u.app.run("view.set", json!({"zoom": 0.5})); + press(&mut u, Key::Num0, Modifiers::SHIFT); + assert_eq!(zoom(&u), 1.0, "Shift+0: 100%"); + // Shift+1 arrives as `!` on most layouts. + press(&mut u, Key::Exclamationmark, Modifiers::SHIFT); + let fit = zoom(&u); + assert!(fit > 1.0, "Shift+1 fits the two small rectangles: {fit}"); + u.app.run("select.set", json!({"ids": [b]})); + press(&mut u, Key::Num2, Modifiers::SHIFT); + assert!(zoom(&u) > fit, "Shift+2 fits the smaller selection"); + let v = &u.app.session.ds().unwrap().view; + assert_eq!((v.cx, v.cy), (110.0, 40.0), "centred on it"); + // `+` and `-` step the zoom. + let z = zoom(&u); + press(&mut u, Key::Minus, Modifiers::NONE); + let out = zoom(&u); + assert!(out < z, "- zooms out"); + press(&mut u, Key::Plus, Modifiers::SHIFT); + assert!(zoom(&u) > out, "+ zooms in"); +} + +#[test] +fn alignment_keys() { + let (mut u, a, b) = setup(); + let x = |u: &Ui, id: NodeId| u.app.session.doc().unwrap().world_bounds(id).unwrap(); + press(&mut u, Key::D, Modifiers::ALT); + assert_eq!((x(&u, a).x1, x(&u, b).x1), (120.0, 120.0), "Alt+D aligns right edges"); + press(&mut u, Key::W, Modifiers::ALT); + assert_eq!((x(&u, a).y0, x(&u, b).y0), (0.0, 0.0), "Alt+W aligns tops"); + press(&mut u, Key::V, Modifiers::ALT); + assert_eq!((x(&u, a).center().y, x(&u, b).center().y), (20.0, 20.0), "Alt+V centres vertically"); +} + +#[test] +fn panel_keys_and_cmd_k() { + let (mut u, _, _) = setup(); + press(&mut u, Key::E, Modifiers::SHIFT); + assert_eq!(u.app.ui.right_tab, RightTab::Prototype, "Shift+E: Prototype"); + press(&mut u, Key::E, Modifiers::SHIFT); + assert_eq!(u.app.ui.right_tab, RightTab::Design, "and back to Design"); + press(&mut u, Key::D, Modifiers::SHIFT); + assert_eq!(u.app.ui.right_tab, RightTab::Inspect, "Shift+D: Dev Mode"); + press(&mut u, Key::D, Modifiers::SHIFT); + assert_eq!(u.app.ui.right_tab, RightTab::Design); + let collapsed = u.app.ui.left_collapsed; + press(&mut u, Key::Backslash, Modifiers::SHIFT | Modifiers::COMMAND); + assert_eq!(u.app.ui.left_collapsed, !collapsed, "Shift+Cmd+\\ toggles the left sidebar"); + press(&mut u, Key::K, Modifiers::COMMAND); + assert!(u.app.ui.palette.is_some(), "Cmd+K opens Actions on the canvas"); +} + +#[test] +fn text_keys_while_editing() { + let mut s = Session::new(); + let t = id(s.execute("text.create", &json!({"text": "Hello", "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); + assert!(u.app.session.ds().unwrap().text_edit.is_some(), "Enter edits the text"); + press(&mut u, Key::A, Modifiers::COMMAND); + press(&mut u, Key::U, Modifiers::COMMAND); + let style = |u: &Ui| u.app.session.doc().unwrap().node(t).unwrap().kind.text().unwrap().style_at(1).clone(); + assert_eq!(style(&u).decoration, TextDecoration::Underline, "Cmd+U underlines the selection"); + let size = style(&u).font_size; + press(&mut u, Key::Period, Modifiers::SHIFT | Modifiers::COMMAND); + assert!(style(&u).font_size > size, "Cmd+Shift+> grows the text"); + // Plain letters type; Cmd+K doesn't open Actions while editing. + press(&mut u, Key::K, Modifiers::COMMAND); + assert!(u.app.ui.palette.is_none()); + assert!(u.app.session.ds().unwrap().text_edit.is_some(), "still editing"); +} + +#[test] +fn hold_z_zoom_tool() { + let (mut u, _, _) = setup(); + u.app.run("view.set", json!({"zoom": 1.0})); + let p = pos2(700.0, 450.0); + let held = Modifiers::NONE; + u.frame(vec![Event::PointerMoved(p), Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: held }]); + let before = u.app.to_page(p); + u.button(p, true); + u.button(p, false); + assert_eq!(zoom(&u), 2.0, "a click with Z held zooms in a step"); + let after = u.app.to_page(p); + assert!((before.x - after.x).abs() < 0.01 && (before.y - after.y).abs() < 0.01, "about the pointer"); + assert_eq!(u.app.session.selection().len(), 2, "the click didn't reach the move tool"); + u.frame(vec![Event::Key { key: Key::Z, physical_key: None, pressed: false, repeat: false, modifiers: held }]); + u.button(p, true); + u.button(p, false); + assert_eq!(zoom(&u), 2.0, "released: clicks select again"); +} + +#[test] +fn shortcuts_panel_opens_switches_tabs_and_searches() { + let (mut u, _, _) = setup(); + // Ctrl+Shift+? (Control on macOS too). + press(&mut u, Key::Questionmark, Modifiers { ctrl: true, command: !cfg!(target_os = "macos"), shift: true, ..Modifiers::NONE }); + assert!(u.app.ui.shortcuts_panel, "Ctrl+Shift+? opens the panel"); + for g in figcraft_engine::keymap::Group::ALL { + u.app.ui.shortcuts_tab = g; + u.frame(vec![]); + } + u.app.ui.shortcuts_query = "zoom".into(); + u.frame(vec![]); + u.app.ui.shortcuts_query = "no such shortcut".into(); + u.frame(vec![]); + u.app.run("help.shortcuts", json!({})); + assert!(!u.app.ui.shortcuts_panel, "the command toggles it"); +} diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index dd076609..38293251 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -97,7 +97,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } // New tab. let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0)); - let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).on_hover_text("New design file ⌘N"); + let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).on_hover_text(crate::shortcuts::tip("New design file", "file.new")); if nresp.hovered() { ui.painter().rect_filled(nr, CornerRadius::same(5), t.hover); } @@ -125,7 +125,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty()); if dirty { app.ui.find = None; - app.toast("Unsaved changes: save first, or close again with ⌥ to discard"); + app.toast(format!("Unsaved changes: save first, or close again with {} to discard", crate::toolbar::pretty_shortcut("Alt"))); if ui.input(|inp| inp.modifiers.alt) { app.run("file.close", json!({"index": i, "force": true})); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a0fc06a2..5b6c5e80 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -86,7 +86,10 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } } // Resources (components, plugins) opens the Assets panel; Actions palette. - for (icon, tip, id) in [(Icon::Assets, "Resources ⇧I", "resources"), (Icon::Actions, "Actions ⌘K", "actions")] { + for (icon, tip, id) in [ + (Icon::Assets, crate::shortcuts::key_tip("Resources", "Shift+I"), "resources"), + (Icon::Actions, crate::shortcuts::tip("Actions", "app.actions"), "actions"), + ] { let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); let resp = ui.interact(r, ui.id().with(id), Sense::click()).on_hover_text(tip); if resp.hovered() { @@ -150,7 +153,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { ui.painter().text(hr.center(), Align2::CENTER_CENTER, "?", crate::theme::regular(15.0), t.text); crate::right::menu_popup(&hresp).show(|ui| { for (l, c) in [("Keyboard shortcuts", "help.shortcuts"), ("Help page", "help.page"), ("About FigCraft", "help.about")] { - if crate::right::menu_item(ui, l, figcraft_engine::catalog::shortcut(c).unwrap_or("")) { + if crate::right::menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) { app.run(c, json!({})); ui.close(); } @@ -328,30 +331,7 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { } } -/// "Shift+Cmd+K" → "⇧⌘K" on macOS. +/// "Shift+Cmd+K" → "⇧⌘K" on macOS, "Ctrl+Shift+K" elsewhere. pub fn pretty_shortcut(s: &str) -> String { - if s.is_empty() { - return String::new(); - } - let mac = cfg!(target_os = "macos"); - if !mac { - return s.replace("Cmd", "Ctrl"); - } - let mut out = String::new(); - for part in s.split('+') { - out.push_str(match part { - "Shift" => "⇧", - "Cmd" => "⌘", - "Alt" => "⌥", - "Ctrl" => "⌃", - "Delete" => "⌫", - "Enter" => "↵", - "Escape" => "Esc", - "Tab" => "⇥", - "PageUp" => "⇞", - "PageDown" => "⇟", - other => other, - }); - } - out + figcraft_engine::keymap::display(s, crate::shortcuts::platform()) } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bf20319e..b7678091 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -94,11 +94,13 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d ### `ui.key {key, shift?, alt?, cmd?, ctrl?}` -A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `Q` `X` `Shift+B` `Shift+E` `Shift+W` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`; with the shape builder, `Enter` finishes): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key, -`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge; auto layout children move one place instead: along the flow, between wrapped rows, to the next grid cell, or into the neighbouring nested auto layout frame; with `cmd` they resize the selection). With the Comment +A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `B` `Q` `X` `Shift+E` `Shift+W` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`; with the shape builder, `Enter` finishes): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key, +`Escape`, `Delete`/`Backspace`, `Enter`, number keys (opacity; two quick digits make an exact percentage), arrows (nudge; shift = big nudge; auto layout children move one place instead: along the flow, between wrapped rows, to the next grid cell, or into the neighbouring nested auto layout frame; with `cmd` they resize the selection). With the Comment tool, `ArrowUp`/`ArrowDown` open the previous/next thread of the Comments panel's list instead -(returns `{"comment": id}`). While text is being edited, keys go to the text editor. Returns -`{"command": id}` for shortcuts. +(returns `{"comment": id}`). While text is being edited, the text shortcuts (Bold, alignment, +size steps, Save…) run and other keys go to the text editor. Shortcuts follow the platform the app +runs on (`cmd` is Cmd on macOS and Ctrl elsewhere; on macOS `ctrl` is the separate Control key). +Returns `{"command": id}` for shortcuts. ### `ui.type {text}` diff --git a/docs/mcp.md b/docs/mcp.md index 05ebc938..c2dfec98 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -138,7 +138,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), `.svg` or an image; save as `.figcraft`. | | `undo` / `redo` | Step through history. | | `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. | -| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (menu shortcuts, tool keys, Escape, Delete, arrows; text editing keys while editing text). | +| `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. | | `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 when attached). | @@ -159,6 +159,24 @@ along the flow, to the previous/next row of a wrapping layout, to the neighbouri at the end of a flow or across a row's axis, into the neighbouring nested auto layout frame when the frame around flows that way (a list of rows: Down moves an item into the row below). +### Keyboard shortcuts + +The default shortcuts live in one table (`figcraft_engine::keymap`), grouped like the public shortcut +reference (Essential, Tools, View, Zoom, Text, Shape, Selection, Edit, Transform, Arrange, +Components, Prototype) with the macOS keys and, where they differ, the Windows/Linux keys (for +example Use as mask is ⌃⌘M on macOS and Ctrl+Alt+M elsewhere; Bring to front ⌥⌘] and +Ctrl+Shift+]). `list_commands` reports each command's first key for the platform the server runs +on; `key` resolves keys the way the canvas does. Commands behind keys: + +- `object.opacityKeys {digits, ids?}`: the opacity number keys. One digit is tens of percent (`"0"` + is 100%); two digits are the exact percentage (`"45"` is 45%, `"05"` is 5%), what typing two + digits in quick succession does on the canvas. +- `view.zoomAt {x, y, out?, width?, height?}`: the zoom tool (hold Z on the canvas; Alt zooms out). + A point zooms one step in (or out) keeping that page point in place; an area fills the view. +- `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. + ### Editing vectors Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): diff --git a/docs/parity.md b/docs/parity.md index a25c8b04..b00c1a02 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -1,6 +1,6 @@ # Parity report -Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (420 commands in the engine). +Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (437 commands in the engine). **197 / 202 menu items (97.5%)**