mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
+14
-3
@@ -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 <port>`: execute commands, inspect document/UI,
|
||||
synthetic pointer/keys/typing, menu invocation, screenshots, renders).
|
||||
|
||||
+232
-233
@@ -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())
|
||||
}
|
||||
|
||||
@@ -161,6 +161,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<V
|
||||
Ok(json!({"selection": ids}))
|
||||
}
|
||||
|
||||
/// The opacity number keys give: one digit is tens of percent (0 is 100%), two digits typed in
|
||||
/// quick succession the exact percentage.
|
||||
pub fn opacity_from_digits(digits: &str) -> Option<f32> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let big = boolean(p, "big").unwrap_or(false);
|
||||
let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small };
|
||||
|
||||
@@ -194,6 +194,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
|
||||
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
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::*;
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
let ds = s.ds()?;
|
||||
let frames = ds.doc.top_frames(ds.page);
|
||||
@@ -146,6 +165,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
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<CommandSpec> {
|
||||
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()
|
||||
|
||||
@@ -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<Item = &'static str> {
|
||||
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
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
+49
-53
@@ -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<NodeId> {
|
||||
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<Value, String> {
|
||||
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<NodeId> {
|
||||
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<Value, String> {
|
||||
let scale = f(p, "scale").unwrap_or(1.0);
|
||||
let ids: Vec<u64> = ids_of(p).into_iter().map(|i| i.0).collect();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<bool>,
|
||||
zoom_drag: Option<Pos2>,
|
||||
pub last_pointer: Option<Point>,
|
||||
preedit: Option<PreeditView>,
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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<T: Copy + PartialEq>(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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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![]
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -1427,9 +1427,6 @@ fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option<Layout> {
|
||||
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 {
|
||||
|
||||
+24
-18
@@ -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"),
|
||||
],
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
+120
-128
@@ -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<Key> {
|
||||
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<Vec<Entry>> = 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<Event> = 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<u32> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Group>, query: &str, platform: Platform) -> Vec<Row> {
|
||||
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<Row> = KEYS
|
||||
.iter()
|
||||
.filter(|b| wanted(b.group, b.title()))
|
||||
.map(|b| {
|
||||
// Alternatives that read the same (⌘+ and ⇧⌘+) show once.
|
||||
let mut keys: Vec<String> = 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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
|
||||
+19
-1
@@ -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):
|
||||
|
||||
+1
-1
@@ -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%)**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user