From 16cdbd1b3399b29743736ae66e3cf90cfe53312c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:50 +0000 Subject: [PATCH] refactor(ui-egui): move menus.rs items to menus/{bar,context,entries,invoke,labels,run,slots,state,submenus,tree,ui_commands}.rs --- crates/ui-egui/src/menus.rs | 4415 +---------------------- crates/ui-egui/src/menus/bar.rs | 172 + crates/ui-egui/src/menus/context.rs | 173 + crates/ui-egui/src/menus/entries.rs | 147 + crates/ui-egui/src/menus/invoke.rs | 279 ++ crates/ui-egui/src/menus/labels.rs | 333 ++ crates/ui-egui/src/menus/run.rs | 725 ++++ crates/ui-egui/src/menus/slots.rs | 169 + crates/ui-egui/src/menus/state.rs | 275 ++ crates/ui-egui/src/menus/submenus.rs | 210 ++ crates/ui-egui/src/menus/tree.rs | 711 ++++ crates/ui-egui/src/menus/ui_commands.rs | 1227 +++++++ 12 files changed, 4453 insertions(+), 4383 deletions(-) create mode 100644 crates/ui-egui/src/menus/bar.rs create mode 100644 crates/ui-egui/src/menus/context.rs create mode 100644 crates/ui-egui/src/menus/entries.rs create mode 100644 crates/ui-egui/src/menus/invoke.rs create mode 100644 crates/ui-egui/src/menus/labels.rs create mode 100644 crates/ui-egui/src/menus/run.rs create mode 100644 crates/ui-egui/src/menus/slots.rs create mode 100644 crates/ui-egui/src/menus/state.rs create mode 100644 crates/ui-egui/src/menus/submenus.rs create mode 100644 crates/ui-egui/src/menus/tree.rs create mode 100644 crates/ui-egui/src/menus/ui_commands.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 89162c521..2c8c051f6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -45,2304 +45,39 @@ fn sub(label: &'static str, items: Vec) -> Item { } use Item::Sep; -/// UI-level commands: (id, label, shortcut, params doc). -pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ - ( - "app.language", - "Interface Language", - "", - "{lang: auto|} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, es, zh-hant)", - ), - ( - "file.open", - "Open…", - "Cmd+O", - "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", - ), - ( - "file.save", - "Save", - "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", - ), - ( - "file.place", - "Place…", - "Cmd+Shift+P", - "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)", - ), - ("file.openRecent1", "Open Recent File 1", "", "{}"), - ("file.openRecent2", "Open Recent File 2", "", "{}"), - ("file.openRecent3", "Open Recent File 3", "", "{}"), - ("file.openRecent4", "Open Recent File 4", "", "{}"), - ("file.openRecent5", "Open Recent File 5", "", "{}"), - ("file.openRecent6", "Open Recent File 6", "", "{}"), - ("file.openRecent7", "Open Recent File 7", "", "{}"), - ("file.openRecent8", "Open Recent File 8", "", "{}"), - ("file.openRecent9", "Open Recent File 9", "", "{}"), - ("file.openRecent10", "Open Recent File 10", "", "{}"), - ("view.goto1", "Saved View 1", "", "{} go to the 1. saved view"), - ("view.goto2", "Saved View 2", "", "{} go to the 2. saved view"), - ("view.goto3", "Saved View 3", "", "{} go to the 3. saved view"), - ("view.goto4", "Saved View 4", "", "{} go to the 4. saved view"), - ("view.goto5", "Saved View 5", "", "{} go to the 5. saved view"), - ("view.goto6", "Saved View 6", "", "{} go to the 6. saved view"), - ("view.goto7", "Saved View 7", "", "{} go to the 7. saved view"), - ("view.goto8", "Saved View 8", "", "{} go to the 8. saved view"), - ("view.goto9", "Saved View 9", "", "{} go to the 9. saved view"), - ("view.goto10", "Saved View 10", "", "{} go to the 10. saved view"), - ("view.goto11", "Saved View 11", "", "{} go to the 11. saved view"), - ("view.goto12", "Saved View 12", "", "{} go to the 12. saved view"), - ("view.goto13", "Saved View 13", "", "{} go to the 13. saved view"), - ("view.goto14", "Saved View 14", "", "{} go to the 14. saved view"), - ("view.goto15", "Saved View 15", "", "{} go to the 15. saved view"), - ("view.goto16", "Saved View 16", "", "{} go to the 16. saved view"), - ("view.goto17", "Saved View 17", "", "{} go to the 17. saved view"), - ("view.goto18", "Saved View 18", "", "{} go to the 18. saved view"), - ("view.goto19", "Saved View 19", "", "{} go to the 19. saved view"), - ("view.goto20", "Saved View 20", "", "{} go to the 20. saved view"), - ("view.goto21", "Saved View 21", "", "{} go to the 21. saved view"), - ("view.goto22", "Saved View 22", "", "{} go to the 22. saved view"), - ("view.goto23", "Saved View 23", "", "{} go to the 23. saved view"), - ("view.goto24", "Saved View 24", "", "{} go to the 24. saved view"), - ("view.goto25", "Saved View 25", "", "{} go to the 25. saved view"), - ("select.recall1", "Saved Selection 1", "", "{} select the 1. saved selection"), - ("select.recall2", "Saved Selection 2", "", "{} select the 2. saved selection"), - ("select.recall3", "Saved Selection 3", "", "{} select the 3. saved selection"), - ("select.recall4", "Saved Selection 4", "", "{} select the 4. saved selection"), - ("select.recall5", "Saved Selection 5", "", "{} select the 5. saved selection"), - ("select.recall6", "Saved Selection 6", "", "{} select the 6. saved selection"), - ("select.recall7", "Saved Selection 7", "", "{} select the 7. saved selection"), - ("select.recall8", "Saved Selection 8", "", "{} select the 8. saved selection"), - ("select.recall9", "Saved Selection 9", "", "{} select the 9. saved selection"), - ("select.recall10", "Saved Selection 10", "", "{} select the 10. saved selection"), - ("select.recall11", "Saved Selection 11", "", "{} select the 11. saved selection"), - ("select.recall12", "Saved Selection 12", "", "{} select the 12. saved selection"), - ("select.recall13", "Saved Selection 13", "", "{} select the 13. saved selection"), - ("select.recall14", "Saved Selection 14", "", "{} select the 14. saved selection"), - ("select.recall15", "Saved Selection 15", "", "{} select the 15. saved selection"), - ("select.recall16", "Saved Selection 16", "", "{} select the 16. saved selection"), - ("select.recall17", "Saved Selection 17", "", "{} select the 17. saved selection"), - ("select.recall18", "Saved Selection 18", "", "{} select the 18. saved selection"), - ("select.recall19", "Saved Selection 19", "", "{} select the 19. saved selection"), - ("select.recall20", "Saved Selection 20", "", "{} select the 20. saved selection"), - ("select.recall21", "Saved Selection 21", "", "{} select the 21. saved selection"), - ("select.recall22", "Saved Selection 22", "", "{} select the 22. saved selection"), - ("select.recall23", "Saved Selection 23", "", "{} select the 23. saved selection"), - ("select.recall24", "Saved Selection 24", "", "{} select the 24. saved selection"), - ("select.recall25", "Saved Selection 25", "", "{} select the 25. saved selection"), - ("type.recentFont1", "Recent Font 1", "", "{} apply the 1. most recently used font"), - ("type.recentFont2", "Recent Font 2", "", "{} apply the 2. most recently used font"), - ("type.recentFont3", "Recent Font 3", "", "{} apply the 3. most recently used font"), - ("type.recentFont4", "Recent Font 4", "", "{} apply the 4. most recently used font"), - ("type.recentFont5", "Recent Font 5", "", "{} apply the 5. most recently used font"), - ("type.recentFont6", "Recent Font 6", "", "{} apply the 6. most recently used font"), - ("type.recentFont7", "Recent Font 7", "", "{} apply the 7. most recently used font"), - ("type.recentFont8", "Recent Font 8", "", "{} apply the 8. most recently used font"), - ("type.recentFont9", "Recent Font 9", "", "{} apply the 9. most recently used font"), - ("type.recentFont10", "Recent Font 10", "", "{} apply the 10. most recently used font"), - ("type.recentFont11", "Recent Font 11", "", "{} apply the 11. most recently used font"), - ("type.recentFont12", "Recent Font 12", "", "{} apply the 12. most recently used font"), - ("type.recentFont13", "Recent Font 13", "", "{} apply the 13. most recently used font"), - ("type.recentFont14", "Recent Font 14", "", "{} apply the 14. most recently used font"), - ("type.recentFont15", "Recent Font 15", "", "{} apply the 15. most recently used font"), - ("file.clearRecent", "Clear Recent Files", "", "{}"), - ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), - ( - "ui.missingFontsDialog", - "Missing Fonts Dialog", - "", - "{folder?} open Missing Fonts (dialog `missingFonts`) for the fonts the active document uses that aren't available (text.missingFonts); with folder, it looks for their files there at once (text.findFontFiles). An error when no font is missing. Opening a document whose fonts are missing shows it after the missing linked file questions (dialog `missingLinks`). Disabled on the web", - ), - ( - "ui.findFontsInFolder", - "Find Fonts in Folder…", - "", - "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", - ), - ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), - ( - "file.export.svg", - "Export As SVG…", - "", - "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)", - ), - ("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"), - ( - "file.exportForScreens", - "Export for Screens…", - "Cmd+Alt+E", - "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})", - ), - ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), - ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), - ( - "file.newWithoutDialog", - "New Without Dialog", - "Cmd+Alt+N", - "{} makes a document with the settings the New Document dialog opens on (the first Recent entry, else the Print category's Letter), named after the next untitled document, without opening the dialog → {index, previewMode}", - ), - ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), - ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), - ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), - ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"), - ("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"), - ("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"), - ("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"), - ( - "shortcuts.preset", - "Keyboard Shortcut Set", - "", - "{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)", - ), - ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), - ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), - ( - "ui.shortcutImportDialog", - "Import Shortcut Set…", - "", - "{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \"\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too", - ), - ("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"), - ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), - ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), - ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), - ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), - ("view.snapToGlyph", "Snap to Glyph", "", "{} toggle: drawing snaps to live-text baselines and glyph bounds"), - ( - "view.preciseCursors", - "Precise Cursors", - "", - "{} toggle General › Use Precise Cursors: the drawing tools' pointers become a crosshair → true when on", - ), - ( - "imageTrace.preview", - "Image Trace Preview", - "", - "{on?: bool} turn the Image Trace panel's Preview on (true), off (false) or toggle it (omitted), as its checkbox does: on, setting changes trace the selected Image Trace object at once; off, they wait for Trace; returns the new state", - ), - ("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"), - ("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"), - ( - "view.livePaintGaps", - "Show Live Paint Gaps", - "", - "{} toggle drawing the gaps of the selected Live Paint groups (those livePaint.info lists) in each group's Gap Preview Color while edges show → true when on", - ), - ("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"), - ("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"), - ( - "type.alignCenter", - "Align center", - "Cmd+Shift+C", - "{} align the selected type's paragraphs (or those the Type tool's selection touches) centred", - ), - ("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"), - ("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"), - ("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"), - ( - "type.applyBullets", - "Apply Bullets", - "", - "{bullet?: \"dot\"|\"dash\"|\"circle\"|\"square\"|\"none\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \"bullet\"}); \"none\" turns the list off", - ), - ( - "type.applyNumbers", - "Apply Numbers", - "", - "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", - ), - ( - "type.convertListToText", - "Convert to Text", - "", - "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", - ), - ( - "align.horizontalLeft", - "Horizontal Align Left", - "", - "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.horizontalCenter", - "Horizontal Align Center", - "", - "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.horizontalRight", - "Horizontal Align Right", - "", - "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.horizontalVerticalCenter", - "Horizontal & Vertical Align Center", - "", - "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.verticalTop", - "Vertical Align Top", - "", - "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.verticalCenter", - "Vertical Align Center", - "", - "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.verticalBottom", - "Vertical Align Bottom", - "", - "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.distributeVerticalTop", - "Vertical Distribute Top", - "", - "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeVerticalCenter", - "Vertical Distribute Center", - "", - "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeVerticalBottom", - "Vertical Distribute Bottom", - "", - "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeHorizontalLeft", - "Horizontal Distribute Left", - "", - "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeHorizontalCenter", - "Horizontal Distribute Center", - "", - "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeHorizontalRight", - "Horizontal Distribute Right", - "", - "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeVerticalSpace", - "Vertical Distribute Space", - "", - "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeHorizontalSpace", - "Horizontal Distribute Space", - "", - "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "type.bold", - "Bold", - "", - "{} the selected type (or the Type tool's selection) in its family's Bold face, or back to the regular face when it is bold; keeps italics. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+B", - ), - ( - "type.italic", - "Italic", - "", - "{} the selected type (or the Type tool's selection) in its family's Italic (or Oblique) face, or back upright when it is italic; keeps the weight. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+I", - ), - ("effect.last", "Last Effect…", "Cmd+Alt+Shift+E", "{} open the dialog of the last effect applied"), - ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), - ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), - ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), - ("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"), - ( - "view.actualSize", - "Actual Size", - "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", - ), - ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), - ( - "view.goToArtboard", - "Go to Artboard", - "", - "{index: 0-based number | \"first\" | \"previous\" | \"next\" | \"last\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}", - ), - ("view.edges", "Hide Edges", "Cmd+H", "{}"), - ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), - ( - "view.template", - "Hide Template", - "Cmd+Shift+W", - "{} template layers left off the canvas (Show Template draws them again, dimmed as before) → shown", - ), - ("view.rulers", "Show Rulers", "Cmd+R", "{}"), - ( - "view.videoRulers", - "Show Video Rulers", - "", - "{} the rulers show (as Show Rulers shows them) and count the active artboard in video pixels from its top-left corner, a pixel as wide as its Video Ruler Pixel Aspect Ratio says (artboard.setProps videoRulerPixelAspectRatio) and one point tall; again (Hide Video Rulers), they count in the General unit from the ruler origin as before → whether video rulers show", - ), - ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), - ("view.guides", "Hide Guides", "Cmd+;", "{}"), - ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), - ("view.grid", "Show Grid", "Cmd+'", "{}"), - ("view.snapToGrid", "Snap to Grid", "Cmd+Shift+'", "{}"), - ("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"), - ("view.presentation", "Presentation Mode", "Shift+F", "{}"), - ("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"), - ( - "view.rotate", - "Rotate View", - "", - "{angle: degrees, clockwise on screen as the status bar shows it} turn the view to that angle about the center of the canvas; returns {angle}, normalized to (-180, 180]", - ), - ( - "view.rotateToSelection", - "Rotate View to Selection", - "", - "{} turn the view about the center of the canvas to the angle the selected objects share (selectionRotation in document.inspect, 0 when they differ), so their bounding box is square to the window; returns {angle}", - ), - ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), - ("window.control", "Control", "", "{}"), - ("window.toolbar", "Tools", "", "{}"), - ( - "window.toolbarColumns", - "Toolbar: Single/Double Column", - "", - "{double?: bool} show the toolbar's tools in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", - ), - ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), - ( - "window.floatTools", - "Float Tool Group", - "", - "{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state", - ), - ( - "toolbar.show", - "Toolbar: Show Controls", - "", - "{control?: \"fillStroke\"|\"coloring\"|\"drawMode\"|\"screenMode\", on?: bool} show (true), hide (false) or toggle (omitted) one of the controls under the toolbar's tools (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the All Tools drawer's Show section does; saved with the preferences; returns every control's state", - ), - ( - "window.editToolbar", - "Edit Toolbar", - "", - "{open?: bool} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar, as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state", - ), - ( - "toolbar.addTool", - "Toolbar: Add Tool", - "", - "{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\"} put `tool` in the current (Basic or Advanced) toolbar, moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)", - ), - ( - "toolbar.removeTool", - "Toolbar: Remove Tool", - "", - "{tool: id, group?: bool} take `tool` (with `group`, its whole group) out of the current toolbar (one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots", - ), - ( - "toolbar.reset", - "Toolbar: Reset", - "", - "{} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes; returns its slots", - ), - ("window.taskBar", "Contextual Task Bar", "", "{}"), - ( - "window.taskBar.pin", - "Pin Bar Position", - "", - "{pinned?: bool} keep the Contextual Task Bar where it is instead of following the selection (true), let it follow the selection again from where it is (false) or toggle (omitted), as the bar's More Options menu does; returns the new state", - ), - ("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"), - ( - "window.dock", - "Panels", - "Tab", - "{} hide the panels, the toolbar and the Control bar, or show again the ones the last Tab hid (all three when none of them shows and Tab hid none); returns whether it showed them", - ), - ( - "window.panels", - "Panels Except Tools and Control", - "Shift+Tab", - "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", - ), - ( - "window.panel", - "Show Panel", - "", - "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", - ), - ( - "window.panel.layout", - "Change Panel Layout", - "", - "{action} serialized docking action, including floating geometry, divider sizes and accordion state", - ), - ( - "window.panel.move", - "Move Panel", - "", - "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel, group?: bool, placement?} move a panel (with `group`, its whole group) into the group holding `anchor`: as a tab (before the `before` panel; null appends), a split beside it, or (`above` / `below`) a group of its own above or below it in its dock column or floating window; `placement` (a serialized Placement) replaces zone and before", - ), - ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), - ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), - ( - "window.panel.collapse", - "Collapse Panel Group", - "", - "{panel, collapsed?: bool} minimize the group holding a panel to its tabs (in a floating window, the window to its title bar), or restore it, as a double-click on a tab or the drag bar does; the same as window.panel.minimize {panel, minimized}. Without `collapsed` it toggles. Returns {panel, collapsed, layout}", - ), - ( - "window.panel.float", - "Float Panel", - "", - "{panel: id or \"tools\", x?, y?, width?, height?, group?: bool, column?: bool or n, onto?: id, above?: id, below?: id, collapsed?: bool} float a panel (with `group`, its whole group; with `column`, its whole dock column or floating window) as a floating window with its top-left corner at x, y in window points (default: inside the dock area, cascaded), as dragging its tab out does; a whole floating window keeps its rect unless given one. `onto` makes it a tab of the floating group holding that panel instead; `above` / `below` a group of its own above or below the group holding that panel, in its floating window or dock column. `collapsed` then minimizes (true) or restores (false) its window or group. A hidden panel shows first. \"tools\" floats the Tools panel at x, y. Returns {layout, closed, floating}", - ), - ( - "window.panel.dock", - "Dock Panel", - "", - "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window. Returns {layout, closed, floating}", - ), - ( - "window.collapseDock", - "Collapse Panels to Icons", - "", - "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", - ), - ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), - ( - "window.workspace", - "Workspace", - "", - "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)", - ), - ( - "window.workspace.reset", - "Reset Essentials", - "", - "{} reset the current workspace to the way it was saved, dropping the layout changes made in it", - ), - ("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"), - ("window.workspace.manage", "Manage Workspaces…", "", "{}"), - ("window.workspace.delete", "Delete Workspace", "", "{name}"), - ("window.workspace.rename", "Rename Workspace", "", "{name, to}"), - ("window.workspace.list", "List Workspaces", "", "{}"), - ("window.newWindow", "New Window", "", "{}"), - ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), - ( - "tool.setOption", - "Tool Option", - "", - "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen", - ), - ( - "effect.dialog", - "Effect…", - "", - "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}", - ), - ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), - ( - "ui.recolorDialog", - "Recolor Artwork…", - "", - "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name; or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", - ), - ( - "ui.cropImage", - "Crop Image", - "", - "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", - ), - ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), - ( - "file.export.pdf", - "Save as PDF…", - "", - "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing; viewAfterSaving opens the file) → {path, bytes, warnings}", - ), - ("help.about", "About VectorCraft", "", "{}"), - ("help.checkForUpdates", "Check for Updates…", "", "{}"), - ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), - ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), - ( - "ui.swatchOptions", - "Swatch Options…", - "", - "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit) or a gradient (the same dialog with `name` only; it shows the gradient), pattern editing for a pattern", - ), - ( - "ui.newSwatch", - "New Swatch…", - "", - "{spot?, global?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new); a color opens as a spot color with `spot`, and as a global process color with `global` alone", - ), - ( - "ui.newColorGroup", - "New Color Group…", - "", - "{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)", - ), - ( - "ui.colorPicker", - "Color Picker…", - "", - "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", - ), - ( - "ui.sampleScreenColor", - "Sample Color from Screen", - "", - "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", - ), - ( - "ui.newBrush", - "New Brush…", - "", - "{ids?} (default: the selected art) open New Brush, what the Brushes panel's New Brush button and art dropped on the panel open (dialog `newBrush`, field `type`: calligraphic|scatter|art|bristle|pattern, Art when there is art, else Calligraphic; scatter, art and pattern need art, else OK gives the error `nothing selected`); OK opens that type's Brush Options for the new brush (dialog `brushOptions` with the fields of ui.brushOptions and `__create`; name: New Brush, numbered when taken; the `ids` given in `__ids`), whose OK runs brush.new {type, name, params, ids?} (a name another brush has gets a number) and whose Cancel adds no brush", - ), - ( - "ui.brushOptions", - "Brush Options…", - "", - "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift, keyColor, and tips: true opens Colorization Tips (what each method does, beside a sample of the brush painted with it), as the Tips button beside Method does; OK runs brush.options", - ), - ( - "ui.graphicStyleOptions", - "Graphic Style Options…", - "", - "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", - ), - ( - "ui.symbolOptions", - "Symbol Options…", - "F8", - "{name?} open Symbol Options (dialog `symbolOptions`, fields `name`, `exportType`: movieClip|graphic, `registration` 0..8). Without `name` and with a selection: New Symbol (default name the first free \"New Symbol\"/\"New Symbol N\", Export Type Movie Clip, Registration centre); OK runs symbol.new {name, exportType, registration}. With `edit: true` or `name`: edit that symbol; OK runs symbol.options {name, newName?, exportType, registration}", - ), - ( - "tool.options", - "Tool Options…", - "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values})", - ), - ( - "ui.colorGuideOptions", - "Color Guide Options…", - "", - "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)", - ), - ( - "ui.colorBalanceDialog", - "Adjust Color Balance…", - "", - "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step", - ), - ( - "ui.saturateDialog", - "Saturate…", - "", - "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", - ), - ( - "window.swatchLibrary", - "Swatch Library", - "", - "{library: id or name (see swatch.library.list), or the id of a persistent panel's missing library | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}", - ), - ( - "window.swatchLibrary.other", - "Other Library…", - "", - "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel", - ), - ( - "ui.libraryPanel", - "Library Panel Options", - "", - "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}", - ), - ( - "ui.libraryPanel.locate", - "Locate Library…", - "", - "{panel: an open library panel's id, path?} (default: pick a file) Locate Library… of a persistent library panel whose library's file is missing: load the library from the file, as Other Library… does, and show it in the panel, which keeps its view, Show Find Field and Persistent. When the file's library has a panel open already, the panel closes and that one is brought forward, made persistent when the closed one was → what ui.libraryPanel answers", - ), - ( - "ui.saveSwatchLibrary", - "Save Swatch Library…", - "", - "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", - ), - ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), - ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), - ("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"), - ("window.userSwatchLibrary4", "User Swatch Library 4", "", "{} open the 4. User Defined swatch library"), - ("window.userSwatchLibrary5", "User Swatch Library 5", "", "{} open the 5. User Defined swatch library"), - ("window.userSwatchLibrary6", "User Swatch Library 6", "", "{} open the 6. User Defined swatch library"), - ("window.userSwatchLibrary7", "User Swatch Library 7", "", "{} open the 7. User Defined swatch library"), - ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), - ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), - ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), - ( - "ui.mergeGraphicStyles", - "Merge Graphic Styles…", - "", - "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)", - ), - ( - "ui.layerOptions", - "Options for Selection…", - "", - "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step)", - ), - ( - "ui.newLayer", - "New Layer Options…", - "Cmd+Alt+L", - "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields", - ), - ( - "ui.newArtboard", - "New Artboard Options…", - "", - "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing", - ), - ( - "ui.layersPanelOptions", - "Panel Options…", - "", - "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, locateSelected (Locate Selected Objects, off by default: when the last selected object changes outside the panel, on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as after layer.locate, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off; a change made during a drag is located when the drag ends), rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)", - ), - ( - "ui.layersExpand", - "Expand Layers Panel Rows", - "", - "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}", - ), - ( - "ui.layersFilter", - "Filter Layers Panel Rows", - "", - "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}", - ), - ( - "ui.tileEdgeColor", - "Tile Edge Color…", - "", - "{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \"Light Blue\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode", - ), - ( - "ui.flattenTransparencyDialog", - "Flatten Transparency…", - "", - "{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step", - ), - ( - "ui.flattenerPresetsDialog", - "Transparency Flattener Presets…", - "", - "{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export", - ), - ( - "ui.flattenerPreview", - "Flattener Preview", - "", - "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it", - ), - ( - "window.graphicStyleLibrary", - "Graphic Style Library", - "", - "{library: id or name (see graphicStyle.libraries), or the id of a persistent panel's missing library | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}", - ), - ( - "window.graphicStyleLibrary.other", - "Other Library…", - "", - "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel", - ), - ( - "ui.saveGraphicStyleLibrary", - "Save Graphic Style Library…", - "", - "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", - ), - ( - "window.userGraphicStyleLibrary1", - "User Graphic Style Library 1", - "", - "{} open the 1. User Defined graphic style library (graphicStyle.libraries, category user)", - ), - ("window.userGraphicStyleLibrary2", "User Graphic Style Library 2", "", "{} open the 2. User Defined graphic style library"), - ("window.userGraphicStyleLibrary3", "User Graphic Style Library 3", "", "{} open the 3. User Defined graphic style library"), - ("window.userGraphicStyleLibrary4", "User Graphic Style Library 4", "", "{} open the 4. User Defined graphic style library"), - ("window.userGraphicStyleLibrary5", "User Graphic Style Library 5", "", "{} open the 5. User Defined graphic style library"), - ("window.userGraphicStyleLibrary6", "User Graphic Style Library 6", "", "{} open the 6. User Defined graphic style library"), - ("window.userGraphicStyleLibrary7", "User Graphic Style Library 7", "", "{} open the 7. User Defined graphic style library"), - ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), - ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), - ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), - ( - "window.symbolLibrary", - "Symbol Library", - "", - "{library: id or name (see symbol.libraries) | null (close)} open a symbol library in a library panel of its own (UI state `library_panels`, kind symbols); clicking a symbol there runs symbol.addFromLibrary, dragging one onto the canvas runs it with place → {open, panel, name, count}", - ), - ( - "window.symbolLibrary.other", - "Other Library…", - "", - "{path?} (default: pick a file) load the symbols of another document or a saved symbol library (engine: symbol.loadLibrary), and open them in a library panel", - ), - ( - "ui.saveSymbolLibrary", - "Save Symbols…", - "", - "{names?: [the symbol selected in the Symbols panel]} open Save Symbol Library (dialog `saveSymbolLibrary`: name, user: save to the user library folder, selectedOnly); OK runs symbol.saveLibrary", - ), - ("window.userSymbolLibrary1", "User Symbol Library 1", "", "{} open the 1. User Defined symbol library (symbol.libraries, category user)"), - ("window.userSymbolLibrary2", "User Symbol Library 2", "", "{} open the 2. User Defined symbol library"), - ("window.userSymbolLibrary3", "User Symbol Library 3", "", "{} open the 3. User Defined symbol library"), - ("window.userSymbolLibrary4", "User Symbol Library 4", "", "{} open the 4. User Defined symbol library"), - ("window.userSymbolLibrary5", "User Symbol Library 5", "", "{} open the 5. User Defined symbol library"), - ("window.userSymbolLibrary6", "User Symbol Library 6", "", "{} open the 6. User Defined symbol library"), - ("window.userSymbolLibrary7", "User Symbol Library 7", "", "{} open the 7. User Defined symbol library"), - ("window.userSymbolLibrary8", "User Symbol Library 8", "", "{} open the 8. User Defined symbol library"), - ("window.userSymbolLibrary9", "User Symbol Library 9", "", "{} open the 9. User Defined symbol library"), - ("window.userSymbolLibrary10", "User Symbol Library 10", "", "{} open the 10. User Defined symbol library"), - ( - "window.brushLibrary", - "Brush Library", - "", - "{library: id or name (see brush.libraries) | null (close)} open a brush library in a library panel of its own (UI state `library_panels`, kind brushes); clicking a brush there runs brush.addFromLibrary (with apply when paths are selected), dragging one onto a path runs it with apply on that path, dragging one onto the Brushes panel adds it → {open, panel, name, count}", - ), - ( - "window.brushLibrary.other", - "Other Library…", - "", - "{path?} (default: pick a file) load the brushes of another document or a saved brush library (engine: brush.loadLibrary), and open them in a library panel", - ), - ( - "ui.saveBrushLibrary", - "Save Brushes…", - "", - "{names?: [the brushes selected in the Brushes panel]} open Save Brush Library (dialog `saveBrushLibrary`: name, user: save to the user library folder, selectedOnly); OK runs brush.saveLibrary", - ), - ("window.userBrushLibrary1", "User Brush Library 1", "", "{} open the 1. User Defined brush library (brush.libraries, category user)"), - ("window.userBrushLibrary2", "User Brush Library 2", "", "{} open the 2. User Defined brush library"), - ("window.userBrushLibrary3", "User Brush Library 3", "", "{} open the 3. User Defined brush library"), - ("window.userBrushLibrary4", "User Brush Library 4", "", "{} open the 4. User Defined brush library"), - ("window.userBrushLibrary5", "User Brush Library 5", "", "{} open the 5. User Defined brush library"), - ("window.userBrushLibrary6", "User Brush Library 6", "", "{} open the 6. User Defined brush library"), - ("window.userBrushLibrary7", "User Brush Library 7", "", "{} open the 7. User Defined brush library"), - ("window.userBrushLibrary8", "User Brush Library 8", "", "{} open the 8. User Defined brush library"), - ("window.userBrushLibrary9", "User Brush Library 9", "", "{} open the 9. User Defined brush library"), - ("window.userBrushLibrary10", "User Brush Library 10", "", "{} open the 10. User Defined brush library"), - ( - "ui.expandDialog", - "Expand…", - "", - "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step", - ), - ( - "attributes.openUrl", - "Browser", - "", - "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}", - ), - ( - "ui.javascriptFilesDialog", - "Link JavaScript Files…", - "", - "{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected", - ), - ( - "ui.spotColors", - "Spot Colors…", - "", - "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step", - ), - ( - "ui.menuDialog", - "Menu Dialog", - "", - "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", - ), - ( - "ui.widthPointEdit", - "Width Point Edit…", - "", - "{id, index?: existing width point, t?: 0..1 for a new point, adjustAdjoining?: bool (false)} open Width Point Edit on path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking the stroke opens it too, Shift on a width point enabling adjoining adjustment): OK runs stroke.widthPoint.set; discard: true (Delete) removes an existing point or discards a prospective point", - ), - ( - "ui.corners", - "Corners…", - "", - "{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape", - ), - ( - "ui.simplifyAuto", - "Auto-Simplify", - "", - "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, opening at the level object.path.simplify {auto: true} picks for the selection, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open", - ), - ( - "ui.simplifyMoreOptions", - "More Options", - "", - "{} the Simplify dialog (dialog `simplify`: tolerance (pt), angleThreshold, straightLines, showOriginal, retain: ticked, OK makes Object › Path › Simplify open this dialog with these fields next time) takes the open Simplify slider's place, at the slider's tolerance → {tolerance}; an error when the slider isn't open", - ), - ( - "ui.justification", - "Justification…", - "", - "{} open Justification (the Paragraph panel menu's Justification…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, with Preview (dialog `justification`: wordMin, wordDesired, wordMax 0..1000, letterMin, letterDesired, letterMax -100..500, glyphMin, glyphDesired, glyphMax 50..200 (percent), autoLeading 0..500 (percent), singleWord (fullJustify|alignLeft|alignCenter|alignRight), preview): OK runs text.setFormat with the values that changed, as one undo step, and is disabled while a row's Minimum exceeds its Desired or Desired its Maximum; an error with no type selected", - ), - ( - "ui.hyphenation", - "Hyphenation…", - "", - "{} open Hyphenation (the Paragraph panel menu's Hyphenation…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows (dialog `hyphenation`: hyphenate, wordsLongerThan 1..25, afterFirst 1..15, beforeLast 1..15, hyphenLimit 0..25 (0: no limit), hyphenationZone (pt), hyphenateCapitalized): OK runs text.setFormat with the values that changed, as one undo step; an error with no type selected", - ), - ( - "ui.colorGuideLimit", - "Limit Color Guide to Library", - "", - "{library: swatch library id or name (see swatch.library.list) | \"document\" (the document's swatches) | \"\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}", - ), - ( - "ui.colorPanel", - "Color Panel Options", - "", - "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", - ), - ( - "ui.savePdfDialog", - "Save PDF Dialog", - "", - "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)", - ), - ( - "file.exportAs", - "Export As…", - "", - "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}", - ), - ( - "ui.fileInfoDialog", - "File Info Dialog", - "", - "{} open File Info (dialog `fileInfo`: the file.info fields, plus __keyword, keywords typed but not added yet); OK runs file.info. The File Info… menu item opens it too", - ), - ( - "ui.rasterEffectsSettingsDialog", - "Document Raster Effects Settings Dialog", - "", - "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too", - ), - ( - "ui.effectGalleryDialog", - "Effect Gallery Dialog", - "", - "{} open Effect Gallery (dialog `effectGallery`: browse raster filters, build a stack, OK applies it as separate effects in one undo step). Effect → Effect Gallery… opens it too", - ), - ( - "ui.pdfPresetsDialog", - "PDF Presets…", - "", - "{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export", - ), - ( - "ui.pdfPresetDialog", - "PDF Preset", - "", - "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets", - ), - ("file.openRecent11", "Open Recent File 11", "", "{}"), - ("file.openRecent12", "Open Recent File 12", "", "{}"), - ("file.openRecent13", "Open Recent File 13", "", "{}"), - ("file.openRecent14", "Open Recent File 14", "", "{}"), - ("file.openRecent15", "Open Recent File 15", "", "{}"), - ("file.openRecent16", "Open Recent File 16", "", "{}"), - ("file.openRecent17", "Open Recent File 17", "", "{}"), - ("file.openRecent18", "Open Recent File 18", "", "{}"), - ("file.openRecent19", "Open Recent File 19", "", "{}"), - ("file.openRecent20", "Open Recent File 20", "", "{}"), - ("file.openRecent21", "Open Recent File 21", "", "{}"), - ("file.openRecent22", "Open Recent File 22", "", "{}"), - ("file.openRecent23", "Open Recent File 23", "", "{}"), - ("file.openRecent24", "Open Recent File 24", "", "{}"), - ("file.openRecent25", "Open Recent File 25", "", "{}"), - ("file.openRecent26", "Open Recent File 26", "", "{}"), - ("file.openRecent27", "Open Recent File 27", "", "{}"), - ("file.openRecent28", "Open Recent File 28", "", "{}"), - ("file.openRecent29", "Open Recent File 29", "", "{}"), - ("file.openRecent30", "Open Recent File 30", "", "{}"), - ("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"), - ( - "file.cancelExport", - "Cancel Export", - "", - "{} stop every export running in the background (Background Export): an export stops between artboards and writes no file once cancelled → {cancelled: n}", - ), - ( - "file.exportInBackground", - "Continue in Background", - "", - "{} close the export progress dialog; the exports go on, shown in the status bar → {hidden: n}", - ), - ( - "ui.dxfOptionsDialog", - "DXF Options Dialog", - "", - "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", - ), - ( - "links.editOriginal", - "Edit Original", - "", - "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", - ), - ( - "links.reveal", - "Show in Folder", - "", - "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}", - ), - ( - "ui.placementOptionsDialog", - "Placement Options…", - "", - "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", - ), - ( - "ui.findLinksInFolder", - "Find Linked Files in Folder…", - "", - "{folder?, ids?: [image or placed document ids]} the Links panel's Find in Folder…: asks for a folder (or takes folder), then opens Find Linked Files (dialog `findLinks`) looking in it and its subfolders for the files of linked objects `ids` (default: the selected linked images and placed documents, else every missing file; links.findFiles). An error when there is nothing to look for. Disabled where there is no folder picker (the web)", - ), - ( - "ui.packageDialog", - "Package…", - "Cmd+Alt+Shift+P", - "{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage", - ), - ( - "ui.batchDialog", - "Batch…", - "", - "{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides", - ), - ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), - ( - "docInfo.save", - "Save Document Info…", - "", - "{path?, selectionOnly?} write Document Info's text report (document.info {format: \"text\"}) to path, else a picked file (the web downloads it) → {path}", - ), - ( - "ui.epsOptionsDialog", - "EPS Options Dialog", - "", - "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too", - ), - ( - "file.saveForWeb", - "Save for Web (Legacy)…", - "Cmd+Alt+Shift+S", - "{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}", - ), - ( - "file.saveForWeb.browser", - "Preview in Browser", - "", - "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}", - ), - ( - "file.exportSelection", - "Export Selection…", - "", - "{multiple?: false} collect the selected objects as assets (assets.add: one of them all, as one image; true: one per object) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead", - ), - ( - "css.copy", - "Copy Selected Style", - "", - "{scope?: selection|all (default selection), …css.selection options} copy the CSS of the selection (css.selection; all: css.generate) to the clipboard (CSS Properties panel) → {css, rules: n}", - ), - ( - "css.exportFile", - "Export CSS…", - "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", - ), - ( - "file.print", - "Print…", - "Cmd+P", - "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", - ), - ( - "print.printers", - "Printers", - "", - "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}", - ), - ("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"), - ( - "ui.printPresetsDialog", - "Print Presets…", - "", - "{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export", - ), - ( - "ui.printPresetDialog", - "Print Preset", - "", - "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets", - ), - ( - "plugin.dialog", - "Plug-in…", - "", - "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", - ), - ( - "ui.installPlugin", - "Install Plug-in…", - "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks", - ), - ( - "plugin.cancelRuns", - "Cancel Plug-in Run", - "", - "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", - ), - ( - "plugin.hideRuns", - "Continue in Background", - "", - "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}", - ), - ( - "ui.checkScript", - "Check Script…", - "", - "{path? | data? | dataBase64?, name?, includes?: true with a path} check a script written for the reference app without running it (script.check; with a path, the files its #include lines name are checked too) and show the report (dialog `scriptCheck`: `report`, script.check's result; Copy Report copies it as JSON); without a file pick one (the web opens the browser's file picker; File › Open of a .jsx, .jsxinc or .jsxbin file shows the report too)", - ), - ( - "ui.perspectiveGridDialog", - "Define Perspective Grid…", - "", - "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define", - ), - ( - "ui.perspectivePresetsDialog", - "Perspective Grid Presets…", - "", - "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*", - ), - ( - "ui.savePerspectivePreset", - "Save Grid as Preset…", - "", - "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields", - ), - ( - "ui.perspectiveUserPreset1.1", - "One Point Perspective Preset 1", - "", - "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset1.2", - "One Point Perspective Preset 2", - "", - "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset1.3", - "One Point Perspective Preset 3", - "", - "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset1.4", - "One Point Perspective Preset 4", - "", - "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset1.5", - "One Point Perspective Preset 5", - "", - "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.1", - "Two Point Perspective Preset 1", - "", - "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.2", - "Two Point Perspective Preset 2", - "", - "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.3", - "Two Point Perspective Preset 3", - "", - "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.4", - "Two Point Perspective Preset 4", - "", - "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.5", - "Two Point Perspective Preset 5", - "", - "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.1", - "Three Point Perspective Preset 1", - "", - "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.2", - "Three Point Perspective Preset 2", - "", - "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.3", - "Three Point Perspective Preset 3", - "", - "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.4", - "Three Point Perspective Preset 4", - "", - "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.5", - "Three Point Perspective Preset 5", - "", - "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.blendOptions", - "Blend Options…", - "", - "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with", - ), - ( - "ui.livePaintGapOptions", - "Gap Options…", - "", - "{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step", - ), - ( - "ui.perspectivePlane", - "Perspective Plane Options…", - "", - "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move", - ), -]; +mod bar; +mod context; +mod entries; +mod invoke; +mod labels; +mod run; +mod slots; +mod state; +mod submenus; +mod tree; +mod ui_commands; -/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label, -/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`), or a library panel's id -/// as it is (`library:swatches:pastels`). -pub(crate) fn normalize_panel(input: &str) -> Option<&'static str> { - let name = input.trim(); - crate::state::all_panels() - .find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)) - .map(|(id, _)| id) - .or_else(|| crate::panels::library_panel::intern(input)) -} - -/// An optional `{key?: bool}` param: none when it is omitted or null (the commands then toggle). -pub(crate) fn opt_bool(p: &Value, key: &str) -> Result, String> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::Bool(b)) => Ok(Some(*b)), - Some(_) => Err(format!("`{key}` must be true or false")), - } -} - -/// Handle a UI command. `None` = not a UI command (the engine handles it). -/// `params` has only keys the params description of engine command `id` names, or the parameter -/// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their -/// descriptions haven't been audited against what they read. -pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Value) -> Result<(), String> { - if UI_COMMANDS.iter().any(|c| c.0 == id) { - return Ok(()); - } - session.check_params(id, params).map_err(|e| e.to_string()) -} - -/// Runs `id` when the UI runs it itself: one of [`UI_COMMANDS`], or an engine command the UI -/// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the -/// engine. -pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { - // The Window menu's panel items toggle like its other checked items (#2361); the panel's - // other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it. - if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { - let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); - if app.ui.open_panel.as_deref() == Some(panel) { - app.ui.open_panel = None; - return Some(Ok(json!({"open": null}))); - } - return Some(app.run("window.panel.close", json!({"panel": panel}))); - } - if let Some(result) = crate::panel_docking::command(app, id, p) { - return Some(result); - } - if id == "app.language" { - let lang = p.get("lang").and_then(Value::as_str).unwrap_or(""); - let value = if lang.eq_ignore_ascii_case("auto") { - "auto".to_string() - } else if let Some(l) = crate::i18n::Lang::from_code(lang) { - l.code().to_string() - } else { - let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::all().map(|l| l.code())).collect(); - return Some(Err(format!("lang must be one of {}", codes.join(", ")))); - }; - return Some(app.run("prefs.set", json!({"key": "interfaceLanguage", "value": value})).map(|_| json!(value))); - } - if let Some(r) = crate::panels::character::intercept_text_command(app, id) { - return Some(r); - } - if let Some(r) = crate::shortcut_editor::run_command(app, id, p) - .or_else(|| crate::workspaces::run_command(app, id, p)) - .or_else(|| crate::edit_toolbar::command(app, id, p)) - { - return Some(r); - } - let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); - let flag = |b: &mut bool| { - *b = !*b; - Ok(json!(*b)) - }; - let r = match id { - "file.newDialog" => { - crate::dialogs::open_new_document(app); - Ok(Value::Null) - } - "file.newWithoutDialog" => crate::dialogs::new_document_without_dialog(app), - "app.home" => { - app.ui.home = Some(home_key(app)); - Ok(Value::Null) - } - "file.open" => match s("path") { - Some(path) => io::open_path(app, &path), - None => io::open_dialog(app).map(|_| Value::Null), - }, - // Saves write through the app (save panel, download); with no path known the panel and the - // format's options dialog ask first. - "file.save" => io::save(app, SaveMode::Save, p, true), - "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), - "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), - "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), - "document.exportSelection" => { - io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) - } - // Bytes sent by an agent go straight to the engine; a path or nothing opens it here. - "file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")), - "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), - "file.reveal" => io::reveal(app), - "file.cancelExport" => Ok(io::cancel_exports(app)), - "file.exportInBackground" => Ok(io::hide_exports(app)), - id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { - Some(path) => io::open_recent_reporting(app, &path), - None => Err("no such recent file".into()), - }, - "type.findFont" => { - crate::find_font::open(app); - Ok(Value::Null) - } - "ui.missingFontsDialog" => crate::dialogs::missing_fonts::open_command(app, s("folder")), - "ui.findFontsInFolder" => crate::dialogs::missing_fonts::find_in_folder_command(app, s("folder")), - "file.clearRecent" => { - app.ui.recent_files.clear(); - Ok(Value::Null) - } - "file.recentFiles" => Ok(json!(io::recent_files(app))), - "file.place" => crate::place::run(app, p), - "file.place.queue" => crate::place::queue(app, p), - "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { - crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None); - Ok(Value::Null) - } - "file.export.svg" => io::export(app, Some("svg"), s("path"), p), - "file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => { - crate::dialogs::open_export_for_screens(app); - Ok(Value::Null) - } - "file.exportForScreens" => io::export_for_screens(app, p.clone()), - "file.export.png" if s("path").is_none() => io::target_path(app, None, "png").and_then(|path| { - let f = vectorcraft_engine::cmd::fileio::format("png").ok_or("no PNG encoder")?; - let mut params = if p.is_object() { p.clone() } else { json!({}) }; - params["format"] = json!("png"); - params["path"] = json!(path); - crate::dialogs::open_raster_options(app, f, params); - Ok(Value::Null) - }), - "file.export.png" => io::export(app, Some("png"), s("path"), p), - "file.documentSetup" => crate::dialogs::open_document_setup(app), - "edit.preferences" => { - crate::prefs_dialog::open(app, s("category").as_deref()); - Ok(Value::Null) - } - // Edit → Color Settings… / Assign Profile… (no params): show the colour-management panel. - "edit.colorSettings" | "edit.assignProfile" if p.as_object().is_none_or(|o| o.is_empty()) => { - app.ui.open_panel = Some("separations".into()); - app.ui.dock = true; - app.session.execute(id, p).map_err(|e| e.to_string()) - } - "view.outline" => flag(&mut app.ui.view.outline), - "view.pixelPreview" => flag(&mut app.ui.view.pixel_preview), - "view.trimView" => flag(&mut app.ui.view.trim_view), - "view.cornerWidget" => flag(&mut app.ui.view.corner_widgets), - // New View: the engine stores the current zoom, centre and rotation under the given name. - "view.saved.new" if p.get("zoom").is_none() => { - let Some(v) = app.view().copied() else { return Some(Err("no document".into())) }; - let mut params = if p.is_object() { p.clone() } else { json!({}) }; - params["center"] = json!([v.center.x, v.center.y]); - params["zoom"] = json!(v.zoom); - params["rotation"] = json!(v.rotation); - app.session.execute(id, ¶ms).map_err(|e| e.to_string()) - } - // A saved view: its zoom, centre and rotation, in the current window. - id if saved_view_slot(id).is_some() => { - let saved = saved_view(app, id).and_then(|sv| crate::state::View::saved(sv).map(|to| (sv.name.clone(), to))); - match (saved, app.view_mut()) { - (Some((name, to)), Some(v)) => { - (v.center, v.zoom, v.rotation) = (to.center, to.zoom, to.rotation); - Ok(json!({ "name": name })) - } - _ => Err("no such view".into()), - } - } - id if saved_selection_slot(id).is_some() => match saved_selection_name(app, id) { - Some(name) => app.run("select.recall", json!({ "name": name })), - None => Err("no such saved selection".into()), - }, - "view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel), - "view.snapToGlyph" => flag(&mut app.ui.view.snap_to_glyph), - // The preference itself (General › Use Precise Cursors), so the command, the dialog and the saved - // preferences share one value. - "view.preciseCursors" => { - let on = !app.session.prefs.use_precise_cursors; - app.run("prefs.set", json!({"key": "usePreciseCursors", "value": on})).map(|_| { - crate::prefs_dialog::snapshot(app); - json!(on) - }) - } - "view.textThreads" => flag(&mut app.ui.view.text_threads), - "type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars), - "type.bold" => crate::panels::character::toggle_face(app, Face::Bold), - "type.italic" => crate::panels::character::toggle_face(app, Face::Italic), - id if let Some((_, justify)) = crate::panels::paragraph::ALIGN_COMMANDS.iter().find(|c| c.0 == id) => { - crate::panels::paragraph::align(app, justify) - } - "type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p), - "type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p), - "type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app), - id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b), - "view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator), - "view.livePaintGaps" => flag(&mut app.ui.view.live_paint_gaps), - "effect.last" => match app.last_effect.clone() { - Some((e, params)) => { - let label = vectorcraft_effects::effect_info(&e).map(|i| i.label.trim_end_matches('…').to_string()).unwrap_or_else(|| e.clone()); - let mut fields = params.as_object().cloned().unwrap_or_default(); - fields.insert("__effect".into(), json!(e)); - fields.insert("__label".into(), json!(label)); - fields.insert("preview".into(), json!(true)); - app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields }); - Ok(Value::Null) - } - None => Err("no effect applied yet".into()), - }, - id if id.starts_with("type.recentFont") => { - let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); - match n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned() { - Some(font) => app.run("text.setStyle", json!({ "font": font })), - None => Err("no such recent font".into()), - } - } - "view.edges" => flag(&mut app.ui.view.edges), - "view.artboards" => flag(&mut app.ui.view.artboards), - "view.template" => flag(&mut app.ui.view.templates), - "view.rulers" => flag(&mut app.ui.view.rulers), - "view.videoRulers" => { - let v = &mut app.ui.view; - let show = !(v.video_rulers && v.rulers); - v.video_rulers = show; - v.rulers |= show; - Ok(json!(show)) - } - "view.boundingBox" => flag(&mut app.ui.view.bounding_box), - "view.guides" => { - let r = flag(&mut app.ui.view.guides); - // Hidden ruler guides and guide paths cannot stay selected: their selection overlay - // would otherwise reveal geometry that Hide Guides just hid. - let hidden_selection = !app.ui.view.guides - && app.session.active().is_some_and(|d| { - !d.selection.guides.is_empty() - || d.selection - .objects - .iter() - .any(|id| matches!(d.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Path { guide: true, .. }))) - }); - if hidden_selection { - // Deselecting a document that is open cannot fail. - let _ = app.run("select.none", json!({})); - } - r - } - "view.smartGuides" => flag(&mut app.ui.view.smart_guides), - "view.grid" => flag(&mut app.ui.view.grid), - "view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid), - "view.snapToPoint" => flag(&mut app.ui.view.snap_to_point), - "view.zoomIn" | "view.zoomOut" => { - let up = id == "view.zoomIn"; - let print = app.session.prefs.display_print_size; - // Selection & Anchor Display › Zoom to Selection: the selection comes to the middle. - let focus = if app.session.prefs.zoom_to_selection { app.selection_box().map(|b| b.center()) } else { None }; - match app.view_mut() { - Some(v) => { - v.zoom = next_zoom(v.zoom, up); - if let Some(c) = focus { - v.center = c; - } - Ok(json!({"zoom": crate::state::zoom_display_percent(v.zoom, print)})) - } - None => Err("no document".into()), - } - } - "view.setZoom" => { - let print = app.session.prefs.display_print_size; - let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print); - let center = - p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); - match app.view_mut() { - Some(v) => { - v.zoom = z.clamp(0.0313, 640.0); - v.fitted = true; - if let Some(c) = center { - v.center = c; - } - Ok(Value::Null) - } - None => Err("no document".into()), - } - } - "view.fitArtboard" | "view.fitAll" | "view.actualSize" => { - crate::canvas::fit(app, id); - Ok(Value::Null) - } - "view.goToArtboard" => go_to_artboard(app, p), - "view.presentation" => { - app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 }; - Ok(json!(app.ui.screen_mode)) - } - "view.screenMode" => { - app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { - Some(m) => m.min(3) as u8, - // F cycles the three screen modes. Without a mode, the command also goes from - // Presentation Mode (not in the cycle) back to Normal. - None if app.ui.screen_mode >= 2 => 0, - None => app.ui.screen_mode + 1, - }; - Ok(json!(app.ui.screen_mode)) - } - // View › Rotate View and the status bar's angle menu: the view turns about the center of the - // canvas to the angle, normalized as a Rotate View drag normalizes it. - "view.rotate" => { - let key = "angle"; - let Some(angle) = p.get(key).and_then(Value::as_f64).filter(|a| a.is_finite()) else { - return Some(Err(format!("`{key}` must be a number"))); - }; - match app.view_mut() { - Some(v) => { - v.rotation = vectorcraft_geom::normalize_deg(angle); - Ok(json!({ "angle": v.rotation })) - } - None => Err("no document".into()), - } - } - // View › Rotate View to Selection: the view turns about the center of the canvas to the angle - // of the selection's bounding box, so the box is square to the window. - "view.rotateToSelection" => { - let Some(st) = app.session.active() else { return Some(Err("no document".into())) }; - if st.selection.objects.is_empty() { - return Some(Err("nothing selected".into())); - } - let angle = vectorcraft_geom::normalize_deg(st.doc.bbox_angle(&st.selection.objects)); - match app.view_mut() { - Some(v) => { - v.rotation = angle; - Ok(json!({ "angle": angle })) - } - None => Err("no document".into()), - } - } - "view.rotateReset" => { - if let Some(v) = app.view_mut() { - v.rotation = 0.0; - } - Ok(Value::Null) - } - "window.control" => flag(&mut app.ui.control_bar), - "window.toolbar" => flag(&mut app.ui.toolbar), - "window.toolbarColumns" => opt_bool(p, "double").map(|on| { - let on = on.unwrap_or(!app.ui.toolbar_double); - app.ui.toolbar_double = on; - json!(on) - }), - "window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced), - "imageTrace.preview" => opt_bool(p, "on").map(|on| { - let on = on.unwrap_or(!app.ui.image_trace_preview); - app.ui.image_trace_preview = on; - json!(on) - }), - "window.floatTools" => { - let floating = match opt_bool(p, "floating") { - Ok(on) => on, - Err(e) => return Some(Err(e)), - }; - let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) }; - crate::toolbar::float_group(app, &tool, floating).map(Value::Bool) - } - "window.taskBar" => flag(&mut app.ui.task_bar), - "window.taskBar.pin" => { - let place = &mut app.ui.task_bar_place; - opt_bool(p, "pinned").map(|pinned| { - let pinned = pinned.unwrap_or(!place.pinned); - // Pinning holds the bar where it shows; unpinning lets it follow the selection from - // there (the bar turns its pinned spot into an offset when it is next drawn). - if pinned && !place.pinned { - place.pin_at = place.shown_at; - } - place.pinned = pinned; - json!(pinned) - }) - } - "window.taskBar.reset" => { - app.ui.task_bar_place = Default::default(); - Ok(Value::Null) - } - "window.dock" => { - let ui = &mut app.ui; - // The bars the last Tab hid show again, unless the toolbar or the Control bar was shown - // another way since then (Window › Tools, Window › Control). Panels shown with Shift+Tab - // in between stay shown. Switching workspaces clears the record (`workspaces::apply`). - let hid = ui.tab_hid.take().filter(|h| (h.dock || h.toolbar || h.control_bar) && !ui.toolbar && !ui.control_bar); - let on = if let Some(h) = hid { - ui.dock |= h.dock; - ui.toolbar = h.toolbar; - ui.control_bar = h.control_bar; - true - } else if ui.dock || ui.toolbar || ui.control_bar { - ui.tab_hid = Some(crate::state::TabHid { dock: ui.dock, toolbar: ui.toolbar, control_bar: ui.control_bar }); - ui.dock = false; - ui.toolbar = false; - ui.control_bar = false; - ui.open_panel = None; - false - } else { - ui.dock = true; - ui.toolbar = true; - ui.control_bar = true; - true - }; - Ok(json!(on)) - } - "window.panels" => { - let on = !app.ui.dock; - app.ui.dock = on; - if !on { - app.ui.open_panel = None; - } - Ok(json!(on)) - } - "window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) { - Some(b) => { - app.ui.brightness = b; - app.session.prefs.ui_brightness = b.id().into(); - app.canvas.key = None; - Ok(json!(b.id())) - } - None => Err("brightness must be dark|mediumDark|mediumLight|light".into()), - }, - "window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()), - "tool.select" => match s("tool") { - Some(t) if vectorcraft_tools::tool_info(&t).is_some() => { - app.select_tool(&t); - Ok(json!({"tool": app.session.tool_id()})) - } - Some(t) => Err(format!("unknown tool `{t}`")), - None => Err("missing `tool`".into()), - }, - "tool.setOption" => app.session.set_tool_option_cmd(p), - "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), - "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), - "ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()), - "ui.cropImage" => { - app.select_tool(vectorcraft_tools::cropimage::ID); - Ok(json!({"tool": app.session.tool_id()})) - } - "ui.paramDialog" => { - let cmd = s("command").unwrap_or_default(); - let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); - fields.insert("__command".into(), json!(cmd)); - fields.insert("__label".into(), json!(s("label").unwrap_or(cmd.clone()))); - app.ui.dialog = Some(crate::state::Dialog { kind: "command".into(), fields }); - Ok(Value::Null) - } - "effect.applyLast" => match app.last_effect.clone() { - Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})), - None => Err("no effect applied yet".into()), - }, - "file.export.pdf" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::open_save_pdf(app, &json!({})), - "file.export.pdf" => io::export_pdf(app, p.clone()), - "help.about" => { - app.ui.about = true; - Ok(Value::Null) - } - "help.checkForUpdates" => crate::updates_ui::check(app), - "help.commandPalette" => { - app.ui.palette_open = !app.ui.palette_open; - app.ui.palette_query.clear(); - Ok(Value::Null) - } - // Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`). - "file.close" => { - let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index())?; - crate::unsaved::close(app, i) - } - "file.closeAll" => crate::unsaved::close_all(app, "closeAll"), - "app.quit" => crate::unsaved::close_all(app, "quit"), - "ui.swatchOptions" => match s("name") { - Some(name) => crate::dialogs::swatch_options::open(app, &name), - None => Err("missing `name`".into()), - }, - "ui.newSwatch" => { - let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); - crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref()) - } - "ui.newColorGroup" => { - let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::new_color_group::open(app, names.unwrap_or_default()) - } - "ui.colorPicker" => crate::dialogs::open_color_picker(app, p), - "ui.sampleScreenColor" => crate::screen_color::start(app, p), - "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), - "ui.symbolOptions" => { - let name = s("name"); - let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false); - if edit || name.is_some() { - crate::dialogs::symbol_options::open_edit(app, name.as_deref()) - } else { - crate::dialogs::symbol_options::open(app) - } - } - "ui.newBrush" => crate::dialogs::new_brush::open(app, p.get("ids").cloned()), - "ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()), - "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), - "ui.colorGuideOptions" => { - crate::dialogs::color_guide_options::open(app); - Ok(Value::Null) - } - "ui.colorBalanceDialog" => { - crate::dialogs::color_balance::open(app); - Ok(Value::Null) - } - "ui.saturateDialog" => { - crate::dialogs::saturate::open(app); - Ok(Value::Null) - } - "window.swatchLibrary" => crate::panels::swatches::open_library(app, p), - "window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")), - "ui.saveSwatchLibrary" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::save_swatch_library::open(app, names.unwrap_or_default()) - } - id if id.starts_with(crate::panels::swatches::USER_SLOT) => match crate::panels::swatches::user_library(app, id) { - Some(l) => crate::panels::swatches::open_library(app, &json!({ "library": l.id })), - None => Err("no such user library".into()), - }, - "ui.mergeGraphicStyles" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::graphic_style_options::open_merge(app, names.unwrap_or_default()) - } - "ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app), - "ui.layerOptions" => crate::dialogs::layer_options::open(app, p), - "ui.newLayer" => crate::dialogs::layer_options::open_new(app, p), - "ui.newArtboard" => crate::dialogs::artboard_options::open_new(app), - "ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app), - "ui.layersExpand" => crate::panels::layers::expand(app, p), - "ui.layersFilter" => crate::panels::layers::set_filter(app, p), - "ui.flattenTransparencyDialog" => { - crate::dialogs::flatten::open(app); - Ok(Value::Null) - } - "ui.flattenerPresetsDialog" => { - crate::dialogs::flattener_presets::open(app, s("selected").as_deref()); - Ok(Value::Null) - } - "ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p), - "window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p), - "ui.libraryPanel" => crate::panels::library_panel::command(app, p), - "ui.libraryPanel.locate" => crate::panels::library_panel::locate(app, p), - "window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")), - "ui.saveGraphicStyleLibrary" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::save_style_library::open(app, names.unwrap_or_default()) - } - id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) { - Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), - None => Err("no such user library".into()), - }, - "window.symbolLibrary" => crate::panels::symbols::open_library(app, p), - "window.symbolLibrary.other" => crate::panels::symbols::other_library(app, s("path")), - "ui.saveSymbolLibrary" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::save_symbol_library::open(app, names.unwrap_or_default()) - } - id if id.starts_with(crate::panels::symbols::USER_SLOT) => match crate::panels::symbols::user_library(app, id) { - Some(l) => crate::panels::symbols::open_library(app, &json!({ "library": l.id })), - None => Err("no such user library".into()), - }, - "window.brushLibrary" => crate::panels::brushes::open_library(app, p), - "window.brushLibrary.other" => crate::panels::brushes::other_library(app, s("path")), - "ui.saveBrushLibrary" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::save_brush_library::open(app, names.unwrap_or_default()) - } - id if id.starts_with(crate::panels::brushes::USER_SLOT) => match crate::panels::brushes::user_library(app, id) { - Some(l) => crate::panels::brushes::open_library(app, &json!({ "library": l.id })), - None => Err("no such user library".into()), - }, - "ui.expandDialog" => crate::dialogs::expand::open(app), - "ui.simplifyAuto" => crate::dialogs::simplify_slider::auto(app), - "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::more_options(app), - "attributes.openUrl" => crate::panels::attributes::open_url(app, p), - "ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p), - "ui.spotColors" => crate::dialogs::spot_colors::open(app), - "ui.menuDialog" => match s("command").as_deref() { - Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), - c => match c.and_then(|c| menu_dialog(app, c)) { - Some((kind, fields)) => { - app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); - Ok(Value::Null) - } - None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), - }, - }, - "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), - "ui.corners" => crate::dialogs::corners::open(app, p), - "ui.hyphenation" => crate::dialogs::hyphenation::open(app), - "ui.justification" => crate::dialogs::justification::open(app), - "ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p), - "ui.colorPanel" => crate::panels::color::command(app, p), - "ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p), - "file.exportAs" if s("path").is_none() => { - crate::dialogs::open_export_as(app, s("format").as_deref(), p); - Ok(Value::Null) - } - "file.exportAs" => io::export(app, s("format").as_deref(), s("path"), p), - "ui.fileInfoDialog" => crate::dialogs::file_info::open(app), - "ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app), - "ui.effectGalleryDialog" => crate::dialogs::effect_gallery::open(app, p), - "ui.pdfPresetsDialog" => { - crate::dialogs::pdf_presets::open(app, s("selected").as_deref()); - Ok(Value::Null) - } - "ui.pdfPresetDialog" => crate::dialogs::open_pdf_preset(app, p), - // Save for Office Documents…: its dialog; with options, pick the file (or write `path`). - "document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app), - "document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })), - "ui.dxfOptionsDialog" if app.session.active().is_none() => Err("no document".into()), - "ui.dxfOptionsDialog" => { - crate::dialogs::dxf_options::open(app, p); - Ok(Value::Null) - } - "links.editOriginal" => crate::panels::links::open_file(app, p, false), - "links.reveal" => crate::panels::links::open_file(app, p, true), - "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), - "ui.findLinksInFolder" => { - crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned()) - } - "ui.packageDialog" => crate::dialogs::package::open(app), - "ui.batchDialog" => { - let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; - let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; - crate::dialogs::action_batch::open(app, set, action) - } - "ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p), - "file.showPackage" => crate::dialogs::package::show_package(app, p), - "docInfo.save" => crate::panels::doc_info::save_report(app, p), - // Slice Options… and Divide Slices…: their dialogs, on the selected slices. - "object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_options(app), - "object.slice.divide" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_divide(app), - "ui.epsOptionsDialog" if app.session.active().is_none() => Err("no document".into()), - "ui.epsOptionsDialog" => { - crate::dialogs::eps_options::open(app, p); - Ok(Value::Null) - } - // Save for Web: its dialog; with settings, write the files. - "file.saveForWeb" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::save_for_web::open(app), - "file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()), - "file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p), - // File → Export Selection…: the selection becomes one asset (`multiple: true`: one per - // object), shown checked on Export for Screens' Assets tab. - "file.exportSelection" => { - app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| { - let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); - crate::dialogs::open_export_for_screens_assets(app, Some(&ids)); - json!({ "assets": ids }) - }) - } - "css.copy" => crate::panels::css_properties::copy(app, p), - "css.exportFile" => crate::panels::css_properties::export_file(app, p), - // File → Print…: its dialog; with params, print (or save the job as a PDF) without it. - "file.print" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::print::open(app), - "file.print" => crate::print::run(app, p), - "print.printers" => Ok(crate::print::printers(app)), - "print.printerSetup" => { - let printer = s("printer").filter(|n| !n.is_empty()); - match app.services.print.as_mut() { - Some(service) => service.setup(printer.as_deref()).map(|_| Value::Null), - None => Err("printing isn't available here".into()), - } - } - "ui.printPresetsDialog" => { - crate::dialogs::print_presets::open(app, s("selected").as_deref()); - Ok(Value::Null) - } - "ui.printPresetDialog" => crate::dialogs::print::open_preset(app, p), - "plugin.dialog" => crate::dialogs::plugin::open(app, p), - "ui.installPlugin" => io::install_plugin(app, p), - "plugin.cancelRuns" => Ok(crate::plugin_runs::cancel_all(app)), - "plugin.hideRuns" => Ok(crate::plugin_runs::hide_all(app)), - "ui.checkScript" => crate::dialogs::script_check::command(app, p), - "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), - "ui.perspectivePresetsDialog" => { - crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); - Ok(Value::Null) - } - "ui.savePerspectivePreset" => crate::dialogs::perspective_grid::open_save(app), - id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => match crate::dialogs::perspective_presets::slot_preset(app, id) { - Some(name) => app.run("perspective.grid.preset", json!({ "name": name })), - None => Err("no such perspective grid preset".into()), - }, - "ui.blendOptions" => crate::dialogs::blend_options::open(app), - "ui.livePaintGapOptions" => crate::dialogs::live_paint_gaps::open(app), - "ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p), - _ => return None, - }; - Some(r) -} - -/// `view.goToArtboard`: make artboard `index` (a number, or first / previous / next / last from -/// the current one) active, synchronizing the Artboard tool when in use, and fit it in the window. -fn go_to_artboard(app: &mut VectorcraftApp, p: &Value) -> Result { - let n = app.session.active().map(|d| d.doc.artboards.len()).ok_or("no document open")?; - let last = n.checked_sub(1).ok_or("the document has no artboards")?; - let current = app.view().map_or(0, |v| v.artboard).min(last); - let to = match p.get("index") { - Some(Value::String(s)) => match s.as_str() { - "first" => 0, - "previous" => current.saturating_sub(1), - "next" => current + 1, - "last" => last, - other => return Err(format!("index must be a number or first, previous, next or last, not `{other}`")), - }, - Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("index must be a number or first, previous, next or last")?, - None => return Err("missing index".into()), - } - .min(last); - crate::panels::artboards::select(app, to); - crate::canvas::fit(app, "view.fitArtboard"); - Ok(json!({ "index": to })) -} - -/// Checked state for toggle items. -pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { - if id == "app.language" { - let lang = p.get("lang").and_then(Value::as_str).unwrap_or(""); - let pref = app.session.prefs.interface_language.as_str(); - return Some(if lang.eq_ignore_ascii_case("auto") { - crate::i18n::Lang::from_code(pref).is_none() - } else { - crate::i18n::Lang::from_code(pref).is_some_and(|l| l.code().eq_ignore_ascii_case(lang)) - }); - } - let v = &app.ui.view; - // An item that can be on or off is `Some` whatever the document and selection (off with none): - // the macOS menu bar can't change an item's kind without rebuilding. - // The first type object (of type on a path, with `onpath`) among the selected objects and the - // objects inside them, in the order the type commands act on them. - let selected_text = |onpath: bool| { - let st = app.session.active()?; - let mut found = None; - for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { - n.walk(&mut |c| { - if found.is_none() - && let vectorcraft_engine::doc::NodeKind::Text(t) = &c.kind - && (!onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. })) - { - found = Some(t); - } - }); - if found.is_some() { - break; - } - } - found - }; - Some(match id { - "type.orientation.vertical" | "type.orientation.horizontal" => { - selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical")) - } - // Checked when the first type object `selected_text` finds has it on. - "type.opticalMarginAlignment" => selected_text(false).is_some_and(|t| t.optical_margin), - "view.outline" => v.outline, - "view.pixelPreview" => v.pixel_preview, - "view.trimView" => v.trim_view, - "view.snapToPixel" => v.snap_to_pixel, - "view.snapToGlyph" => v.snap_to_glyph, - // Type on a Path effect of the selected path type. - "type.pathOptions" if p.get("effect").is_some() => { - selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())) - } - "view.smartGuides" => v.smart_guides, - "view.grid" => v.grid, - "view.snapToGrid" => v.snap_to_grid, - "view.snapToPoint" => v.snap_to_point, - "view.rulers" => v.rulers, - // A View › Rotate View item is checked when the active view is turned to its angle; without - // a document no item is checked. - "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), - "window.control" => app.ui.control_bar, - "window.toolbar" => app.ui.toolbar, - "window.toolbarAdvanced" => app.ui.toolbar_advanced, - "window.toolbarColumns" => app.ui.toolbar_double, - "window.editToolbar" => app.ui.tools_drawer, - "window.floatTools" => { - let tool = p.get("tool").and_then(Value::as_str).unwrap_or(""); - app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool)) - } - "window.taskBar" => app.ui.task_bar, - "window.taskBar.pin" => app.ui.task_bar_place.pinned, - "window.panel" => { - let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); - normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id)) - } - "window.collapseDock" => crate::panel_docking::dock_collapsed(&app.ui), - "window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), - "window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness), - "view.slices.lock" => app.session.slices_locked(), - "object.slice.clipToArtboard" => app.session.active().is_some_and(|d| d.doc.slices_clip_to_artboard), - "view.proofColors" => vectorcraft_render::proof::view().proof_colors, - "view.overprintPreview" => vectorcraft_render::proof::view().overprint, - "view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()), - "perspective.grid.snap" => perspective_grid(app).is_some_and(|g| g.snap), - "perspective.grid.lockStation" => perspective_grid(app).is_some_and(|g| g.lock_station), - "file.documentColorMode" | "object.convertDocumentColorMode" => { - let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk); - p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" }) - } - // The ruler context menu lists the document units, the current one checked. - "document.setUnits" => { - p.get("units").and_then(Value::as_str).and_then(vectorcraft_engine::doc::Unit::named) == Some(app.session.general_unit()) - } - _ => return None, - }) -} - -/// The active document's perspective grid (View → Perspective Grid toggles). -fn perspective_grid(app: &VectorcraftApp) -> Option { - app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc)) -} - -/// Label for toggles whose text flips (Outline/Preview, Hide/Show …). -pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { - let v = &app.ui.view; - match id { - "view.outline" => if v.outline { "Preview" } else { "Outline" }.into(), - "window.workspace.reset" => format!("Reset {}", app.ui.workspace), - "view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(), - "view.template" => if v.templates { "Hide Template" } else { "Show Template" }.into(), - "view.cornerWidget" => if v.corner_widgets { "Hide Corner Widget" } else { "Show Corner Widget" }.into(), - "view.textThreads" => if v.text_threads { "Hide Text Threads" } else { "Show Text Threads" }.into(), - "type.hiddenCharacters" => if v.hidden_chars { "Hide Hidden Characters" } else { "Show Hidden Characters" }.into(), - "view.gradientAnnotator" => if v.gradient_annotator { "Hide Gradient Annotator" } else { "Show Gradient Annotator" }.into(), - "view.livePaintGaps" => if v.live_paint_gaps { "Hide Live Paint Gaps" } else { "Show Live Paint Gaps" }.into(), - "effect.last" => match &app.last_effect { - Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())), - None => label.into(), - }, - id if id.starts_with("type.recentFont") => { - let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); - n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into()) - } - id if saved_view_slot(id).is_some() => saved_view(app, id).map(|v| v.name.clone()).unwrap_or_else(|| "—".into()), - id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()), - "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), - "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), - "view.videoRulers" => if v.video_rulers && v.rulers { "Hide Video Rulers" } else { "Show Video Rulers" }.into(), - "view.globalRulers" => if app.session.active().is_some_and(|d| d.doc.rulers == vectorcraft_doc::RulerMode::Global) { - "Change to Artboard Rulers" - } else { - "Change to Global Rulers" - } - .into(), - "view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(), - "view.transparencyGrid" => { - if app.session.active().is_some_and(|d| d.transparency_grid) { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into() - } - "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), - "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), - "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), - "view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(), - "view.printTiling" => if app.session.active().is_some_and(|d| d.print_tiling) { "Hide Print Tiling" } else { "Show Print Tiling" }.into(), - id if id.starts_with("file.openRecent") => recent_slot(app, id) - .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) - .unwrap_or_else(|| "—".into()), - // Edit Contents reads Edit Envelope while an envelope's contents are being edited. - "object.envelope.editContents" => { - let editing = app.session.active().is_some_and(|st| { - st.selection - .objects - .first() - .and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id)) - .is_some_and(|e| matches!(e.kind, vectorcraft_doc::NodeKind::Envelope { editing: true, .. })) - }); - if editing { "Edit Envelope" } else { "Edit Contents" }.into() - } - "select.globalEdit" => { - if app.session.active().is_some_and(|d| d.global_edit.is_some()) { "Stop Global Edit" } else { "Start Global Edit" }.into() - } - "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), - "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), - id if id.starts_with(crate::panels::swatches::USER_SLOT) => { - crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { - crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::panels::symbols::USER_SLOT) => { - crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::panels::brushes::USER_SLOT) => { - crate::panels::brushes::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { - crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into()) - } - "perspective.grid.show" => if perspective_grid(app).is_some_and(|g| g.visible) { "Hide Grid" } else { "Show Grid" }.into(), - "perspective.grid.rulers" => if perspective_grid(app).is_some_and(|g| g.rulers) { "Hide Rulers" } else { "Show Rulers" }.into(), - "perspective.grid.lock" => if perspective_grid(app).is_some_and(|g| g.locked) { "Unlock Grid" } else { "Lock Grid" }.into(), - _ => label.into(), - } -} - -/// Does the menu item `id` show a name that is user, file or system data rather than an interface -/// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a -/// plug-in? Such names are shown as they are, never translated (a workspace the user calls -/// "Layers" stays "Layers"). -fn shows_a_name(id: &str, label: &str) -> bool { - const SLOTS: [&str; 8] = [ - "file.openRecent", - "view.goto", - "type.recentFont", - crate::panels::swatches::USER_SLOT, - crate::panels::graphic_styles::USER_SLOT, - crate::panels::symbols::USER_SLOT, - crate::panels::brushes::USER_SLOT, - crate::dialogs::perspective_presets::SLOT, - ]; - SLOTS.iter().any(|s| id.starts_with(s)) - || saved_selection_slot(id).is_some() - || matches!(id, "text.setStyle" | "plugin.dialog") - || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) - || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) -} - -/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or -/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds -/// while another thread installs or removes plug-ins. -pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { - id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() -} - -/// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled -/// around a name (Undo *Move*, Reset *Essentials*, Last Effect: *Drop Shadow*) are translated as -/// templates so the name can move; names that are user data (files, views, fonts, custom -/// workspaces, plug-ins: [`shows_a_name`]) pass through. -pub fn display_label(app: &VectorcraftApp, id: &str, label: &str) -> String { - let lang = crate::i18n::current(); - let fmt = crate::i18n::fmt; - match id { - "edit.undo" | "edit.redo" => { - let last = app.session.active().and_then(|d| if id == "edit.undo" { d.history.undo.last() } else { d.history.redo.last() }); - // The step's name as the History panel lists it. - let name = crate::panels::history::shown; - match last { - Some(h) if id == "edit.undo" => fmt(tl!("Undo {name}"), &[("name", &name(h))]), - Some(h) => fmt(tl!("Redo {name}"), &[("name", &name(h))]), - None => tl!(if id == "edit.undo" { "Undo" } else { "Redo" }).to_string(), - } - } - "window.workspace.reset" => { - let name = &app.ui.workspace; - fmt(tl!("Reset {name}"), &[("name", if crate::workspaces::is_builtin(name) { tl!(name) } else { name })]) - } - "effect.last" => match &app.last_effect { - Some((e, _)) => { - let name = vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str()); - fmt(tl!("Last Effect: {name}"), &[("name", crate::i18n::tr_id(lang, e, name))]) - } - None => crate::i18n::tr_id(lang, id, label).to_string(), - }, - _ => { - let shown = dynamic_label(app, id, label); - if shows_a_name(id, &shown) { shown } else { crate::i18n::tr_id(lang, id, &shown).to_string() } - } - } -} - -/// The label of an Effect menu item in the UI language, by the effect's own id. Every effect item -/// runs `effect.apply` or `effect.dialog`, so the command id can't tell them apart; an `@id` row -/// for the effect id (for example `pathfinder.add`) wins over the plain row of the English label. -/// `None` when the catalog has no such row, and the plain translation applies. -fn effect_item_label(id: &str, p: &Value) -> Option { - if !matches!(id, "effect.apply" | "effect.dialog") { - return None; - } - let effect = p.get("effect")?.as_str()?; - let shown = crate::i18n::tr_id(crate::i18n::current(), effect, ""); - (!shown.is_empty()).then(|| shown.to_string()) -} - -/// The label of an item of the canvas context menu's Unlock submenu (`layer.setProps` with one id -/// and `locked: false`): the object's name as the Layers panel shows it in `lang` -/// ([`crate::panels::layers::shown_name`]). `None` for any other item, or when the object is gone. -fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: crate::i18n::Lang) -> Option { - if id != "layer.setProps" || p.get("locked") != Some(&Value::Bool(false)) { - return None; - } - let [one] = p.get("ids")?.as_array()?.as_slice() else { return None }; - let n = app.session.active()?.doc.node(vectorcraft_doc::NodeId(one.as_u64()?))?; - Some(crate::panels::layers::shown_name(app, n, lang)) -} - -/// Menu slots that are left out while they have nothing to show (unused saved views, recent files, -/// User Defined swatch and graphic style libraries). -fn hidden_when_disabled(id: &str) -> bool { - id.starts_with("view.goto") - || saved_selection_slot(id).is_some() - || id.starts_with("file.openRecent") - || id.starts_with(crate::panels::swatches::USER_SLOT) - || id.starts_with(crate::panels::graphic_styles::USER_SLOT) - || id.starts_with(crate::panels::symbols::USER_SLOT) - || id.starts_with(crate::panels::brushes::USER_SLOT) - || id.starts_with(crate::dialogs::perspective_presets::SLOT) -} - -/// Menu items another item stands in for right now: Envelope Distort's Reset with Warp and Reset -/// with Mesh take the place of Make with Warp and Make with Mesh while an envelope is selected. -fn swapped_out(app: &VectorcraftApp, id: &str) -> bool { - let reset = match id { - "object.envelope.makeWithWarp" | "object.envelope.makeWithMesh" => false, - "object.envelope.resetWithWarp" | "object.envelope.resetWithMesh" => true, - _ => return false, - }; - enabled(app, "object.envelope.release") != reset -} - -/// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them). -const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [ - "file.openRecent1", - "file.openRecent2", - "file.openRecent3", - "file.openRecent4", - "file.openRecent5", - "file.openRecent6", - "file.openRecent7", - "file.openRecent8", - "file.openRecent9", - "file.openRecent10", - "file.openRecent11", - "file.openRecent12", - "file.openRecent13", - "file.openRecent14", - "file.openRecent15", - "file.openRecent16", - "file.openRecent17", - "file.openRecent18", - "file.openRecent19", - "file.openRecent20", - "file.openRecent21", - "file.openRecent22", - "file.openRecent23", - "file.openRecent24", - "file.openRecent25", - "file.openRecent26", - "file.openRecent27", - "file.openRecent28", - "file.openRecent29", - "file.openRecent30", -]; - -/// The saved selection a `select.recallN` slot names (none past the document's last). -fn saved_selection_name(app: &VectorcraftApp, id: &str) -> Option { - let n = saved_selection_slot(id)?.checked_sub(1)?; - app.session.active()?.doc.saved_selections.get(n).map(|x| x.name.clone()) -} - -/// The recent file a `file.openRecentN` slot names (none past the preference's count). -fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> { - let n: usize = id.strip_prefix("file.openRecent")?.parse().ok()?; - io::recent_files(app).get(n.checked_sub(1)?) -} - -/// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on -/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item -/// another stands in for ([`swapped_out`]). -pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option)> { - let en = enabled(app, id); - if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { - return None; - } - Some((en, checked(app, id, p))) -} +pub use bar::menu_bar; +pub(crate) use bar::{home_key, home_showing}; +#[cfg(test)] +use bar::{menu_body, pointer_reaches_title}; +pub use context::{context_items, context_menu_body, ruler_menu_body, ruler_unit_items}; +pub use entries::{MenuEntry, Row, context_entries, entry, menu_entries, pretty_shortcut}; +pub(crate) use invoke::graph_dialog; +pub use invoke::{click_target, invoke, invoke_from_system_menu}; +#[cfg(test)] +pub(crate) use labels::lists_plugin; +#[cfg(test)] +use labels::shows_a_name; +pub use labels::{CONTEXT_LABELS, DYNAMIC_LABELS, display_label, dynamic_label, item_shortcut, menu_strings}; +pub use run::{check_params, run_ui_command}; +pub(crate) use run::{normalize_panel, opt_bool}; +#[cfg(test)] +use slots::RECENT_FONT_IDS; +pub use state::{checked, enabled, plugin_revision, shortcut_of, shown_state}; +pub(crate) use submenus::PATH_EFFECTS; +pub use tree::{menu_tree, menu_tree_named}; +pub use ui_commands::UI_COMMANDS; /// One row of a menu as it shows now. The in-window menus draw these and the macOS menu bar /// ([`crate::native_menu`]) is built from them, so the two can't drift. @@ -2355,2091 +90,5 @@ pub enum Entry<'a> { Item(Row<'a>), } -/// A menu item as it shows now ([`entry`]). -pub struct Row<'a> { - /// The command and its params; `None` for an item not implemented yet (always disabled). - pub command: Option<(&'static str, &'a Value)>, - /// The tree's own English label, which [`click_target`] reads. - pub source: &'static str, - /// The label in the UI language, as it reads now (Undo *Move*, Show/Hide…, a recent file). - pub label: std::borrow::Cow<'static, str>, - /// The shortcut as the registry writes it (`Cmd+Shift+Z`), the user's overrides applied. - pub shortcut: Option<&'static str>, - pub enabled: bool, - /// `Some` for an item that is on or off. - pub checked: Option, -} - -impl Row<'_> { - /// What choosing it runs ([`click_target`]); `None` for an item not implemented yet. - pub fn target(&self) -> Option<(String, Value)> { - self.command.map(|(id, p)| click_target(self.source, id, p)) - } -} - -/// Item `it` as the menus show it now, or `None` while they leave it out ([`shown_state`]). -pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { - use crate::i18n::t; - Some(match it { - Item::Sep => Entry::Sep, - Item::Header(h) => Entry::Header(t(h)), - Item::Sub(label, children) => Entry::Sub(t(label), children), - Item::Todo(label, sc) => Entry::Item(Row { - command: None, - source: label, - label: t(label).into(), - shortcut: Some(*sc).filter(|s| !s.is_empty()), - enabled: false, - checked: None, - }), - Item::Cmd(label, id, p) => { - let (enabled, checked) = shown_state(app, id, p)?; - Entry::Item(Row { - command: Some((id, p)), - source: label, - label: effect_item_label(id, p) - .or_else(|| unlock_item_label(app, id, p, crate::i18n::current())) - .unwrap_or_else(|| display_label(app, id, label)) - .into(), - shortcut: item_shortcut(id, p), - enabled, - checked, - }) - } - }) -} - -/// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins -/// list them (the command palette's cache follows it). -pub fn plugin_revision() -> u64 { - vectorcraft_plugins::registry::revision() -} - -/// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type. -const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [ - [ - "ui.perspectiveUserPreset1.1", - "ui.perspectiveUserPreset1.2", - "ui.perspectiveUserPreset1.3", - "ui.perspectiveUserPreset1.4", - "ui.perspectiveUserPreset1.5", - ], - [ - "ui.perspectiveUserPreset2.1", - "ui.perspectiveUserPreset2.2", - "ui.perspectiveUserPreset2.3", - "ui.perspectiveUserPreset2.4", - "ui.perspectiveUserPreset2.5", - ], - [ - "ui.perspectiveUserPreset3.1", - "ui.perspectiveUserPreset3.2", - "ui.perspectiveUserPreset3.3", - "ui.perspectiveUserPreset3.4", - "ui.perspectiveUserPreset3.5", - ], -]; - -/// Select → saved selections: the n-th saved selection of the active document. -const SAVED_SELECTION_IDS: [&str; vectorcraft_engine::doc::SavedSelection::MAX] = [ - "select.recall1", - "select.recall2", - "select.recall3", - "select.recall4", - "select.recall5", - "select.recall6", - "select.recall7", - "select.recall8", - "select.recall9", - "select.recall10", - "select.recall11", - "select.recall12", - "select.recall13", - "select.recall14", - "select.recall15", - "select.recall16", - "select.recall17", - "select.recall18", - "select.recall19", - "select.recall20", - "select.recall21", - "select.recall22", - "select.recall23", - "select.recall24", - "select.recall25", -]; - -/// View → saved views: the n-th saved view of the active document. -const SAVED_VIEW_IDS: [&str; vectorcraft_engine::doc::SavedView::MAX] = [ - "view.goto1", - "view.goto2", - "view.goto3", - "view.goto4", - "view.goto5", - "view.goto6", - "view.goto7", - "view.goto8", - "view.goto9", - "view.goto10", - "view.goto11", - "view.goto12", - "view.goto13", - "view.goto14", - "view.goto15", - "view.goto16", - "view.goto17", - "view.goto18", - "view.goto19", - "view.goto20", - "view.goto21", - "view.goto22", - "view.goto23", - "view.goto24", - "view.goto25", -]; - -/// The 1-based slot a `view.gotoN` id stands for. -fn saved_view_slot(id: &str) -> Option { - id.strip_prefix("view.goto")?.parse().ok() -} - -/// The saved view a `view.gotoN` slot names (none past the document's last). -fn saved_view<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a vectorcraft_engine::doc::SavedView> { - app.session.active()?.doc.views.get(saved_view_slot(id)?.checked_sub(1)?) -} - -/// The 1-based slot a `select.recallN` id stands for. -fn saved_selection_slot(id: &str) -> Option { - id.strip_prefix("select.recall")?.parse().ok() -} - -/// Type → Recent Fonts slots (Preferences › Type › Number of Recent Fonts shows up to 15). -const RECENT_FONT_IDS: [&str; crate::MAX_RECENT_FONTS] = [ - "type.recentFont1", - "type.recentFont2", - "type.recentFont3", - "type.recentFont4", - "type.recentFont5", - "type.recentFont6", - "type.recentFont7", - "type.recentFont8", - "type.recentFont9", - "type.recentFont10", - "type.recentFont11", - "type.recentFont12", - "type.recentFont13", - "type.recentFont14", - "type.recentFont15", -]; - -/// Effective shortcut of a command: the user's override (Edit → Keyboard Shortcuts) or the default. -pub fn shortcut_of(id: &str) -> Option<&'static str> { - crate::shortcut_editor::command_shortcut(id) -} - -/// Is a command currently enabled? -/// Commands that would act on the text being typed, held back while an IME composes in the Type -/// tool (its marked text isn't committed yet): Undo/Redo — the native menu takes ⌘Z ahead of the -/// IME —, the clipboard and the selection. -/// Clear History also waits: it ends the typing session as Undo does. -pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool { - app.session.tool_composing() - && (matches!( - id, - "edit.undo" | "edit.redo" | "edit.cut" | "edit.copy" | "edit.clear" | "edit.duplicate" | "select.all" | "select.none" | "select.inverse" - ) || id == "edit.clearHistory" - || id.starts_with("edit.paste")) -} - -/// Engine commands that take `ids` and otherwise act on the Appearance panel's subject (the -/// targeted layer, group or object, else the selection). Without `ids` and without a subject they -/// are off, as the panel's buttons are: in menus, in the command palette and on their keys. -const SUBJECT_COMMANDS: [&str; 2] = ["appearance.addFill", "appearance.addStroke"]; - -/// Is there an Appearance panel subject: a targeted layer, group or object, or a selected one? -fn has_subject(app: &VectorcraftApp) -> bool { - app.session.active().is_some_and(|st| !st.selection.subjects().is_empty()) -} - -/// Can command `id` run now? An engine command asks the engine, a UI command the app's state; -/// none that would act on text an input method is still composing. -pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { - if waits_for_ime(app, id) { - return false; - } - if SUBJECT_COMMANDS.contains(&id) { - return has_subject(app); - } - if let Some(c) = vectorcraft_engine::find_command(id) { - // The system clipboard's contents can be pasted with an empty internal clipboard. - return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste"); - } - match id { - // Save is off for a clean document that already has its own file. - "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()), - "type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(), - id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(), - "type.applyBullets" | "type.applyNumbers" | "type.convertListToText" => crate::panels::character::text_style(app).is_some(), - id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()), - "file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()), - "file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()), - "plugin.cancelRuns" => crate::plugin_runs::any_running(app), - "plugin.hideRuns" => crate::plugin_runs::any_shown(app), - "file.place" - | "file.export.svg" - | "file.export.png" - | "file.exportAs" - | "file.exportForScreens" - | "file.documentSetup" - | "view.zoomIn" - | "view.zoomOut" - | "view.fitArtboard" - | "view.fitAll" - | "view.actualSize" - | "view.rotate" => app.session.active().is_some(), - "view.rotateToSelection" => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()), - id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), - "file.clearRecent" => !app.ui.recent_files.is_empty(), - id if id.starts_with("type.recentFont") => { - id["type.recentFont".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.recent_fonts().len()) && app.session.active().is_some() - } - id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).is_some(), - id if saved_view_slot(id).is_some() => saved_view(app, id).is_some(), - "effect.dialog" | "ui.recolorDialog" | "ui.effectGalleryDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.cropImage" => selected_image(app, |_| true), - "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), - "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), - "ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(), - "ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), - "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), - "ui.graphicStyleOptions" => app.session.active().is_some(), - "ui.newArtboard" => app.session.active().is_some(), - "ui.sampleScreenColor" => crate::screen_color::available(app), - "ui.newBrush" => app.session.active().is_some(), - "ui.brushOptions" => crate::dialogs::brush_options::available(app), - "ui.hyphenation" | "ui.justification" => crate::panels::character::text_style(app).is_some(), - "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.saveSwatchLibrary" => app.session.active().is_some(), - id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(), - "ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.saveGraphicStyleLibrary" => app.session.active().is_some(), - id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(), - "ui.saveSymbolLibrary" => app.session.active().is_some_and(|d| !d.doc.symbols.is_empty()), - id if id.starts_with(crate::panels::symbols::USER_SLOT) => crate::panels::symbols::user_library(app, id).is_some(), - "ui.saveBrushLibrary" => crate::panels::brushes::has_brushes(app), - id if id.starts_with(crate::panels::brushes::USER_SLOT) => crate::panels::brushes::user_library(app, id).is_some(), - "ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.spotColors" => app.session.active().is_some(), - "ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.simplifyAuto" | "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::showing(app), - "ui.dxfOptionsDialog" => app.session.active().is_some(), - "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), - "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), - "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), - "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), - "ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()), - "ui.epsOptionsDialog" => app.session.active().is_some(), - "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), - "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "css.copy" | "css.exportFile" => app.session.active().is_some(), - "print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()), - "plugin.dialog" => app.session.active().is_some(), - "ui.perspectiveGridDialog" | "ui.savePerspectivePreset" => app.session.active().is_some(), - id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { - app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some() - } - "ui.perspectivePlane" => app.session.active().is_some(), - // Only one window per document so far: greyed out, as Window › Arrange's items are (#3724). - "window.newWindow" => false, - _ => true, - } -} - -/// Is a placed document selected? -fn selected_placed(app: &VectorcraftApp) -> bool { - app.session.active().is_some_and(|st| { - st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::PlacedDocument(_)))) - }) -} - -/// Is an image `keep` accepts selected? -fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObject) -> bool) -> bool { - app.session.active().is_some_and(|st| { - st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(&n.kind, vectorcraft_doc::NodeKind::Image(im) if keep(im)))) - }) -} - -/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see -/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`). -/// `english_names` is Preferences › Type › Show Font Names in English (the Type → Font labels -/// follow it; tests use the default `true`). -pub fn menu_tree() -> Vec<(&'static str, Vec)> { - menu_tree_named(true) -} - -pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { - let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id, "toggle": true })); - vec![ - ( - // The application menu: About and Discord only. The in-window bar never shows it (File - // is first there, as in Illustrator); on macOS `native_menu::mac_layout` composes the - // App menu from this entry and the other menus (About from Help, Settings from Edit, - // Quit from File, Language and Appearance synthesized). It stays first: `from_tree` - // gives the first menu the App role. - "VectorCraft", - vec![c("About VectorCraft", "help.about"), c("Join Our Discord", "help.discord")], - ), - ( - "File", - vec![ - c("New…", "file.newDialog"), - c("New from Template…", "file.newFromTemplate"), - c("Open…", "file.open"), - sub("Open Recent Files", { - let mut v: Vec = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect(); - v.push(Sep); - v.push(c("Clear Recent Files", "file.clearRecent")); - v - }), - c("Show in Folder", "file.reveal"), - Sep, - c("Close", "file.close"), - c("Close All", "file.closeAll"), - c("Save", "file.save"), - c("Save As…", "file.saveAs"), - c("Save a Copy…", "file.saveCopy"), - c("Save as Template…", "file.saveAsTemplate"), - c("Save as PDF…", "file.export.pdf"), - c("Save for Office Documents…", "document.exportForOffice"), - c("Revert", "file.revert"), - Sep, - c("Place…", "file.place"), - Sep, - sub( - "Export", - vec![ - c("Export for Screens…", "file.exportForScreens"), - c("Export As…", "file.exportAs"), - c("Export As SVG…", "file.export.svg"), - c("Export As PNG…", "file.export.png"), - c("Save for Web (Legacy)…", "file.saveForWeb"), - ], - ), - c("Export Selection…", "file.exportSelection"), - Sep, - c("Package…", "ui.packageDialog"), - sub("Scripts", vec![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]), - Sep, - c("Document Setup…", "file.documentSetup"), - sub( - "Document Color Mode", - vec![ - cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})), - cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})), - ], - ), - c("File Info…", "file.info"), - Sep, - c("Print…", "file.print"), - Sep, - c("Exit", "app.quit"), - ], - ), - ( - "Edit", - vec![ - c("Undo", "edit.undo"), - c("Redo", "edit.redo"), - Sep, - c("Cut", "edit.cut"), - c("Copy", "edit.copy"), - c("Paste", "edit.paste"), - c("Paste in Front", "edit.pasteInFront"), - c("Paste in Back", "edit.pasteInBack"), - c("Paste in Place", "edit.pasteInPlace"), - c("Paste on All Artboards", "edit.pasteOnAllArtboards"), - c("Paste without Formatting", "edit.pasteWithoutFormatting"), - c("Clear", "edit.clear"), - Sep, - cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})), - cp("Find Next", "edit.findNext", json!({"find": ""})), - sub("Spelling", vec![todo("Auto Spell Check"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")]), - Sep, - sub( - "Edit Colors", - vec![ - c("Recolor Artwork…", "ui.recolorDialog"), - sub( - "Recolor with Preset", - vec![ - cp("1 Color Job…", "ui.recolorDialog", json!({"colors": 1})), - cp("2 Color Job…", "ui.recolorDialog", json!({"colors": 2})), - cp("3 Color Job…", "ui.recolorDialog", json!({"colors": 3})), - cp("Color Library…", "ui.recolorDialog", json!({"library": ""})), - ], - ), - c("Adjust Color Balance…", "ui.colorBalanceDialog"), - c("Blend Front to Back", "edit.colors.blendFrontToBack"), - c("Blend Horizontally", "edit.colors.blendHorizontally"), - c("Blend Vertically", "edit.colors.blendVertically"), - c("Convert to CMYK", "edit.colors.toCMYK"), - c("Convert to Grayscale", "edit.colors.toGrayscale"), - c("Convert to RGB", "edit.colors.toRGB"), - c("Invert Colors", "edit.colors.invert"), - cp( - "Overprint Black…", - "edit.colors.overprintBlack", - json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}), - ), - c("Saturate…", "ui.saturateDialog"), - ], - ), - c("Edit Original", "links.editOriginal"), - Sep, - c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), - c("Print Presets…", "ui.printPresetsDialog"), - c("PDF Presets…", "ui.pdfPresetsDialog"), - c("Perspective Grid Presets…", "ui.perspectivePresetsDialog"), - Sep, - c("Color Settings…", "edit.colorSettings"), - c("Assign Profile…", "edit.assignProfile"), - Sep, - c("Keyboard Shortcuts…", "edit.keyboardShortcuts"), - c("Preferences…", "edit.preferences"), - ], - ), - ( - "Object", - vec![ - sub("Transform", transform_items()), - sub("Arrange", arrange_items()), - sub( - "Align", - vec![ - cp("Horizontal Align Left", "object.align", json!({"horizontal": "left"})), - cp("Horizontal Align Center", "object.align", json!({"horizontal": "center"})), - cp("Horizontal Align Right", "object.align", json!({"horizontal": "right"})), - Sep, - cp("Horizontal & Vertical Align Center", "object.align", json!({"horizontal": "center", "vertical": "center"})), - Sep, - cp("Vertical Align Top", "object.align", json!({"vertical": "top"})), - cp("Vertical Align Center", "object.align", json!({"vertical": "center"})), - cp("Vertical Align Bottom", "object.align", json!({"vertical": "bottom"})), - ], - ), - Sep, - c("Group", "object.group"), - c("Ungroup", "object.ungroup"), - sub( - "Lock", - vec![c("Selection", "object.lock"), c("All Artwork Above", "object.lock.above"), c("Other Layers", "object.lock.otherLayers")], - ), - c("Unlock All", "object.unlockAll"), - sub( - "Hide", - vec![c("Selection", "object.hide"), c("All Artwork Above", "object.hide.above"), c("Other Layers", "object.hide.otherLayers")], - ), - c("Show All", "object.showAll"), - Sep, - c("Expand…", "ui.expandDialog"), - c("Expand Appearance", "effect.expandAppearance"), - c("Crop Image", "ui.cropImage"), - c("Rasterize…", "object.rasterize"), - cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})), - cp( - "Create Object Mosaic…", - "object.createObjectMosaic", - json!({"columns": 10, "rows": 10, "spacingX": 0, "spacingY": 0, "gray": false, "deleteRaster": false}), - ), - c("Vector Halftone…", "object.vectorHalftone"), - c("Create Trim Marks", "object.createTrimMarks"), - c("Flatten Transparency…", "ui.flattenTransparencyDialog"), - Sep, - c("Make Pixel Perfect", "object.makePixelPerfect"), - Sep, - sub( - "Slice", - vec![ - c("Make", "object.slice.make"), - c("Release", "object.slice.release"), - c("Create from Guides", "object.slice.fromGuides"), - c("Create from Selection", "object.slice.fromSelection"), - Sep, - c("Duplicate Slice", "object.slice.duplicate"), - c("Combine Slices", "object.slice.combine"), - c("Divide Slices…", "object.slice.divide"), - Sep, - c("Delete All", "object.slice.deleteAll"), - c("Slice Options…", "object.slice.options"), - c("Clip to Artboard", "object.slice.clipToArtboard"), - ], - ), - Sep, - sub( - "Path", - vec![ - c("Join", "path.join"), - c("Average…", "path.average"), - Sep, - c("Outline Stroke", "object.path.outlineStroke"), - c("Offset Path…", "object.path.offsetPath"), - c("Reverse Path Direction", "path.reverse"), - Sep, - c("Simplify…", "object.path.simplify"), - c("Add Anchor Points", "object.path.addAnchorPoints"), - c("Remove Anchor Points", "path.removeAnchors"), - c("Divide Objects Below", "object.path.divideObjectsBelow"), - c("Split Into Grid…", "object.path.splitIntoGrid"), - Sep, - cp("Clean Up…", "object.path.cleanUp", json!({"strayPoints": true, "unpaintedObjects": true, "emptyTextPaths": true})), - ], - ), - sub("Shape", vec![c("Convert to Shape", "object.shape.convertToShape"), c("Expand Shape", "object.expandShape")]), - sub( - "Pattern", - vec![c("Make", "object.pattern.make"), c("Edit Pattern", "object.pattern.edit"), c("Tile Edge Color…", "ui.tileEdgeColor")], - ), - sub( - "Repeat", - vec![ - c("Radial", "object.repeat.radial"), - c("Grid", "object.repeat.grid"), - c("Mirror", "object.repeat.mirror"), - Sep, - c("Release", "object.repeat.release"), - c("Options…", "object.repeat.options"), - ], - ), - sub( - "Intertwine", - vec![c("Make", "object.intertwine.make"), c("Edit", "object.intertwine.edit"), c("Release", "object.intertwine.release")], - ), - sub("Objects on Path", vec![c("Attach", "object.objectsOnPath.attach"), c("Expand", "object.objectsOnPath.expand")]), - sub( - "Blend", - vec![ - c("Make", "object.blend.make"), - c("Release", "object.blend.release"), - Sep, - c("Blend Options…", "object.blend.options"), - Sep, - c("Expand", "object.blend.expand"), - Sep, - c("Replace Spine", "object.blend.replaceSpine"), - c("Reverse Spine", "object.blend.reverseSpine"), - c("Reverse Front to Back", "object.blend.reverseFrontToBack"), - ], - ), - sub( - "Envelope Distort", - vec![ - // While an envelope is selected, Reset takes Make's place (`swapped_out`). - c("Make with Warp…", "object.envelope.makeWithWarp"), - c("Reset with Warp…", "object.envelope.resetWithWarp"), - c("Make with Mesh…", "object.envelope.makeWithMesh"), - c("Reset with Mesh…", "object.envelope.resetWithMesh"), - c("Make with Top Object", "object.envelope.makeWithTopObject"), - Sep, - c("Release", "object.envelope.release"), - c("Envelope Options…", "object.envelope.options"), - c("Expand", "object.envelope.expand"), - Sep, - c("Edit Contents", "object.envelope.editContents"), - ], - ), - sub( - "Perspective", - vec![ - c("Attach to Active Plane", "perspective.attach"), - c("Release with Perspective", "perspective.release"), - c("Move Plane to Match Object", "perspective.plane.matchObject"), - c("Edit Text", "perspective.editText"), - ], - ), - sub( - "Live Paint", - vec![ - c("Make", "livePaint.make"), - c("Merge", "livePaint.merge"), - c("Release", "livePaint.release"), - Sep, - c("Gap Options…", "ui.livePaintGapOptions"), - Sep, - c("Expand", "livePaint.expand"), - ], - ), - sub( - "Image Trace", - vec![ - // Traced at once with the Default preset, as the Control bar's Image Trace - // button does; other presets come from its arrow or the Image Trace panel - // (#543: the "…" form asked for a preset's name in a text box). - cp("Make", "imageTrace.make", json!({"preset": "Default"})), - cp("Make and Expand", "imageTrace.makeAndExpand", json!({"preset": "Default"})), - c("Release", "imageTrace.release"), - c("Expand", "imageTrace.expand"), - ], - ), - sub( - "Text Wrap", - vec![ - c("Make", "object.textWrap.make"), - c("Release", "object.textWrap.release"), - c("Text Wrap Options…", "object.textWrap.options"), - ], - ), - Sep, - sub( - "Clipping Mask", - vec![ - c("Make", "object.clippingMask.make"), - c("Release", "object.clippingMask.release"), - c("Edit Contents", "object.clippingMask.editContents"), - c("Edit Clipping Path", "object.clippingMask.editMask"), - ], - ), - sub("Compound Path", vec![c("Make", "object.compoundPath.make"), c("Release", "object.compoundPath.release")]), - sub( - "Artboards", - vec![ - c("Convert to Artboards", "artboard.convertToArtboards"), - c("Rearrange All Artboards…", "artboard.rearrange"), - Sep, - c("Fit to Artwork Bounds", "artboard.fitToArt"), - c("Fit to Selected Art", "artboard.fitToSelection"), - ], - ), - sub( - "Graph", - vec![ - c("Type…", "graph.setType"), - c("Data…", "graph.setData"), - c("Design…", "graph.design"), - c("Column…", "graph.column"), - c("Marker…", "graph.marker"), - ], - ), - sub( - "Collect for Export", - vec![ - cp("As Single Asset", "assets.add", json!({"multiple": false})), - cp("As Multiple Assets", "assets.add", json!({"multiple": true})), - ], - ), - Sep, - sub("Plug-ins", crate::dialogs::plugin::object_menu()), - ], - ), - ( - "Type", - vec![ - sub("Font", font_items(english_names)), - sub("Recent Fonts", RECENT_FONT_IDS.iter().map(|id| c("Recent Font", id)).collect()), - sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()), - c("Bold", "type.bold"), - c("Italic", "type.italic"), - Sep, - panel("Glyphs", "glyphs"), - sub("Insert Special Character", insert_items(INSERT_SPECIAL)), - sub("Insert Whitespace Character", insert_items(INSERT_WHITESPACE)), - sub("Insert Break Character", insert_items(INSERT_BREAK)), - Sep, - c("Convert To Area Type", "type.convertToAreaType"), - c("Convert To Point Type", "type.convertToPointType"), - c("Area Type Options…", "text.areaOptions"), - sub( - "Type on a Path", - PATH_EFFECTS - .iter() - .map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect }))) - .chain([Sep, c("Type on a Path Options…", "type.pathOptions")]) - .collect(), - ), - sub( - "Threaded Text", - vec![ - c("Create", "text.thread.create"), - c("Release Selection", "text.thread.releaseSelection"), - c("Remove Threading", "text.thread.remove"), - ], - ), - c("Fit Headline", "text.fitHeadline"), - Sep, - c("Find Font…", "type.findFont"), - sub( - "Change Case", - vec![ - cp("UPPERCASE", "type.changeCase", json!({"case": "upper"})), - cp("lowercase", "type.changeCase", json!({"case": "lower"})), - cp("Title Case", "type.changeCase", json!({"case": "title"})), - cp("Sentence case", "type.changeCase", json!({"case": "sentence"})), - ], - ), - cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})), - sub( - "Bullets and Numbering", - vec![ - c("Apply Bullets", "type.applyBullets"), - c("Apply Numbers", "type.applyNumbers"), - c("Convert to Text", "type.convertListToText"), - ], - ), - Sep, - c("Create Outlines", "type.createOutlines"), - c("Scale Text Attributes", "type.scaleTextAttributes"), - c("Optical Margin Alignment", "type.opticalMarginAlignment"), - Sep, - c("Fill with Placeholder Text", "type.fillPlaceholder"), - c("Insert Inline Symbol", "text.insertInline"), - Sep, - c("Show Hidden Characters", "type.hiddenCharacters"), - sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]), - ], - ), - ("Select", { - let mut v = vec![ - c("All", "select.all"), - c("All on Active Artboard", "select.allOnArtboard"), - c("Deselect", "select.none"), - c("Reselect", "select.reselect"), - Sep, - c("Inverse", "select.inverse"), - Sep, - c("Next Object Above", "select.nextAbove"), - c("Next Object Below", "select.nextBelow"), - Sep, - sub( - "Same", - vec![ - c("Appearance", "select.same.appearance"), - c("Appearance Attribute", "select.same.appearanceAttribute"), - c("Blending Mode", "select.same.blendingMode"), - c("Fill & Stroke", "select.same.fillAndStroke"), - c("Fill Color", "select.same.fillColor"), - c("Opacity", "select.same.opacity"), - c("Stroke Color", "select.same.strokeColor"), - c("Stroke Weight", "select.same.strokeWeight"), - c("Graphic Style", "select.same.graphicStyle"), - c("Shape", "select.same.shapeType"), - c("Symbol Instance", "select.same.symbolInstance"), - Sep, - c("Font Family", "select.same.fontFamily"), - c("Font Family & Style", "select.same.fontFamilyStyle"), - c("Font Family, Style & Size", "select.same.fontFamilyStyleSize"), - c("Font Size", "select.same.fontSize"), - c("Text Fill Color", "select.same.textFillColor"), - c("Text Stroke Color", "select.same.textStrokeColor"), - ], - ), - sub( - "Object", - vec![ - c("All on Same Layers", "select.object.allOnSameLayers"), - c("Direction Handles", "select.object.directionHandles"), - Sep, - c("Brush Strokes", "select.object.brushStrokes"), - c("Bristle Brush Strokes", "select.object.bristleBrushStrokes"), - c("Clipping Masks", "select.object.clippingMasks"), - c("Slices", "select.object.slices"), - c("Stray Points", "select.object.strayPoints"), - c("Open Paths", "select.object.openPaths"), - Sep, - c("All Text Objects", "select.object.textObjects"), - c("Point Text Objects", "select.object.pointText"), - c("Area Text Objects", "select.object.areaText"), - ], - ), - c("Start Global Edit", "select.globalEdit"), - Sep, - c("Save Selection…", "select.save"), - c("Update Selection", "select.update"), - c("Edit Selection…", "select.editSaved"), - Sep, - ]; - v.extend(SAVED_SELECTION_IDS.iter().map(|id| c("Saved Selection", id))); - v - }), - ("Effect", effect_menu()), - ("View", { - let mut v = vec![ - c("Outline", "view.outline"), - c("Overprint Preview", "view.overprintPreview"), - c("Pixel Preview", "view.pixelPreview"), - c("Trim View", "view.trimView"), - c("Presentation Mode", "view.presentation"), - sub( - "Screen Mode", - vec![ - cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})), - cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})), - cp("Full Screen Mode", "view.screenMode", json!({"mode": 2})), - ], - ), - Sep, - sub( - "Proof Setup", - vec![ - cp("Working CMYK", "view.proofSetup", json!({"target": "workingCmyk", "proof": true})), - cp("Legacy Macintosh RGB (Gamma 1.8)", "view.proofSetup", json!({"target": "legacyMacRgb", "proof": true})), - cp("Internet Standard RGB (sRGB)", "view.proofSetup", json!({"target": "srgb", "proof": true})), - cp("Monitor RGB", "view.proofSetup", json!({"target": "monitorRgb", "proof": true})), - cp("Color blindness – Protanopia-type", "view.proofSetup", json!({"target": "protanopia", "proof": true})), - cp("Color blindness – Deuteranopia-type", "view.proofSetup", json!({"target": "deuteranopia", "proof": true})), - cp("Color blindness – Tritanopia-type", "view.proofSetup", json!({"target": "tritanopia", "proof": true})), - cp("Customize…", "window.panel", json!({"panel": "separations"})), - ], - ), - c("Proof Colors", "view.proofColors"), - Sep, - c("Zoom In", "view.zoomIn"), - c("Zoom Out", "view.zoomOut"), - c("Fit Artboard in Window", "view.fitArtboard"), - c("Fit All in Window", "view.fitAll"), - c("Actual Size", "view.actualSize"), - Sep, - sub("Rotate View", crate::state::ROTATE_VIEW_ANGLES.iter().map(|(l, a)| cp(l, "view.rotate", json!({ "angle": a }))).collect()), - c("Rotate View to Selection", "view.rotateToSelection"), - c("Reset Rotate View", "view.rotateReset"), - Sep, - c("Hide Edges", "view.edges"), - c("Hide Artboards", "view.artboards"), - c("Show Print Tiling", "view.printTiling"), - c("Hide Slices", "view.slices.hide"), - c("Lock Slices", "view.slices.lock"), - c("Hide Template", "view.template"), - Sep, - sub( - "Rulers", - vec![ - c("Show Rulers", "view.rulers"), - c("Change to Global Rulers", "view.globalRulers"), - c("Show Video Rulers", "view.videoRulers"), - ], - ), - c("Hide Bounding Box", "view.boundingBox"), - c("Show Transparency Grid", "view.transparencyGrid"), - c("Hide Text Threads", "view.textThreads"), - c("Hide Gradient Annotator", "view.gradientAnnotator"), - c("Hide Corner Widget", "view.cornerWidget"), - c("Show Live Paint Gaps", "view.livePaintGaps"), - Sep, - sub( - "Guides", - vec![ - c("Hide Guides", "view.guides"), - c("Lock Guides", "view.guides.lock"), - c("Make Guides", "view.guides.make"), - c("Release Guides", "view.guides.release"), - c("Clear Guides", "view.guides.clear"), - ], - ), - c("Smart Guides", "view.smartGuides"), - sub( - "Perspective Grid", - vec![ - c("Show Grid", "perspective.grid.show"), - c("Show Rulers", "perspective.grid.rulers"), - c("Snap to Grid", "perspective.grid.snap"), - c("Lock Grid", "perspective.grid.lock"), - c("Lock Station Point", "perspective.grid.lockStation"), - Sep, - c("Define Grid…", "ui.perspectiveGridDialog"), - sub("One Point Perspective", crate::dialogs::perspective_presets::menu(1, PERSPECTIVE_SLOTS[0])), - sub("Two Point Perspective", crate::dialogs::perspective_presets::menu(2, PERSPECTIVE_SLOTS[1])), - sub("Three Point Perspective", crate::dialogs::perspective_presets::menu(3, PERSPECTIVE_SLOTS[2])), - Sep, - c("Save Grid as Preset…", "ui.savePerspectivePreset"), - ], - ), - c("Show Grid", "view.grid"), - c("Snap to Grid", "view.snapToGrid"), - c("Snap to Pixel", "view.snapToPixel"), - c("Snap to Point", "view.snapToPoint"), - c("Snap to Glyph", "view.snapToGlyph"), - Sep, - c("New View…", "view.saved.new"), - c("Edit Views…", "view.saved.edit"), - Sep, - ]; - v.extend(SAVED_VIEW_IDS.iter().map(|id| c("Saved View", id))); - v - }), - ( - "Window", - vec![ - c("New Window", "window.newWindow"), - sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]), - sub("Workspace", crate::workspaces::menu_items()), - Sep, - c("Control", "window.control"), - c("Contextual Task Bar", "window.taskBar"), - c("Tools", "window.toolbar"), - sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns")]), - Sep, - panel("Actions", "actions"), - panel("Align", "align"), - panel("Appearance", "appearance"), - panel("Artboards", "artboards"), - panel("Asset Export", crate::panels::asset_export::ID), - panel("Attributes", "attributes"), - panel("Blend", crate::panels::blend::ID), - panel("Brushes", "brushes"), - panel("Color", "color"), - panel("Color Guide", "colorGuide"), - panel("Color Themes", "colorThemes"), - panel("CSS Properties", crate::panels::css_properties::ID), - panel("Document Info", "docInfo"), - panel("Flattener Preview", crate::panels::flattener_preview::ID), - panel("Gradient", "gradient"), - panel("Graphic Styles", "graphicStyles"), - panel("History", "history"), - panel("Image Trace", "imageTrace"), - panel("Info", "info"), - panel("Layers", "layers"), - panel("Libraries", "libraries"), - panel("Links", crate::panels::links::ID), - panel("Magic Wand", "magicWand"), - panel("Navigator", "navigator"), - panel("Pathfinder", "pathfinder"), - panel("Pattern Options", "patternOptions"), - panel("Properties", "properties"), - panel("Separations Preview", "separations"), - panel("Stroke", "stroke"), - panel("SVG Interactivity", crate::panels::svg_interactivity::ID), - panel("Swatches", "swatches"), - panel("Symbols", "symbols"), - panel("Transform", "transform"), - panel("Transparency", "transparency"), - sub( - "Type", - vec![ - panel("Character", "character"), - panel("Character Styles", "charStyles"), - panel("Glyphs", "glyphs"), - panel("OpenType", "openType"), - panel("Paragraph", "paragraph"), - panel("Paragraph Styles", "paraStyles"), - panel("Tabs", "tabs"), - ], - ), - panel("Variables", crate::panels::variables::ID), - Sep, - sub("Brush Libraries", crate::panels::brushes::window_menu()), - sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), - sub("Swatch Libraries", crate::panels::swatches::window_menu()), - sub("Symbol Libraries", crate::panels::symbols::window_menu()), - ], - ), - ( - "Help", - vec![ - c("Join Our Discord", "help.discord"), - c("ArtCraft Website", "help.website"), - c("VectorCraft on getartcraft.com", "help.appPage"), - c("VectorCraft on GitHub", "help.github"), - Sep, - c("Search Commands…", "help.commandPalette"), - todos("VectorCraft Help…", "F1"), - Sep, - c("Check for Updates…", "help.checkForUpdates"), - c("About VectorCraft", "help.about"), - ], - ), - ] -} - -/// Object → Transform (also in the canvas context menu). -fn transform_items() -> Vec { - vec![ - c("Transform Again", "object.transformAgain"), - Sep, - c("Move…", "object.move"), - c("Rotate…", "object.rotate"), - c("Reflect…", "object.reflect"), - c("Scale…", "object.scale"), - c("Shear…", "object.shear"), - Sep, - c("Transform Each…", "object.transformEach"), - Sep, - c("Reset Bounding Box", "object.resetBoundingBox"), - ] -} - -/// Object → Arrange (also in the canvas context menu). -fn arrange_items() -> Vec { - vec![ - c("Bring to Front", "object.arrange.bringToFront"), - c("Bring Forward", "object.arrange.bringForward"), - c("Send Backward", "object.arrange.sendBackward"), - c("Send to Back", "object.arrange.sendToBack"), - Sep, - c("Send to Current Layer", "object.arrange.sendToCurrentLayer"), - ] -} - -/// The canvas context menu (right-click): what applies to the selection, or to the view when -/// nothing is selected. Commands that can't run now are left out rather than greyed out. -pub fn context_items(app: &VectorcraftApp) -> Vec { - use vectorcraft_doc::NodeKind; - let Some(st) = app.session.active() else { return vec![] }; - let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect(); - let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind)); - let several = roots.len() >= 2; - let mut v = vec![c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep]; - if st.isolation.is_some() { - v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]); - } - // Managing artboards from the canvas: with the Artboard tool, the menu locks and unlocks them - // (`artboard.setProps` with `locked` for each artboard named; an entry with none to change is left out). - if app.session.tool_id() == "artboard" { - let n = st.doc.artboards.len(); - let sel = crate::panels::artboards::picked(app, n); - let others: Vec = (0..n).filter(|i| !sel.contains(i)).collect(); - let entry = |label, idx: Vec, locked: bool| { - let list: Vec = idx.iter().map(|i| json!({ "index": i, "locked": locked })).collect(); - (!list.is_empty()).then(|| cp(label, "artboard.setProps", json!({ "artboards": list }))) - }; - let lock: Vec = - [entry("Lock Selected", sel.clone(), true), entry("Lock All Except Selected", others, true), entry("Lock All", (0..n).collect(), true)] - .into_iter() - .flatten() - .collect(); - let unlock: Vec = [entry("Unlock Selected", sel, false), entry("Unlock All", (0..n).collect(), false)].into_iter().flatten().collect(); - for (label, items) in [("Lock Artboards", lock), ("Unlock Artboards", unlock)] { - if !items.is_empty() { - v.push(sub(label, items)); - } - } - v.push(Sep); - } - // The locked objects under the pointer at the right-click, each unlocked as the Layers panel's - // lock column does. `unlock_item_label` shows each object's name. - let unlock: Vec = app - .ui - .context_locked - .iter() - .filter(|(uid, _)| *uid == st.uid) - .flat_map(|(_, ids)| ids) - .filter(|id| st.doc.node(**id).is_some_and(|n| n.locked)) - .map(|id| cp("Unlock", "layer.setProps", json!({ "ids": [id.0], "locked": false }))) - .collect(); - if !unlock.is_empty() { - v.extend([sub("Unlock", unlock), Sep]); - } - if roots.is_empty() { - v.extend([ - c("Paste", "edit.paste"), - Sep, - c("Zoom In", "view.zoomIn"), - c("Zoom Out", "view.zoomOut"), - c("Fit Artboard in Window", "view.fitArtboard"), - Sep, - c("Show Rulers", "view.rulers"), - c("Show Grid", "view.grid"), - c("Hide Guides", "view.guides"), - c("Lock Guides", "view.guides.lock"), - Sep, - c("Select All", "select.all"), - ]); - } else { - v.extend([c("Cut", "edit.cut"), c("Copy", "edit.copy"), c("Paste", "edit.paste"), Sep]); - if let [one] = roots.as_slice() - && one.is_container() - { - v.push(c("Isolate Selected Group", "object.isolate")); - } - if several { - v.push(c("Group", "object.group")); - } - if any(|k| matches!(k, NodeKind::Group { clip: false, .. })) { - v.push(c("Ungroup", "object.ungroup")); - } - let paths = any(|k| matches!(k, NodeKind::Path { .. })); - if paths { - v.extend([c("Join", "path.join"), c("Average…", "path.average")]); - } - if !st.selection.anchors.is_empty() { - v.extend([c("Remove Anchor Points", "path.removeAnchors"), c("Cut Path at Selected Anchor Points", "path.cutAtAnchors")]); - } - if several { - v.push(c("Make Clipping Mask", "object.clippingMask.make")); - } - if any(|k| matches!(k, NodeKind::Group { clip: true, .. })) { - v.push(c("Release Clipping Mask", "object.clippingMask.release")); - } - if several && paths { - v.push(c("Make Compound Path", "object.compoundPath.make")); - } - // Chooses the Objects on Path tool, whose click on a path attaches the selection to it. - if several { - v.push(c("Objects on Path", "object.objectsOnPath.attach")); - } - if any(|k| matches!(k, NodeKind::Compound { .. })) { - v.push(c("Release Compound Path", "object.compoundPath.release")); - } - // The paragraphs of the selected type, or those the Type tool's selection touches. - if crate::panels::character::text_style(app).is_some() { - v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")])); - } - v.extend([ - c("Make Guides", "view.guides.make"), - Sep, - sub("Transform", transform_items()), - sub("Arrange", arrange_items()), - sub( - "Select", - vec![c("Next Object Above", "select.nextAbove"), c("Next Object Below", "select.nextBelow"), Sep, c("Deselect", "select.none")], - ), - Sep, - c("Export Selection…", "file.exportSelection"), - ]); - } - prune(app, v) -} - -/// `items` without the commands that can't run now (Undo and Redo stay, greyed out), submenus -/// left empty, and separators that no longer separate anything. -fn prune(app: &VectorcraftApp, items: Vec) -> Vec { - let mut out: Vec = Vec::with_capacity(items.len()); - for it in items { - let it = match it { - Item::Sub(l, children) => Item::Sub(l, prune(app, children)), - it => it, - }; - let keep = match &it { - Item::Cmd(_, id, _) => matches!(*id, "edit.undo" | "edit.redo") || enabled(app, id), - Item::Sub(_, children) => !children.is_empty(), - Item::Sep => out.last().is_some_and(|l| !matches!(l, Item::Sep)), - _ => true, - }; - if keep { - out.push(it); - } - } - if matches!(out.last(), Some(Item::Sep)) { - out.pop(); - } - out -} - -/// The canvas context menu's popup; the item clicked goes in `clicked`, for [`invoke`]. -pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { - widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); - // Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter. - render_items(app, ui, &context_items(app), false, clicked); - }); -} - -/// The document units a right-click on a ruler offers: each runs `document.setUnits`, exactly as -/// Preferences ▸ Units ▸ General does, with the current one checked ([`checked`]). -pub fn ruler_unit_items() -> Vec { - vectorcraft_engine::doc::Unit::ALL.iter().map(|u| cp(u.label(), "document.setUnits", json!({ "units": u.label() }))).collect() -} - -/// The ruler context menu's popup (right-click a ruler or the origin box); the unit clicked goes in -/// `clicked`, for [`invoke`]. -pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { - widgets::menu_scroll(ui, |ui| { - ui.set_min_width(160.0); - render_items(app, ui, &ruler_unit_items(), true, clicked); - }); -} - -/// Render the menu bar. -/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width. -pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { - let t = Tokens::get(ui.ctx()); - let ctx = ui.ctx().clone(); - // The press that opens a menu takes the keyboard from a focused text field: the field and its - // selection are kept while the menus are open, so their Select All, Cut, Copy and Paste act on - // its text (#516), the frame after the click that chose them (that click takes the keyboard - // from it again). - type Field = (egui::Id, egui::text_edit::TextEditState); - let (field_key, chosen_key) = (egui::Id::unique("menu-bar-field"), egui::Id::unique("menu-bar-field-event")); - if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) { - ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key)); - state.store(&ctx, field); - to_field(&ctx, field, e); - } - if let Some(field) = focused_text_field(&ctx).and_then(|id| Some((id, egui::TextEdit::load_state(&ctx, id)?))) { - ctx.data_mut(|d| d.insert_temp::(field_key, field)); - } - let mut clicked: Option<(String, Value)> = None; - let tree = menu_tree_named(app.session.prefs.font_names_in_english); - // No application menu in the window (as in Illustrator): the bar starts at File on every - // platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there. - let tree: Vec<(&str, Vec)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect(); - // A menu bar for its layout and menu-button style, but the titles use `Popup::menu` directly: - // egui 0.37's `MenuButton` opens the title under a resting pointer while another menu is open, - // and the app switches titles on movement only (`switch_on_hover`, the native behaviour). - let (end, open) = egui::MenuBar::new() - .ui(ui, |ui| { - let mut titles = Vec::with_capacity(tree.len()); - for (title, items) in tree.iter() { - let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text); - let resp = ui.add(egui::Button::new(text)); - egui::Popup::menu(&resp).show(|ui| menu_body(app, ui, items, &mut clicked)); - titles.push(resp); - } - (ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles)) - }) - .inner; - let field = ctx.data(|d| d.get_temp::(field_key)); - if !open && focused_text_field(&ctx).is_none() { - ctx.data_mut(|d| d.remove::(field_key)); - } - if let Some((id, p)) = clicked { - match field.and_then(|f| Some((f, field_event(app, &id)?))) { - Some(chosen) => { - ctx.data_mut(|d| d.insert_temp(chosen_key, chosen)); - ctx.request_repaint(); - } - None => invoke(app, &id, p), - } - } - end -} - -/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no -/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must -/// not switch to the title under it). -fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool { - title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id) -} - -/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another -/// title opens that menu instead (egui alone needs a click on each title). Returns whether a menu -/// is open. -fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool { - let ids: Vec = titles.iter().map(egui::Popup::default_response_id).collect(); - let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else { - return false; - }; - // `Response::hovered` is false while a menu's popup is open, so hit-test the titles here. - // Only a moving pointer switches: one resting on a title leaves the open menu alone. - let moving = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO); - if let Some(p) = ctx.pointer_hover_pos().filter(|_| moving) - && let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) - && i != open - && let Some(id) = ids.get(i) - { - egui::Popup::open_id(ctx, *id); - ctx.request_repaint(); - } - true -} - -/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt; -/// it scrolls when it is taller than the window. -fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { - widgets::menu_scroll(ui, |ui| { - ui.set_min_width(230.0); - render_items(app, ui, items, true, clicked); - }); -} - -/// `items` as menu buttons; with `checks`, items that can be on or off show a check mark (or the -/// room for one). -fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: bool, clicked: &mut Option<(String, Value)>) { - let t = Tokens::get(ui.ctx()); - for e in items.iter().filter_map(|it| entry(app, it)) { - match e { - Entry::Sep => { - ui.separator(); - } - Entry::Header(h) => { - ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim)); - } - Entry::Sub(label, children) => { - ui.menu_button(label, |ui| { - widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); - render_items(app, ui, children, checks, clicked); - }); - }); - } - Entry::Item(row) => render_row(app, ui, &row, checks, clicked), - } - } -} - -/// One menu item: its label (with a check mark, or the room for one, under `checks`), its shortcut, -/// and Type › Font's samples. -fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, clicked: &mut Option<(String, Value)>) { - let sc = row.shortcut.map(pretty_shortcut).unwrap_or_default(); - let Some((id, p)) = row.command else { - ui.add_enabled_ui(false, |ui| { - ui.add(egui::Button::new(row.label.as_ref()).shortcut_text(sc)); - }) - .response - .on_disabled_hover_text(tl!("Coming soon — tracked in the parity plan")); - return; - }; - let text = match row.checked.filter(|_| checks) { - Some(true) => format!("✓ {}", row.label), - Some(false) => format!(" {}", row.label), - None => row.label.to_string(), - }; - // Type → Font: each family's sample after its name (Enable in-menu font previews). - let sampled = p.get("font").and_then(Value::as_str).filter(|_| id == "text.setStyle" && app.session.prefs.font_preview); - let r = match sampled { - Some(family) => { - let slot = egui::IdSalt::new(("font-sample", family)); - let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE)); - let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner; - if let Some(rect) = out.rect(slot) { - crate::font_menu::menu_item_sample(ui, rect, family); - } - out.response - } - None => ui.add_enabled(row.enabled, egui::Button::new(text).shortcut_text(sc)), - }; - if r.clicked() { - *clicked = row.target(); - ui.close(); - } -} - -/// What the Home screen remembers when it opens (`app.home`): the active document and the -/// document count. When either changes, the Home screen gives way to the document. -pub(crate) fn home_key(app: &VectorcraftApp) -> (Option, usize) { - (app.session.active().map(|d| d.uid), app.session.documents().len()) -} - -/// Whether the Home screen is up: chosen with the Home button (`app.home`), or no document is open -/// and Preferences › General › Show The Home Screen When No Documents Are Open is on (#394). Off, -/// an app with no document shows an empty window, and the Home button still opens the screen. -pub(crate) fn home_showing(app: &VectorcraftApp) -> bool { - app.ui.home.is_some() || (app.session.active().is_none() && app.session.prefs.show_home_screen) -} - -/// What a menu click runs: items whose label ends with "…" and that carry default params open a -/// generic parameter dialog (fields = the defaults) instead of running immediately. -pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { - let dialog = label.ends_with('…') - && p.as_object().is_some_and(|o| !o.is_empty()) - && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog"); - if dialog { - return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p})); - } - (id.to_string(), if p.is_null() { json!({}) } else { p.clone() }) -} - -/// The dialog of command `id` that shows the selection's current values (its query): its heading -/// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they -/// are. -fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> { - Some(match id { - // Whether the text overflows, and Shrink Text's factor, are read-only facts. - "text.areaOptions" => ("Area Type Options", &["overflow", "fitScale"]), - "type.pathOptions" => ("Type on a Path Options", &["start", "end"]), - _ => return None, - }) -} - -/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does. -fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> { - Some(match id { - // Move opens on the offset moved last and the checkboxes it was last confirmed with. - "object.move" => ("move", crate::dialogs::transform::move_fields(app)), - "object.rotate" => ("rotate", json!({"angle": 0})), - "object.scale" => ("scale", json!({"sx": 100, "sy": 100, "uniform": true})), - // Transform Each opens on the values it was last confirmed with (#1732). - "object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::open_fields(app)), - "object.reflect" => ("reflect", json!({"axis": "vertical", "angle": 90})), - "object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})), - "path.average" => ("average", json!({"axis": "both"})), - // Offset Path opens on the values it was last confirmed with. - "object.path.offsetPath" if app.ui.offset_path.is_object() => ("offsetPath", app.ui.offset_path.clone()), - "object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})), - // Simplify opens its slider, or the dialog on the fields it was last confirmed with when - // "Open this dialog with these settings next time" was ticked. - "object.path.simplify" if app.ui.simplify.is_object() => ("simplify", app.ui.simplify.clone()), - "object.path.simplify" => (crate::dialogs::simplify_slider::KIND, crate::dialogs::simplify_slider::fields(app)), - "object.path.splitIntoGrid" => { - // The grid starts at the size of the selection. - let size = - app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false)).map_or((0.0, 0.0), |b| (b.width(), b.height())); - ("splitIntoGrid", crate::dialogs::path_ops::split_fields(size.0, size.1)) - } - "object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()), - "artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::fields()), - _ => return None, - }) -} - -/// The event a focused text field takes for Edit-menu command `id` (Select All, Cut, Copy, -/// Paste), as the same keys would send it. -fn field_event(app: &mut VectorcraftApp, id: &str) -> Option { - Some(match id { - "select.all" => egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }, - "edit.copy" => egui::Event::Copy, - "edit.cut" => egui::Event::Cut, - "edit.paste" | "edit.pasteWithoutFormatting" => egui::Event::Paste(app.written_clipboard_text()?), - _ => return None, - }) -} - -/// Invoke an item clicked in the system menu bar (macOS; its key equivalents come back to egui as -/// key presses, see [`crate::native_menu`]). While a text field has the keyboard, Select All, Cut, -/// Copy and Paste act on the field's text, as their keys do. While its keys select every artboard -/// ([`crate::panels::artboards::selects_all`]), Select All does too. Everything else (and those -/// commands with no field focused) goes to [`invoke`]. -pub fn invoke_from_system_menu(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str, p: Value) { - match focused_text_field(ctx).and_then(|f| Some((f, field_event(app, id)?))) { - Some((field, e)) => to_field(ctx, field, e), - None if id == "select.all" && crate::panels::artboards::selects_all(app, ctx) => crate::panels::artboards::select_all(app), - None => invoke(app, id, p), - } -} - -/// The text field that has the keyboard, if any. -fn focused_text_field(ctx: &egui::Context) -> Option { - ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some()) -} - -/// Give text field `field` the keyboard (back) and event `e`, a [`field_event`], this frame. -fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) { - ctx.memory_mut(|m| m.request_focus(field)); - ctx.input_mut(|i| i.events.push(e)); -} - -/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input). -/// Object › Graph › Type… or Data… (`id`): its dialog, with the selected graph's current values -/// (the command's query). -pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result { - // Graph Data: the worksheet. - if id == "graph.setData" { - return crate::dialogs::graph_data::open(app); - } - let v = app.session.execute(id, &json!({})).map_err(|e| e.to_string())?; - let (label, fields) = ("Graph Type", v); - app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})).map(|_| json!({ "dialog": "command" })) -} - -pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { - if waits_for_ime(app, id) { - return; - } - if SUBJECT_COMMANDS.contains(&id) && p.get("ids").is_none() && p.get("id").is_none() && !has_subject(app) { - return; - } - // Params first: some dialogs' fields take a look through the session (Move's last move). - if p.as_object().is_none_or(|o| o.is_empty()) - && let Some((kind, fields)) = menu_dialog(app, id) - { - app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); - return; - } - // Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection. - if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::envelope::open(app, id) { - app.status(e); - } - return; - } - // Variables (data merge): a name and a kind, a name and a row of values. - if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { - crate::dialogs::variables::open(app, id, None); - return; - } - // Pathfinder Options: a dialog with the current options. - if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) { - crate::dialogs::pathfinder_options::open(app); - return; - } - // Repeat Options: a dialog with the selected repeat's current values. - if id == "object.repeat.options" - && p.as_object().is_none_or(|o| o.is_empty()) - && let Some(fields) = crate::panels::pattern_options::repeat_fields(app) - { - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields})); - return; - } - // Area Type Options and Type on a Path Options: dialogs with the selected type's current - // values (the command's query), less those the dialog leaves alone. - if let Some((label, hidden)) = queried_dialog(id) - && p.as_object().is_none_or(|o| o.is_empty()) - { - match app.session.execute(id, &json!({})) { - Ok(mut fields) => { - if let Some(o) = fields.as_object_mut() { - o.retain(|k, _| !hidden.contains(&k.as_str())); - } - let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})); - } - Err(e) => app.status(e.to_string()), - } - return; - } - // Object → Graph → Type… / Data…: dialogs with the selected graph's current values. - if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = graph_dialog(app, id) { - app.status(e); - } - return; - } - // Object → Graph → Design… / Column… / Marker…: the document's graph designs. - if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) { - let r = match id { - "graph.design" => crate::dialogs::graph_design::open_design(app), - "graph.column" => crate::dialogs::graph_design::open_column(app), - _ => crate::dialogs::graph_design::open_marker(app), - }; - if let Err(e) = r { - app.status(e); - } - return; - } - // New View…: a name dialog, starting from the first free "View N". - if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) { - let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedView::default_name(&d.doc.views)).unwrap_or_default(); - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}})); - return; - } - // Edit Views…: the saved views in a list, to rename or delete. - if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::edit_views::open(app) { - app.status(e); - } - return; - } - // Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with - // the view's angle filled in. - if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.view_mut().map(|v| v.rotation) { - Some(angle) => { - app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle}))); - } - None => app.status("no document"), - } - return; - } - // Save Selection…: a name dialog, starting from the first free "Selection N". - if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { - let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default(); - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}})); - return; - } - // Edit Selection…: the saved selections in a list, to rename or delete. - if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::edit_selection::open(app) { - app.status(e); - } - return; - } - // Document Raster Effects Settings and File Info: their dialogs. - if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) { - let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) }; - if let Err(e) = r { - app.status(e); - } - return; - } - // Rasterize…: its options, starting from the document's raster effects settings. - if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.session.execute("document.rasterEffectsSettings", &json!({})) { - Ok(v) => { - let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]}); - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params})); - } - Err(e) => app.status(e.to_string()), - } - return; - } - // Text Wrap Options: a dialog with the selected wrap object's current values. - if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.session.execute(id, &json!({})) { - Ok(fields) => { - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields})); - } - Err(e) => app.status(e.to_string()), - } - return; - } - // Blend Options…: its dialog, on the selected blend's options. - if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::blend_options::open(app) { - app.status(e); - } - return; - } - // Expand…: its dialog. - if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::expand::open(app) { - app.status(e); - } - return; - } - // Help links: the command returns the URL; a menu click opens it (agents just get the URL). - if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") { - app.open_link(id); - return; - } - // Object → Align aligns to what the align buttons align to, unless the call names `to`. With - // anchors selected (Direct Selection) it sends its params unchanged, and the points align to - // their own bounds. - let follows_align_to = id == "object.align" - && p.as_object().is_some_and(|o| !o.contains_key("to")) - && app.session.active().is_some_and(|st| st.selection.anchors.is_empty()); - let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p }; - if let Err(e) = app.run(id, p) { - app.status(e); - } else if matches!(id, "object.pattern.make" | "object.pattern.edit") { - app.ui.open_panel = Some("patternOptions".into()); - } -} - -/// `Cmd+Shift+]` → `⇧⌘]` on macOS, `Ctrl+Shift+]` elsewhere. -pub fn pretty_shortcut(s: &str) -> String { - if s.is_empty() { - return String::new(); - } - if cfg!(target_os = "macos") { - let mut mods = String::new(); - let parts: Vec<&str> = s.split('+').collect(); - let (key, ms): (&str, &[&str]) = if s.ends_with("++") { - ("+", parts.get(..parts.len().saturating_sub(2)).unwrap_or_default()) - } else { - parts.split_last().map_or(("", &[][..]), |(k, rest)| (*k, rest)) - }; - for m in ["Ctrl", "Alt", "Shift", "Cmd"] { - if ms.contains(&m) { - mods.push_str(match m { - "Ctrl" => "⌃", - "Alt" => "⌥", - "Shift" => "⇧", - _ => "⌘", - }); - } - } - format!("{mods}{key}") - } else { - s.replace("Cmd", "Ctrl") - } -} - -#[derive(Serialize)] -pub struct MenuEntry { - pub path: Vec, - pub label: String, - pub command: Option, - pub params: Value, - pub enabled: bool, - pub shortcut: String, -} - -/// Flattened menu for `ui.menu.list`. -pub fn menu_entries(app: &VectorcraftApp) -> Vec { - let mut out = vec![]; - for (title, items) in menu_tree_named(app.session.prefs.font_names_in_english) { - flatten(app, vec![title.to_string()], &items, &mut out); - } - out -} - -/// Flattened canvas context menu for `ui.contextMenu.list` (paths are its submenus). -pub fn context_entries(app: &VectorcraftApp) -> Vec { - let mut out = vec![]; - flatten(app, vec![], &context_items(app), &mut out); - out -} - -fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { - for it in items { - match it { - Item::Cmd(l, id, p) => { - let Some((enabled, _)) = shown_state(app, id, p) else { continue }; - out.push(MenuEntry { - path: path.clone(), - label: unlock_item_label(app, id, p, crate::i18n::Lang::EN).unwrap_or_else(|| dynamic_label(app, id, l)), - command: Some(id.to_string()), - params: p.clone(), - enabled, - shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(), - }) - } - Item::Todo(l, sc) => out.push(MenuEntry { - path: path.clone(), - label: l.to_string(), - command: None, - params: Value::Null, - enabled: false, - shortcut: sc.to_string(), - }), - Item::Sub(l, ch) => { - let mut p = path.clone(); - p.push(l.to_string()); - flatten(app, p, ch, out); - } - _ => {} - } - } -} - -/// Type → Size presets. -const TYPE_SIZES: [(&str, u32); 14] = [ - ("6 pt", 6), - ("8 pt", 8), - ("9 pt", 9), - ("10 pt", 10), - ("11 pt", 11), - ("12 pt", 12), - ("14 pt", 14), - ("18 pt", 18), - ("24 pt", 24), - ("30 pt", 30), - ("36 pt", 36), - ("48 pt", 48), - ("60 pt", 60), - ("72 pt", 72), -]; - -/// Type → Type on a Path effects (label, `type.pathOptions` effect), also the choices of its -/// Options dialog. -pub(crate) const PATH_EFFECTS: &[(&str, &str)] = - &[("Rainbow", "rainbow"), ("Skew", "skew"), ("3D Ribbon", "3dRibbon"), ("Stair Step", "stairStep"), ("Gravity", "gravity")]; - -const INSERT_SPECIAL: &[(&str, &str)] = &[ - ("Bullet", "bullet"), - ("Copyright Symbol", "copyright"), - ("Ellipsis", "ellipsis"), - ("Paragraph Symbol", "paragraph"), - ("Registered Trademark Symbol", "registered"), - ("Section Symbol", "section"), - ("Trademark Symbol", "trademark"), - ("Em Dash", "emDash"), - ("En Dash", "enDash"), - ("Discretionary Hyphen", "discretionaryHyphen"), - ("Nonbreaking Hyphen", "nonBreakingHyphen"), - ("Double Left Quotation Marks", "doubleLeftQuote"), - ("Double Right Quotation Marks", "doubleRightQuote"), - ("Single Left Quotation Mark", "singleLeftQuote"), - ("Single Right Quotation Mark", "singleRightQuote"), -]; -const INSERT_WHITESPACE: &[(&str, &str)] = &[ - ("Em Space", "emSpace"), - ("En Space", "enSpace"), - ("Hair Space", "hairSpace"), - ("Sixth Space", "sixthSpace"), - ("Thin Space", "thinSpace"), - ("Nonbreaking Space", "nonBreakingSpace"), - ("Figure Space", "figureSpace"), - ("Punctuation Space", "punctuationSpace"), - ("Third Space", "thirdSpace"), - ("Quarter Space", "quarterSpace"), -]; -const INSERT_BREAK: &[(&str, &str)] = &[("Forced Line Break", "forcedLineBreak"), ("Tab", "tab")]; - -fn insert_items(list: &[(&'static str, &'static str)]) -> Vec { - list.iter().map(|(l, c)| cp(l, "type.insert", json!({ "char": c }))).collect() -} - -/// Type → Font: one item per available family, the installed fonts included. Built again only when -/// the fonts or Show Font Names in English change (the menu tree is built every frame); labels are -/// interned once each, so rebuilding doesn't allocate forever. The command still sets the English -/// family name ([`vectorcraft_text::FontDb::canonical`]). -fn font_items(english_names: bool) -> Vec { - static NAMES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeSet::new()); - static ITEMS: std::sync::Mutex<(u64, bool, Vec)> = std::sync::Mutex::new((u64::MAX, true, Vec::new())); - let db = vectorcraft_text::FontDb::global(); - // Read first: fonts that load meanwhile make the next frame build the list again. - let generation = db.generation(); - let fams = db.menu_family_list(); - let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] }; - if items.0 != generation || items.1 != english_names { - let list = fams - .iter() - .map(|f| { - let english = f.as_str(); - let shown = db.family_display_name(english, english_names); - let label: &'static str = match names.get(shown.as_str()) { - Some(n) => n, - None => { - let n: &'static str = Box::leak(shown.into_boxed_str()); - names.insert(n); - n - } - }; - let font: &'static str = match names.get(english) { - Some(n) => n, - None => { - let n: &'static str = Box::leak(english.to_string().into_boxed_str()); - names.insert(n); - n - } - }; - cp(label, "text.setStyle", json!({ "font": font })) - }) - .collect(); - *items = (generation, english_names, list); - } - items.2.clone() -} - -/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the -/// vector effects): (submenu, the catalogue's menu path of its effects). -/// The raster Stylize submenu shares its path with the vector one and lists only the effects at -/// that path that [`vectorcraft_effects::PIXEL_EFFECTS`] names. -const RASTER_MENUS: [(&str, &[&str]); 10] = [ - ("Artistic", &["Effect", "Artistic"]), - ("Blur", &["Effect", "Blur"]), - ("Brush Strokes", &["Effect", "Brush Strokes"]), - ("Distort", &["Effect", "Distort"]), - ("Pixelate", &["Effect", "Pixelate"]), - ("Sharpen", &["Effect", "Sharpen"]), - ("Sketch", &["Effect", "Sketch"]), - ("Stylize", &["Effect", "Stylize"]), - ("Texture", &["Effect", "Texture"]), - ("Video", &["Effect", "Video"]), -]; - -/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects. -fn effect_menu() -> Vec { - let cat = vectorcraft_effects::effect_catalog(); - let mut out = vec![ - c("Apply Last Effect", "effect.applyLast"), - c("Last Effect…", "effect.last"), - Sep, - c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), - Sep, - Item::Header("Vector Effects"), - ]; - // Submenus (and Crop Marks and Rasterize…, items of their own) in the reference app's order. - let order = [ - "3D and Materials", - "Color Adjustments", - "Convert to Shape", - "Crop Marks", - "Distort & Transform", - "Path", - "Pathfinder", - "Rasterize…", - "Stylize", - "SVG Filters", - "Warp", - ]; - let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label); - // An effect goes in the vector submenu its path names unless it is a Photoshop-style effect, - // which goes in the raster submenu of the same name when `RASTER_MENUS` has one (Stylize › - // Glowing Edges). - let in_vector_menu = - |e: &vectorcraft_effects::EffectInfo| order.iter().any(|s| e.menu == ["Effect", *s]) && !vectorcraft_effects::PIXEL_EFFECTS.contains(&e.id); - let item = |e: &vectorcraft_effects::EffectInfo| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { - // No options (Effect → Pathfinder, Crop Marks): apply directly, like Illustrator. - true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), - false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), - }; - // The effects whose catalogue menu path is `path`. - // `vector` picks the ones for the vector submenu or the ones for the raster submenu. - let items_at = - |path: &[&str], vector: bool| -> Vec { cat.iter().filter(|e| e.menu == path && in_vector_menu(e) == vector).map(item).collect() }; - for sub_name in order { - if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { - out.push(item(e)); - continue; - } - let items = items_at(&["Effect", sub_name], true); - if items.is_empty() { - let placeholder = match sub_name { - "3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")], - "SVG Filters" => vec![todo("Apply SVG Filter…")], - _ => continue, - }; - out.push(sub(sub_name, placeholder)); - } else { - let mut items = items; - if sub_name == "3D and Materials" { - // In the panel's order of 3D types, then Materials (not built yet). - let rank = |label: &str| ["Extrude & Bevel…", "Revolve…", "Inflate…", "Rotate…"].iter().position(|l| *l == label).unwrap_or(9); - items.sort_by_key(|i| match i { - Item::Cmd(label, ..) => rank(label), - _ => 9, - }); - items.push(todo("Materials…")); - } - out.push(sub(sub_name, items)); - } - } - // Live-effect plug-ins close the vector effects. - out.extend(crate::dialogs::plugin::effect_menu()); - let raster: Vec = RASTER_MENUS - .iter() - .filter_map(|(name, path)| { - let items = items_at(path, false); - (!items.is_empty()).then(|| sub(name, items)) - }) - .collect(); - if !raster.is_empty() { - out.push(Sep); - out.push(Item::Header("Raster Effects")); - out.push(c("Effect Gallery…", "ui.effectGalleryDialog")); - out.extend(raster); - } - // Anything not placed above (future effects) still shows up. - let placed = |e: &vectorcraft_effects::EffectInfo| top_level(e) || in_vector_menu(e) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path); - for e in cat.iter().filter(|e| !placed(e)) { - out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); - } - out -} - -/// The shortcut a menu item shows: its command's (items without params), or a panel's for the -/// Window menu's `window.panel {panel}` items. -pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { - match (id, p.get("panel").and_then(Value::as_str)) { - ("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel), - _ if p.is_null() => shortcut_of(id), - _ => None, - } -} - -/// Labels only the canvas context menu ([`context_items`]) shows, so the catalog tests can insist -/// every one of them is translated (a test checks the list against the menu). -pub const CONTEXT_LABELS: &[&str] = &[ - "Isolate Selected Group", - "Exit Isolation Mode", - "Lock Artboards", - "Lock Selected", - "Lock All Except Selected", - "Lock All", - "Unlock Artboards", - "Unlock Selected", - "Unlock", - "Make Clipping Mask", - "Release Clipping Mask", - "Make Compound Path", - "Release Compound Path", - "Select All", -]; - -/// The labels [`dynamic_label`] can show in place of an item's own (Show/Hide pairs and the like), -/// so the catalog tests can insist every one of them is translated. -pub const DYNAMIC_LABELS: &[&str] = &[ - "Preview", - "Outline", - "Hide Edges", - "Show Edges", - "Hide Corner Widget", - "Show Corner Widget", - "Hide Text Threads", - "Show Text Threads", - "Hide Hidden Characters", - "Show Hidden Characters", - "Hide Gradient Annotator", - "Show Gradient Annotator", - "Hide Live Paint Gaps", - "Show Live Paint Gaps", - "Hide Artboards", - "Show Artboards", - "Hide Rulers", - "Show Rulers", - "Hide Video Rulers", - "Show Video Rulers", - "Change to Global Rulers", - "Change to Artboard Rulers", - "Hide Bounding Box", - "Show Bounding Box", - "Hide Transparency Grid", - "Show Transparency Grid", - "Hide Guides", - "Show Guides", - "Hide Grid", - "Show Grid", - "Unlock Guides", - "Lock Guides", - "Show Slices", - "Hide Slices", - "Hide Template", - "Show Template", - "Hide Print Tiling", - "Show Print Tiling", - "Edit Envelope", - "Edit Contents", - "Start Global Edit", - "Stop Global Edit", - "Undo", - "Redo", - "Unlock Grid", - "Lock Grid", -]; - -/// Every English string the menus, the command palette, the panel registry, the toolbar and the -/// Preferences dialog can show: what a complete language catalog has to cover. Names that are user -/// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the -/// view angles are left out. -pub fn menu_strings() -> std::collections::BTreeSet { - use std::collections::BTreeSet; - fn user_data(id: &str, params: &Value) -> bool { - hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params) - } - fn walk(items: &[Item], under: &str, out: &mut BTreeSet) { - for it in items { - match it { - Item::Cmd(l, id, params) => { - let size = l.strip_suffix(" pt").is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())); - // View › Rotate View lists angles: an optional minus sign, digits and a degree sign. - let angle = l - .strip_suffix('°') - .map(|n| n.strip_prefix('-').unwrap_or(n)) - .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); - // VectorCraft › Language lists each language by its own name. - let language_name = *id == "app.language" && crate::i18n::Lang::all().any(|lang| lang.name() == *l); - if !user_data(id, params) && !size && !angle && !language_name && *l != "—" { - out.insert(l.to_string()); - } - } - Item::Todo(l, _) | Item::Header(l) => { - out.insert(l.to_string()); - } - Item::Sub(l, children) => { - out.insert(l.to_string()); - // Type › Font lists the installed families: names, not UI text. - if !(under == "Type" && *l == "Font") { - walk(children, l, out); - } - } - Item::Sep => {} - } - } - } - let mut out = BTreeSet::new(); - for (title, items) in menu_tree() { - out.insert(title.to_string()); - walk(&items, title, &mut out); - } - out.extend(crate::native_menu::MAC_LABELS.iter().map(|s| s.to_string())); - out.extend(DYNAMIC_LABELS.iter().map(|s| s.to_string())); - out.extend(CONTEXT_LABELS.iter().map(|s| s.to_string())); - out.extend(UI_COMMANDS.iter().map(|c| c.1.to_string())); - for c in vectorcraft_engine::cmd::command_specs() { - out.insert(c.label.to_string()); - out.extend(c.menu.iter().map(|m| m.to_string())); - } - out.extend(crate::state::all_panels().map(|(_, label)| label.to_string())); - out.extend(vectorcraft_tools::catalog::all_tools().map(|t| t.label.to_string())); - out.extend(crate::toolbar::BASIC.iter().map(|c| c.0.to_string())); - out.extend(vectorcraft_color::BlendMode::ALL.iter().map(|m| m.label().to_string())); - out.extend(vectorcraft_effects::effect_catalog().iter().map(|e| e.label.to_string())); - out.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|c| c.to_string())); - for sp in vectorcraft_engine::cmd::prefscmds::PREF_SPECS { - out.insert(sp.label.to_string()); - if !sp.section.is_empty() { - out.insert(sp.section.to_string()); - } - if let vectorcraft_engine::cmd::prefscmds::PrefKind::Choice(opts) = sp.kind { - out.extend(opts.iter().map(|o| o.1.to_string())); - } - } - out.extend(crate::prefs_dialog::UI_FIELDS.iter().map(|f| f.2.to_string())); - out.extend(crate::dialogs::button_labels().into_iter().map(str::to_string)); - out.extend(crate::dialogs::FILM_GRAIN_FIELDS.iter().map(|(_, label, _)| label.to_string())); - out.extend(crate::dialogs::PAINT_DAUB_BRUSH_TYPE_LABELS.iter().map(|label| label.to_string())); - out.extend(crate::chrome::hint_strings().into_iter().map(str::to_string)); - out.remove(""); - out -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs new file mode 100644 index 000000000..b00d500d9 --- /dev/null +++ b/crates/ui-egui/src/menus/bar.rs @@ -0,0 +1,172 @@ +use super::entries::{Row, entry, pretty_shortcut}; +use super::invoke::{field_event, focused_text_field, invoke, to_field}; +use super::tree::menu_tree_named; +use super::*; + +/// Render the menu bar. +/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width. +pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { + let t = Tokens::get(ui.ctx()); + let ctx = ui.ctx().clone(); + // The press that opens a menu takes the keyboard from a focused text field: the field and its + // selection are kept while the menus are open, so their Select All, Cut, Copy and Paste act on + // its text (#516), the frame after the click that chose them (that click takes the keyboard + // from it again). + type Field = (egui::Id, egui::text_edit::TextEditState); + let (field_key, chosen_key) = (egui::Id::unique("menu-bar-field"), egui::Id::unique("menu-bar-field-event")); + if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) { + ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key)); + state.store(&ctx, field); + to_field(&ctx, field, e); + } + if let Some(field) = focused_text_field(&ctx).and_then(|id| Some((id, egui::TextEdit::load_state(&ctx, id)?))) { + ctx.data_mut(|d| d.insert_temp::(field_key, field)); + } + let mut clicked: Option<(String, Value)> = None; + let tree = menu_tree_named(app.session.prefs.font_names_in_english); + // No application menu in the window (as in Illustrator): the bar starts at File on every + // platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there. + let tree: Vec<(&str, Vec)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect(); + // A menu bar for its layout and menu-button style, but the titles use `Popup::menu` directly: + // egui 0.37's `MenuButton` opens the title under a resting pointer while another menu is open, + // and the app switches titles on movement only (`switch_on_hover`, the native behaviour). + let (end, open) = egui::MenuBar::new() + .ui(ui, |ui| { + let mut titles = Vec::with_capacity(tree.len()); + for (title, items) in tree.iter() { + let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text); + let resp = ui.add(egui::Button::new(text)); + egui::Popup::menu(&resp).show(|ui| menu_body(app, ui, items, &mut clicked)); + titles.push(resp); + } + (ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles)) + }) + .inner; + let field = ctx.data(|d| d.get_temp::(field_key)); + if !open && focused_text_field(&ctx).is_none() { + ctx.data_mut(|d| d.remove::(field_key)); + } + if let Some((id, p)) = clicked { + match field.and_then(|f| Some((f, field_event(app, &id)?))) { + Some(chosen) => { + ctx.data_mut(|d| d.insert_temp(chosen_key, chosen)); + ctx.request_repaint(); + } + None => invoke(app, &id, p), + } + } + end +} + +/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no +/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must +/// not switch to the title under it). +pub(super) fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool { + title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id) +} + +/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another +/// title opens that menu instead (egui alone needs a click on each title). Returns whether a menu +/// is open. +fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool { + let ids: Vec = titles.iter().map(egui::Popup::default_response_id).collect(); + let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else { + return false; + }; + // `Response::hovered` is false while a menu's popup is open, so hit-test the titles here. + // Only a moving pointer switches: one resting on a title leaves the open menu alone. + let moving = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO); + if let Some(p) = ctx.pointer_hover_pos().filter(|_| moving) + && let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) + && i != open + && let Some(id) = ids.get(i) + { + egui::Popup::open_id(ctx, *id); + ctx.request_repaint(); + } + true +} + +/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt; +/// it scrolls when it is taller than the window. +pub(super) fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(230.0); + render_items(app, ui, items, true, clicked); + }); +} + +/// `items` as menu buttons; with `checks`, items that can be on or off show a check mark (or the +/// room for one). +pub(super) fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: bool, clicked: &mut Option<(String, Value)>) { + let t = Tokens::get(ui.ctx()); + for e in items.iter().filter_map(|it| entry(app, it)) { + match e { + Entry::Sep => { + ui.separator(); + } + Entry::Header(h) => { + ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim)); + } + Entry::Sub(label, children) => { + ui.menu_button(label, |ui| { + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(200.0); + render_items(app, ui, children, checks, clicked); + }); + }); + } + Entry::Item(row) => render_row(app, ui, &row, checks, clicked), + } + } +} + +/// One menu item: its label (with a check mark, or the room for one, under `checks`), its shortcut, +/// and Type › Font's samples. +fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, clicked: &mut Option<(String, Value)>) { + let sc = row.shortcut.map(pretty_shortcut).unwrap_or_default(); + let Some((id, p)) = row.command else { + ui.add_enabled_ui(false, |ui| { + ui.add(egui::Button::new(row.label.as_ref()).shortcut_text(sc)); + }) + .response + .on_disabled_hover_text(tl!("Coming soon — tracked in the parity plan")); + return; + }; + let text = match row.checked.filter(|_| checks) { + Some(true) => format!("✓ {}", row.label), + Some(false) => format!(" {}", row.label), + None => row.label.to_string(), + }; + // Type → Font: each family's sample after its name (Enable in-menu font previews). + let sampled = p.get("font").and_then(Value::as_str).filter(|_| id == "text.setStyle" && app.session.prefs.font_preview); + let r = match sampled { + Some(family) => { + let slot = egui::IdSalt::new(("font-sample", family)); + let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE)); + let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner; + if let Some(rect) = out.rect(slot) { + crate::font_menu::menu_item_sample(ui, rect, family); + } + out.response + } + None => ui.add_enabled(row.enabled, egui::Button::new(text).shortcut_text(sc)), + }; + if r.clicked() { + *clicked = row.target(); + ui.close(); + } +} + +/// What the Home screen remembers when it opens (`app.home`): the active document and the +/// document count. When either changes, the Home screen gives way to the document. +pub(crate) fn home_key(app: &VectorcraftApp) -> (Option, usize) { + (app.session.active().map(|d| d.uid), app.session.documents().len()) +} + +/// Whether the Home screen is up: chosen with the Home button (`app.home`), or no document is open +/// and Preferences › General › Show The Home Screen When No Documents Are Open is on (#394). Off, +/// an app with no document shows an empty window, and the Home button still opens the screen. +pub(crate) fn home_showing(app: &VectorcraftApp) -> bool { + app.ui.home.is_some() || (app.session.active().is_none() && app.session.prefs.show_home_screen) +} diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs new file mode 100644 index 000000000..8a6bfbf44 --- /dev/null +++ b/crates/ui-egui/src/menus/context.rs @@ -0,0 +1,173 @@ +use super::bar::render_items; +use super::state::enabled; +use super::tree::{arrange_items, transform_items}; +use super::*; + +/// The canvas context menu (right-click): what applies to the selection, or to the view when +/// nothing is selected. Commands that can't run now are left out rather than greyed out. +pub fn context_items(app: &VectorcraftApp) -> Vec { + use vectorcraft_doc::NodeKind; + let Some(st) = app.session.active() else { return vec![] }; + let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect(); + let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind)); + let several = roots.len() >= 2; + let mut v = vec![c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep]; + if st.isolation.is_some() { + v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]); + } + // Managing artboards from the canvas: with the Artboard tool, the menu locks and unlocks them + // (`artboard.setProps` with `locked` for each artboard named; an entry with none to change is left out). + if app.session.tool_id() == "artboard" { + let n = st.doc.artboards.len(); + let sel = crate::panels::artboards::picked(app, n); + let others: Vec = (0..n).filter(|i| !sel.contains(i)).collect(); + let entry = |label, idx: Vec, locked: bool| { + let list: Vec = idx.iter().map(|i| json!({ "index": i, "locked": locked })).collect(); + (!list.is_empty()).then(|| cp(label, "artboard.setProps", json!({ "artboards": list }))) + }; + let lock: Vec = + [entry("Lock Selected", sel.clone(), true), entry("Lock All Except Selected", others, true), entry("Lock All", (0..n).collect(), true)] + .into_iter() + .flatten() + .collect(); + let unlock: Vec = [entry("Unlock Selected", sel, false), entry("Unlock All", (0..n).collect(), false)].into_iter().flatten().collect(); + for (label, items) in [("Lock Artboards", lock), ("Unlock Artboards", unlock)] { + if !items.is_empty() { + v.push(sub(label, items)); + } + } + v.push(Sep); + } + // The locked objects under the pointer at the right-click, each unlocked as the Layers panel's + // lock column does. `unlock_item_label` shows each object's name. + let unlock: Vec = app + .ui + .context_locked + .iter() + .filter(|(uid, _)| *uid == st.uid) + .flat_map(|(_, ids)| ids) + .filter(|id| st.doc.node(**id).is_some_and(|n| n.locked)) + .map(|id| cp("Unlock", "layer.setProps", json!({ "ids": [id.0], "locked": false }))) + .collect(); + if !unlock.is_empty() { + v.extend([sub("Unlock", unlock), Sep]); + } + if roots.is_empty() { + v.extend([ + c("Paste", "edit.paste"), + Sep, + c("Zoom In", "view.zoomIn"), + c("Zoom Out", "view.zoomOut"), + c("Fit Artboard in Window", "view.fitArtboard"), + Sep, + c("Show Rulers", "view.rulers"), + c("Show Grid", "view.grid"), + c("Hide Guides", "view.guides"), + c("Lock Guides", "view.guides.lock"), + Sep, + c("Select All", "select.all"), + ]); + } else { + v.extend([c("Cut", "edit.cut"), c("Copy", "edit.copy"), c("Paste", "edit.paste"), Sep]); + if let [one] = roots.as_slice() + && one.is_container() + { + v.push(c("Isolate Selected Group", "object.isolate")); + } + if several { + v.push(c("Group", "object.group")); + } + if any(|k| matches!(k, NodeKind::Group { clip: false, .. })) { + v.push(c("Ungroup", "object.ungroup")); + } + let paths = any(|k| matches!(k, NodeKind::Path { .. })); + if paths { + v.extend([c("Join", "path.join"), c("Average…", "path.average")]); + } + if !st.selection.anchors.is_empty() { + v.extend([c("Remove Anchor Points", "path.removeAnchors"), c("Cut Path at Selected Anchor Points", "path.cutAtAnchors")]); + } + if several { + v.push(c("Make Clipping Mask", "object.clippingMask.make")); + } + if any(|k| matches!(k, NodeKind::Group { clip: true, .. })) { + v.push(c("Release Clipping Mask", "object.clippingMask.release")); + } + if several && paths { + v.push(c("Make Compound Path", "object.compoundPath.make")); + } + // Chooses the Objects on Path tool, whose click on a path attaches the selection to it. + if several { + v.push(c("Objects on Path", "object.objectsOnPath.attach")); + } + if any(|k| matches!(k, NodeKind::Compound { .. })) { + v.push(c("Release Compound Path", "object.compoundPath.release")); + } + // The paragraphs of the selected type, or those the Type tool's selection touches. + if crate::panels::character::text_style(app).is_some() { + v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")])); + } + v.extend([ + c("Make Guides", "view.guides.make"), + Sep, + sub("Transform", transform_items()), + sub("Arrange", arrange_items()), + sub( + "Select", + vec![c("Next Object Above", "select.nextAbove"), c("Next Object Below", "select.nextBelow"), Sep, c("Deselect", "select.none")], + ), + Sep, + c("Export Selection…", "file.exportSelection"), + ]); + } + prune(app, v) +} + +/// `items` without the commands that can't run now (Undo and Redo stay, greyed out), submenus +/// left empty, and separators that no longer separate anything. +fn prune(app: &VectorcraftApp, items: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(items.len()); + for it in items { + let it = match it { + Item::Sub(l, children) => Item::Sub(l, prune(app, children)), + it => it, + }; + let keep = match &it { + Item::Cmd(_, id, _) => matches!(*id, "edit.undo" | "edit.redo") || enabled(app, id), + Item::Sub(_, children) => !children.is_empty(), + Item::Sep => out.last().is_some_and(|l| !matches!(l, Item::Sep)), + _ => true, + }; + if keep { + out.push(it); + } + } + if matches!(out.last(), Some(Item::Sep)) { + out.pop(); + } + out +} + +/// The canvas context menu's popup; the item clicked goes in `clicked`, for [`invoke`]. +pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(200.0); + // Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter. + render_items(app, ui, &context_items(app), false, clicked); + }); +} + +/// The document units a right-click on a ruler offers: each runs `document.setUnits`, exactly as +/// Preferences ▸ Units ▸ General does, with the current one checked ([`checked`]). +pub fn ruler_unit_items() -> Vec { + vectorcraft_engine::doc::Unit::ALL.iter().map(|u| cp(u.label(), "document.setUnits", json!({ "units": u.label() }))).collect() +} + +/// The ruler context menu's popup (right-click a ruler or the origin box); the unit clicked goes in +/// `clicked`, for [`invoke`]. +pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(160.0); + render_items(app, ui, &ruler_unit_items(), true, clicked); + }); +} diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs new file mode 100644 index 000000000..fab26507c --- /dev/null +++ b/crates/ui-egui/src/menus/entries.rs @@ -0,0 +1,147 @@ +use super::context::context_items; +use super::invoke::click_target; +use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, unlock_item_label}; +use super::state::{shortcut_of, shown_state}; +use super::tree::menu_tree_named; +use super::*; + +/// A menu item as it shows now ([`entry`]). +pub struct Row<'a> { + /// The command and its params; `None` for an item not implemented yet (always disabled). + pub command: Option<(&'static str, &'a Value)>, + /// The tree's own English label, which [`click_target`] reads. + pub source: &'static str, + /// The label in the UI language, as it reads now (Undo *Move*, Show/Hide…, a recent file). + pub label: std::borrow::Cow<'static, str>, + /// The shortcut as the registry writes it (`Cmd+Shift+Z`), the user's overrides applied. + pub shortcut: Option<&'static str>, + pub enabled: bool, + /// `Some` for an item that is on or off. + pub checked: Option, +} + +impl Row<'_> { + /// What choosing it runs ([`click_target`]); `None` for an item not implemented yet. + pub fn target(&self) -> Option<(String, Value)> { + self.command.map(|(id, p)| click_target(self.source, id, p)) + } +} + +/// Item `it` as the menus show it now, or `None` while they leave it out ([`shown_state`]). +pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { + use crate::i18n::t; + Some(match it { + Item::Sep => Entry::Sep, + Item::Header(h) => Entry::Header(t(h)), + Item::Sub(label, children) => Entry::Sub(t(label), children), + Item::Todo(label, sc) => Entry::Item(Row { + command: None, + source: label, + label: t(label).into(), + shortcut: Some(*sc).filter(|s| !s.is_empty()), + enabled: false, + checked: None, + }), + Item::Cmd(label, id, p) => { + let (enabled, checked) = shown_state(app, id, p)?; + Entry::Item(Row { + command: Some((id, p)), + source: label, + label: effect_item_label(id, p) + .or_else(|| unlock_item_label(app, id, p, crate::i18n::current())) + .unwrap_or_else(|| display_label(app, id, label)) + .into(), + shortcut: item_shortcut(id, p), + enabled, + checked, + }) + } + }) +} + +/// `Cmd+Shift+]` → `⇧⌘]` on macOS, `Ctrl+Shift+]` elsewhere. +pub fn pretty_shortcut(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + if cfg!(target_os = "macos") { + let mut mods = String::new(); + let parts: Vec<&str> = s.split('+').collect(); + let (key, ms): (&str, &[&str]) = if s.ends_with("++") { + ("+", parts.get(..parts.len().saturating_sub(2)).unwrap_or_default()) + } else { + parts.split_last().map_or(("", &[][..]), |(k, rest)| (*k, rest)) + }; + for m in ["Ctrl", "Alt", "Shift", "Cmd"] { + if ms.contains(&m) { + mods.push_str(match m { + "Ctrl" => "⌃", + "Alt" => "⌥", + "Shift" => "⇧", + _ => "⌘", + }); + } + } + format!("{mods}{key}") + } else { + s.replace("Cmd", "Ctrl") + } +} + +#[derive(Serialize)] +pub struct MenuEntry { + pub path: Vec, + pub label: String, + pub command: Option, + pub params: Value, + pub enabled: bool, + pub shortcut: String, +} + +/// Flattened menu for `ui.menu.list`. +pub fn menu_entries(app: &VectorcraftApp) -> Vec { + let mut out = vec![]; + for (title, items) in menu_tree_named(app.session.prefs.font_names_in_english) { + flatten(app, vec![title.to_string()], &items, &mut out); + } + out +} + +/// Flattened canvas context menu for `ui.contextMenu.list` (paths are its submenus). +pub fn context_entries(app: &VectorcraftApp) -> Vec { + let mut out = vec![]; + flatten(app, vec![], &context_items(app), &mut out); + out +} + +fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { + for it in items { + match it { + Item::Cmd(l, id, p) => { + let Some((enabled, _)) = shown_state(app, id, p) else { continue }; + out.push(MenuEntry { + path: path.clone(), + label: unlock_item_label(app, id, p, crate::i18n::Lang::EN).unwrap_or_else(|| dynamic_label(app, id, l)), + command: Some(id.to_string()), + params: p.clone(), + enabled, + shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(), + }) + } + Item::Todo(l, sc) => out.push(MenuEntry { + path: path.clone(), + label: l.to_string(), + command: None, + params: Value::Null, + enabled: false, + shortcut: sc.to_string(), + }), + Item::Sub(l, ch) => { + let mut p = path.clone(); + p.push(l.to_string()); + flatten(app, p, ch, out); + } + _ => {} + } + } +} diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs new file mode 100644 index 000000000..9b4dd262f --- /dev/null +++ b/crates/ui-egui/src/menus/invoke.rs @@ -0,0 +1,279 @@ +use super::state::{SUBJECT_COMMANDS, has_subject, waits_for_ime}; +use super::*; + +/// What a menu click runs: items whose label ends with "…" and that carry default params open a +/// generic parameter dialog (fields = the defaults) instead of running immediately. +pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { + let dialog = label.ends_with('…') + && p.as_object().is_some_and(|o| !o.is_empty()) + && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog"); + if dialog { + return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p})); + } + (id.to_string(), if p.is_null() { json!({}) } else { p.clone() }) +} + +/// The dialog of command `id` that shows the selection's current values (its query): its heading +/// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they +/// are. +fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> { + Some(match id { + // Whether the text overflows, and Shrink Text's factor, are read-only facts. + "text.areaOptions" => ("Area Type Options", &["overflow", "fitScale"]), + "type.pathOptions" => ("Type on a Path Options", &["start", "end"]), + _ => return None, + }) +} + +/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does. +pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> { + Some(match id { + // Move opens on the offset moved last and the checkboxes it was last confirmed with. + "object.move" => ("move", crate::dialogs::transform::move_fields(app)), + "object.rotate" => ("rotate", json!({"angle": 0})), + "object.scale" => ("scale", json!({"sx": 100, "sy": 100, "uniform": true})), + // Transform Each opens on the values it was last confirmed with (#1732). + "object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::open_fields(app)), + "object.reflect" => ("reflect", json!({"axis": "vertical", "angle": 90})), + "object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})), + "path.average" => ("average", json!({"axis": "both"})), + // Offset Path opens on the values it was last confirmed with. + "object.path.offsetPath" if app.ui.offset_path.is_object() => ("offsetPath", app.ui.offset_path.clone()), + "object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})), + // Simplify opens its slider, or the dialog on the fields it was last confirmed with when + // "Open this dialog with these settings next time" was ticked. + "object.path.simplify" if app.ui.simplify.is_object() => ("simplify", app.ui.simplify.clone()), + "object.path.simplify" => (crate::dialogs::simplify_slider::KIND, crate::dialogs::simplify_slider::fields(app)), + "object.path.splitIntoGrid" => { + // The grid starts at the size of the selection. + let size = + app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false)).map_or((0.0, 0.0), |b| (b.width(), b.height())); + ("splitIntoGrid", crate::dialogs::path_ops::split_fields(size.0, size.1)) + } + "object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()), + "artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::fields()), + _ => return None, + }) +} + +/// The event a focused text field takes for Edit-menu command `id` (Select All, Cut, Copy, +/// Paste), as the same keys would send it. +pub(super) fn field_event(app: &mut VectorcraftApp, id: &str) -> Option { + Some(match id { + "select.all" => egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }, + "edit.copy" => egui::Event::Copy, + "edit.cut" => egui::Event::Cut, + "edit.paste" | "edit.pasteWithoutFormatting" => egui::Event::Paste(app.written_clipboard_text()?), + _ => return None, + }) +} + +/// Invoke an item clicked in the system menu bar (macOS; its key equivalents come back to egui as +/// key presses, see [`crate::native_menu`]). While a text field has the keyboard, Select All, Cut, +/// Copy and Paste act on the field's text, as their keys do. While its keys select every artboard +/// ([`crate::panels::artboards::selects_all`]), Select All does too. Everything else (and those +/// commands with no field focused) goes to [`invoke`]. +pub fn invoke_from_system_menu(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str, p: Value) { + match focused_text_field(ctx).and_then(|f| Some((f, field_event(app, id)?))) { + Some((field, e)) => to_field(ctx, field, e), + None if id == "select.all" && crate::panels::artboards::selects_all(app, ctx) => crate::panels::artboards::select_all(app), + None => invoke(app, id, p), + } +} + +/// The text field that has the keyboard, if any. +pub(super) fn focused_text_field(ctx: &egui::Context) -> Option { + ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some()) +} + +/// Give text field `field` the keyboard (back) and event `e`, a [`field_event`], this frame. +pub(super) fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) { + ctx.memory_mut(|m| m.request_focus(field)); + ctx.input_mut(|i| i.events.push(e)); +} + +/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input). +/// Object › Graph › Type… or Data… (`id`): its dialog, with the selected graph's current values +/// (the command's query). +pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result { + // Graph Data: the worksheet. + if id == "graph.setData" { + return crate::dialogs::graph_data::open(app); + } + let v = app.session.execute(id, &json!({})).map_err(|e| e.to_string())?; + let (label, fields) = ("Graph Type", v); + app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})).map(|_| json!({ "dialog": "command" })) +} + +pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { + if waits_for_ime(app, id) { + return; + } + if SUBJECT_COMMANDS.contains(&id) && p.get("ids").is_none() && p.get("id").is_none() && !has_subject(app) { + return; + } + // Params first: some dialogs' fields take a look through the session (Move's last move). + if p.as_object().is_none_or(|o| o.is_empty()) + && let Some((kind, fields)) = menu_dialog(app, id) + { + app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); + return; + } + // Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection. + if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::envelope::open(app, id) { + app.status(e); + } + return; + } + // Variables (data merge): a name and a kind, a name and a row of values. + if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { + crate::dialogs::variables::open(app, id, None); + return; + } + // Pathfinder Options: a dialog with the current options. + if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) { + crate::dialogs::pathfinder_options::open(app); + return; + } + // Repeat Options: a dialog with the selected repeat's current values. + if id == "object.repeat.options" + && p.as_object().is_none_or(|o| o.is_empty()) + && let Some(fields) = crate::panels::pattern_options::repeat_fields(app) + { + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields})); + return; + } + // Area Type Options and Type on a Path Options: dialogs with the selected type's current + // values (the command's query), less those the dialog leaves alone. + if let Some((label, hidden)) = queried_dialog(id) + && p.as_object().is_none_or(|o| o.is_empty()) + { + match app.session.execute(id, &json!({})) { + Ok(mut fields) => { + if let Some(o) = fields.as_object_mut() { + o.retain(|k, _| !hidden.contains(&k.as_str())); + } + let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})); + } + Err(e) => app.status(e.to_string()), + } + return; + } + // Object → Graph → Type… / Data…: dialogs with the selected graph's current values. + if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = graph_dialog(app, id) { + app.status(e); + } + return; + } + // Object → Graph → Design… / Column… / Marker…: the document's graph designs. + if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) { + let r = match id { + "graph.design" => crate::dialogs::graph_design::open_design(app), + "graph.column" => crate::dialogs::graph_design::open_column(app), + _ => crate::dialogs::graph_design::open_marker(app), + }; + if let Err(e) = r { + app.status(e); + } + return; + } + // New View…: a name dialog, starting from the first free "View N". + if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) { + let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedView::default_name(&d.doc.views)).unwrap_or_default(); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}})); + return; + } + // Edit Views…: the saved views in a list, to rename or delete. + if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::edit_views::open(app) { + app.status(e); + } + return; + } + // Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with + // the view's angle filled in. + if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.view_mut().map(|v| v.rotation) { + Some(angle) => { + app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle}))); + } + None => app.status("no document"), + } + return; + } + // Save Selection…: a name dialog, starting from the first free "Selection N". + if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { + let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default(); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}})); + return; + } + // Edit Selection…: the saved selections in a list, to rename or delete. + if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::edit_selection::open(app) { + app.status(e); + } + return; + } + // Document Raster Effects Settings and File Info: their dialogs. + if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) { + let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) }; + if let Err(e) = r { + app.status(e); + } + return; + } + // Rasterize…: its options, starting from the document's raster effects settings. + if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute("document.rasterEffectsSettings", &json!({})) { + Ok(v) => { + let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]}); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params})); + } + Err(e) => app.status(e.to_string()), + } + return; + } + // Text Wrap Options: a dialog with the selected wrap object's current values. + if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute(id, &json!({})) { + Ok(fields) => { + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields})); + } + Err(e) => app.status(e.to_string()), + } + return; + } + // Blend Options…: its dialog, on the selected blend's options. + if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::blend_options::open(app) { + app.status(e); + } + return; + } + // Expand…: its dialog. + if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::expand::open(app) { + app.status(e); + } + return; + } + // Help links: the command returns the URL; a menu click opens it (agents just get the URL). + if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") { + app.open_link(id); + return; + } + // Object → Align aligns to what the align buttons align to, unless the call names `to`. With + // anchors selected (Direct Selection) it sends its params unchanged, and the points align to + // their own bounds. + let follows_align_to = id == "object.align" + && p.as_object().is_some_and(|o| !o.contains_key("to")) + && app.session.active().is_some_and(|st| st.selection.anchors.is_empty()); + let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p }; + if let Err(e) = app.run(id, p) { + app.status(e); + } else if matches!(id, "object.pattern.make" | "object.pattern.edit") { + app.ui.open_panel = Some("patternOptions".into()); + } +} diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs new file mode 100644 index 000000000..12afaf058 --- /dev/null +++ b/crates/ui-egui/src/menus/labels.rs @@ -0,0 +1,333 @@ +use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; +use super::state::{hidden_when_disabled, shortcut_of}; +use super::tree::menu_tree; +use super::ui_commands::UI_COMMANDS; +use super::*; + +/// Label for toggles whose text flips (Outline/Preview, Hide/Show …). +pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { + let v = &app.ui.view; + match id { + "view.outline" => if v.outline { "Preview" } else { "Outline" }.into(), + "window.workspace.reset" => format!("Reset {}", app.ui.workspace), + "view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(), + "view.template" => if v.templates { "Hide Template" } else { "Show Template" }.into(), + "view.cornerWidget" => if v.corner_widgets { "Hide Corner Widget" } else { "Show Corner Widget" }.into(), + "view.textThreads" => if v.text_threads { "Hide Text Threads" } else { "Show Text Threads" }.into(), + "type.hiddenCharacters" => if v.hidden_chars { "Hide Hidden Characters" } else { "Show Hidden Characters" }.into(), + "view.gradientAnnotator" => if v.gradient_annotator { "Hide Gradient Annotator" } else { "Show Gradient Annotator" }.into(), + "view.livePaintGaps" => if v.live_paint_gaps { "Hide Live Paint Gaps" } else { "Show Live Paint Gaps" }.into(), + "effect.last" => match &app.last_effect { + Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())), + None => label.into(), + }, + id if id.starts_with("type.recentFont") => { + let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); + n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into()) + } + id if saved_view_slot(id).is_some() => saved_view(app, id).map(|v| v.name.clone()).unwrap_or_else(|| "—".into()), + id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()), + "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), + "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), + "view.videoRulers" => if v.video_rulers && v.rulers { "Hide Video Rulers" } else { "Show Video Rulers" }.into(), + "view.globalRulers" => if app.session.active().is_some_and(|d| d.doc.rulers == vectorcraft_doc::RulerMode::Global) { + "Change to Artboard Rulers" + } else { + "Change to Global Rulers" + } + .into(), + "view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(), + "view.transparencyGrid" => { + if app.session.active().is_some_and(|d| d.transparency_grid) { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into() + } + "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), + "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), + "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), + "view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(), + "view.printTiling" => if app.session.active().is_some_and(|d| d.print_tiling) { "Hide Print Tiling" } else { "Show Print Tiling" }.into(), + id if id.starts_with("file.openRecent") => recent_slot(app, id) + .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) + .unwrap_or_else(|| "—".into()), + // Edit Contents reads Edit Envelope while an envelope's contents are being edited. + "object.envelope.editContents" => { + let editing = app.session.active().is_some_and(|st| { + st.selection + .objects + .first() + .and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id)) + .is_some_and(|e| matches!(e.kind, vectorcraft_doc::NodeKind::Envelope { editing: true, .. })) + }); + if editing { "Edit Envelope" } else { "Edit Contents" }.into() + } + "select.globalEdit" => { + if app.session.active().is_some_and(|d| d.global_edit.is_some()) { "Stop Global Edit" } else { "Start Global Edit" }.into() + } + "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), + "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), + id if id.starts_with(crate::panels::swatches::USER_SLOT) => { + crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { + crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::panels::symbols::USER_SLOT) => { + crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::panels::brushes::USER_SLOT) => { + crate::panels::brushes::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { + crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into()) + } + "perspective.grid.show" => if perspective_grid(app).is_some_and(|g| g.visible) { "Hide Grid" } else { "Show Grid" }.into(), + "perspective.grid.rulers" => if perspective_grid(app).is_some_and(|g| g.rulers) { "Hide Rulers" } else { "Show Rulers" }.into(), + "perspective.grid.lock" => if perspective_grid(app).is_some_and(|g| g.locked) { "Unlock Grid" } else { "Lock Grid" }.into(), + _ => label.into(), + } +} + +/// Does the menu item `id` show a name that is user, file or system data rather than an interface +/// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a +/// plug-in? Such names are shown as they are, never translated (a workspace the user calls +/// "Layers" stays "Layers"). +pub(super) fn shows_a_name(id: &str, label: &str) -> bool { + const SLOTS: [&str; 8] = [ + "file.openRecent", + "view.goto", + "type.recentFont", + crate::panels::swatches::USER_SLOT, + crate::panels::graphic_styles::USER_SLOT, + crate::panels::symbols::USER_SLOT, + crate::panels::brushes::USER_SLOT, + crate::dialogs::perspective_presets::SLOT, + ]; + SLOTS.iter().any(|s| id.starts_with(s)) + || saved_selection_slot(id).is_some() + || matches!(id, "text.setStyle" | "plugin.dialog") + || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) + || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) +} + +/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or +/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds +/// while another thread installs or removes plug-ins. +pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { + id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() +} + +/// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled +/// around a name (Undo *Move*, Reset *Essentials*, Last Effect: *Drop Shadow*) are translated as +/// templates so the name can move; names that are user data (files, views, fonts, custom +/// workspaces, plug-ins: [`shows_a_name`]) pass through. +pub fn display_label(app: &VectorcraftApp, id: &str, label: &str) -> String { + let lang = crate::i18n::current(); + let fmt = crate::i18n::fmt; + match id { + "edit.undo" | "edit.redo" => { + let last = app.session.active().and_then(|d| if id == "edit.undo" { d.history.undo.last() } else { d.history.redo.last() }); + // The step's name as the History panel lists it. + let name = crate::panels::history::shown; + match last { + Some(h) if id == "edit.undo" => fmt(tl!("Undo {name}"), &[("name", &name(h))]), + Some(h) => fmt(tl!("Redo {name}"), &[("name", &name(h))]), + None => tl!(if id == "edit.undo" { "Undo" } else { "Redo" }).to_string(), + } + } + "window.workspace.reset" => { + let name = &app.ui.workspace; + fmt(tl!("Reset {name}"), &[("name", if crate::workspaces::is_builtin(name) { tl!(name) } else { name })]) + } + "effect.last" => match &app.last_effect { + Some((e, _)) => { + let name = vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str()); + fmt(tl!("Last Effect: {name}"), &[("name", crate::i18n::tr_id(lang, e, name))]) + } + None => crate::i18n::tr_id(lang, id, label).to_string(), + }, + _ => { + let shown = dynamic_label(app, id, label); + if shows_a_name(id, &shown) { shown } else { crate::i18n::tr_id(lang, id, &shown).to_string() } + } + } +} + +/// The label of an Effect menu item in the UI language, by the effect's own id. Every effect item +/// runs `effect.apply` or `effect.dialog`, so the command id can't tell them apart; an `@id` row +/// for the effect id (for example `pathfinder.add`) wins over the plain row of the English label. +/// `None` when the catalog has no such row, and the plain translation applies. +pub(super) fn effect_item_label(id: &str, p: &Value) -> Option { + if !matches!(id, "effect.apply" | "effect.dialog") { + return None; + } + let effect = p.get("effect")?.as_str()?; + let shown = crate::i18n::tr_id(crate::i18n::current(), effect, ""); + (!shown.is_empty()).then(|| shown.to_string()) +} + +/// The label of an item of the canvas context menu's Unlock submenu (`layer.setProps` with one id +/// and `locked: false`): the object's name as the Layers panel shows it in `lang` +/// ([`crate::panels::layers::shown_name`]). `None` for any other item, or when the object is gone. +pub(super) fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: crate::i18n::Lang) -> Option { + if id != "layer.setProps" || p.get("locked") != Some(&Value::Bool(false)) { + return None; + } + let [one] = p.get("ids")?.as_array()?.as_slice() else { return None }; + let n = app.session.active()?.doc.node(vectorcraft_doc::NodeId(one.as_u64()?))?; + Some(crate::panels::layers::shown_name(app, n, lang)) +} + +/// The shortcut a menu item shows: its command's (items without params), or a panel's for the +/// Window menu's `window.panel {panel}` items. +pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { + match (id, p.get("panel").and_then(Value::as_str)) { + ("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel), + _ if p.is_null() => shortcut_of(id), + _ => None, + } +} + +/// Labels only the canvas context menu ([`context_items`]) shows, so the catalog tests can insist +/// every one of them is translated (a test checks the list against the menu). +pub const CONTEXT_LABELS: &[&str] = &[ + "Isolate Selected Group", + "Exit Isolation Mode", + "Lock Artboards", + "Lock Selected", + "Lock All Except Selected", + "Lock All", + "Unlock Artboards", + "Unlock Selected", + "Unlock", + "Make Clipping Mask", + "Release Clipping Mask", + "Make Compound Path", + "Release Compound Path", + "Select All", +]; + +/// The labels [`dynamic_label`] can show in place of an item's own (Show/Hide pairs and the like), +/// so the catalog tests can insist every one of them is translated. +pub const DYNAMIC_LABELS: &[&str] = &[ + "Preview", + "Outline", + "Hide Edges", + "Show Edges", + "Hide Corner Widget", + "Show Corner Widget", + "Hide Text Threads", + "Show Text Threads", + "Hide Hidden Characters", + "Show Hidden Characters", + "Hide Gradient Annotator", + "Show Gradient Annotator", + "Hide Live Paint Gaps", + "Show Live Paint Gaps", + "Hide Artboards", + "Show Artboards", + "Hide Rulers", + "Show Rulers", + "Hide Video Rulers", + "Show Video Rulers", + "Change to Global Rulers", + "Change to Artboard Rulers", + "Hide Bounding Box", + "Show Bounding Box", + "Hide Transparency Grid", + "Show Transparency Grid", + "Hide Guides", + "Show Guides", + "Hide Grid", + "Show Grid", + "Unlock Guides", + "Lock Guides", + "Show Slices", + "Hide Slices", + "Hide Template", + "Show Template", + "Hide Print Tiling", + "Show Print Tiling", + "Edit Envelope", + "Edit Contents", + "Start Global Edit", + "Stop Global Edit", + "Undo", + "Redo", + "Unlock Grid", + "Lock Grid", +]; + +/// Every English string the menus, the command palette, the panel registry, the toolbar and the +/// Preferences dialog can show: what a complete language catalog has to cover. Names that are user +/// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the +/// view angles are left out. +pub fn menu_strings() -> std::collections::BTreeSet { + use std::collections::BTreeSet; + fn user_data(id: &str, params: &Value) -> bool { + hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params) + } + fn walk(items: &[Item], under: &str, out: &mut BTreeSet) { + for it in items { + match it { + Item::Cmd(l, id, params) => { + let size = l.strip_suffix(" pt").is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())); + // View › Rotate View lists angles: an optional minus sign, digits and a degree sign. + let angle = l + .strip_suffix('°') + .map(|n| n.strip_prefix('-').unwrap_or(n)) + .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); + // VectorCraft › Language lists each language by its own name. + let language_name = *id == "app.language" && crate::i18n::Lang::all().any(|lang| lang.name() == *l); + if !user_data(id, params) && !size && !angle && !language_name && *l != "—" { + out.insert(l.to_string()); + } + } + Item::Todo(l, _) | Item::Header(l) => { + out.insert(l.to_string()); + } + Item::Sub(l, children) => { + out.insert(l.to_string()); + // Type › Font lists the installed families: names, not UI text. + if !(under == "Type" && *l == "Font") { + walk(children, l, out); + } + } + Item::Sep => {} + } + } + } + let mut out = BTreeSet::new(); + for (title, items) in menu_tree() { + out.insert(title.to_string()); + walk(&items, title, &mut out); + } + out.extend(crate::native_menu::MAC_LABELS.iter().map(|s| s.to_string())); + out.extend(DYNAMIC_LABELS.iter().map(|s| s.to_string())); + out.extend(CONTEXT_LABELS.iter().map(|s| s.to_string())); + out.extend(UI_COMMANDS.iter().map(|c| c.1.to_string())); + for c in vectorcraft_engine::cmd::command_specs() { + out.insert(c.label.to_string()); + out.extend(c.menu.iter().map(|m| m.to_string())); + } + out.extend(crate::state::all_panels().map(|(_, label)| label.to_string())); + out.extend(vectorcraft_tools::catalog::all_tools().map(|t| t.label.to_string())); + out.extend(crate::toolbar::BASIC.iter().map(|c| c.0.to_string())); + out.extend(vectorcraft_color::BlendMode::ALL.iter().map(|m| m.label().to_string())); + out.extend(vectorcraft_effects::effect_catalog().iter().map(|e| e.label.to_string())); + out.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|c| c.to_string())); + for sp in vectorcraft_engine::cmd::prefscmds::PREF_SPECS { + out.insert(sp.label.to_string()); + if !sp.section.is_empty() { + out.insert(sp.section.to_string()); + } + if let vectorcraft_engine::cmd::prefscmds::PrefKind::Choice(opts) = sp.kind { + out.extend(opts.iter().map(|o| o.1.to_string())); + } + } + out.extend(crate::prefs_dialog::UI_FIELDS.iter().map(|f| f.2.to_string())); + out.extend(crate::dialogs::button_labels().into_iter().map(str::to_string)); + out.extend(crate::dialogs::FILM_GRAIN_FIELDS.iter().map(|(_, label, _)| label.to_string())); + out.extend(crate::dialogs::PAINT_DAUB_BRUSH_TYPE_LABELS.iter().map(|label| label.to_string())); + out.extend(crate::chrome::hint_strings().into_iter().map(str::to_string)); + out.remove(""); + out +} diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs new file mode 100644 index 000000000..57137bee5 --- /dev/null +++ b/crates/ui-egui/src/menus/run.rs @@ -0,0 +1,725 @@ +use super::bar::home_key; +use super::invoke::menu_dialog; +use super::slots::{recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; +use super::state::{checked, selected_image}; +use super::ui_commands::UI_COMMANDS; +use super::*; + +/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label, +/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`), or a library panel's id +/// as it is (`library:swatches:pastels`). +pub(crate) fn normalize_panel(input: &str) -> Option<&'static str> { + let name = input.trim(); + crate::state::all_panels() + .find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)) + .map(|(id, _)| id) + .or_else(|| crate::panels::library_panel::intern(input)) +} + +/// An optional `{key?: bool}` param: none when it is omitted or null (the commands then toggle). +pub(crate) fn opt_bool(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } +} + +/// Handle a UI command. `None` = not a UI command (the engine handles it). +/// `params` has only keys the params description of engine command `id` names, or the parameter +/// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their +/// descriptions haven't been audited against what they read. +pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Value) -> Result<(), String> { + if UI_COMMANDS.iter().any(|c| c.0 == id) { + return Ok(()); + } + session.check_params(id, params).map_err(|e| e.to_string()) +} + +/// Runs `id` when the UI runs it itself: one of [`UI_COMMANDS`], or an engine command the UI +/// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the +/// engine. +pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + // The Window menu's panel items toggle like its other checked items (#2361); the panel's + // other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it. + if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { + let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); + if app.ui.open_panel.as_deref() == Some(panel) { + app.ui.open_panel = None; + return Some(Ok(json!({"open": null}))); + } + return Some(app.run("window.panel.close", json!({"panel": panel}))); + } + if let Some(result) = crate::panel_docking::command(app, id, p) { + return Some(result); + } + if id == "app.language" { + let lang = p.get("lang").and_then(Value::as_str).unwrap_or(""); + let value = if lang.eq_ignore_ascii_case("auto") { + "auto".to_string() + } else if let Some(l) = crate::i18n::Lang::from_code(lang) { + l.code().to_string() + } else { + let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::all().map(|l| l.code())).collect(); + return Some(Err(format!("lang must be one of {}", codes.join(", ")))); + }; + return Some(app.run("prefs.set", json!({"key": "interfaceLanguage", "value": value})).map(|_| json!(value))); + } + if let Some(r) = crate::panels::character::intercept_text_command(app, id) { + return Some(r); + } + if let Some(r) = crate::shortcut_editor::run_command(app, id, p) + .or_else(|| crate::workspaces::run_command(app, id, p)) + .or_else(|| crate::edit_toolbar::command(app, id, p)) + { + return Some(r); + } + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let flag = |b: &mut bool| { + *b = !*b; + Ok(json!(*b)) + }; + let r = match id { + "file.newDialog" => { + crate::dialogs::open_new_document(app); + Ok(Value::Null) + } + "file.newWithoutDialog" => crate::dialogs::new_document_without_dialog(app), + "app.home" => { + app.ui.home = Some(home_key(app)); + Ok(Value::Null) + } + "file.open" => match s("path") { + Some(path) => io::open_path(app, &path), + None => io::open_dialog(app).map(|_| Value::Null), + }, + // Saves write through the app (save panel, download); with no path known the panel and the + // format's options dialog ask first. + "file.save" => io::save(app, SaveMode::Save, p, true), + "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), + "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), + "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), + "document.exportSelection" => { + io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) + } + // Bytes sent by an agent go straight to the engine; a path or nothing opens it here. + "file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")), + "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), + "file.reveal" => io::reveal(app), + "file.cancelExport" => Ok(io::cancel_exports(app)), + "file.exportInBackground" => Ok(io::hide_exports(app)), + id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { + Some(path) => io::open_recent_reporting(app, &path), + None => Err("no such recent file".into()), + }, + "type.findFont" => { + crate::find_font::open(app); + Ok(Value::Null) + } + "ui.missingFontsDialog" => crate::dialogs::missing_fonts::open_command(app, s("folder")), + "ui.findFontsInFolder" => crate::dialogs::missing_fonts::find_in_folder_command(app, s("folder")), + "file.clearRecent" => { + app.ui.recent_files.clear(); + Ok(Value::Null) + } + "file.recentFiles" => Ok(json!(io::recent_files(app))), + "file.place" => crate::place::run(app, p), + "file.place.queue" => crate::place::queue(app, p), + "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { + crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None); + Ok(Value::Null) + } + "file.export.svg" => io::export(app, Some("svg"), s("path"), p), + "file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => { + crate::dialogs::open_export_for_screens(app); + Ok(Value::Null) + } + "file.exportForScreens" => io::export_for_screens(app, p.clone()), + "file.export.png" if s("path").is_none() => io::target_path(app, None, "png").and_then(|path| { + let f = vectorcraft_engine::cmd::fileio::format("png").ok_or("no PNG encoder")?; + let mut params = if p.is_object() { p.clone() } else { json!({}) }; + params["format"] = json!("png"); + params["path"] = json!(path); + crate::dialogs::open_raster_options(app, f, params); + Ok(Value::Null) + }), + "file.export.png" => io::export(app, Some("png"), s("path"), p), + "file.documentSetup" => crate::dialogs::open_document_setup(app), + "edit.preferences" => { + crate::prefs_dialog::open(app, s("category").as_deref()); + Ok(Value::Null) + } + // Edit → Color Settings… / Assign Profile… (no params): show the colour-management panel. + "edit.colorSettings" | "edit.assignProfile" if p.as_object().is_none_or(|o| o.is_empty()) => { + app.ui.open_panel = Some("separations".into()); + app.ui.dock = true; + app.session.execute(id, p).map_err(|e| e.to_string()) + } + "view.outline" => flag(&mut app.ui.view.outline), + "view.pixelPreview" => flag(&mut app.ui.view.pixel_preview), + "view.trimView" => flag(&mut app.ui.view.trim_view), + "view.cornerWidget" => flag(&mut app.ui.view.corner_widgets), + // New View: the engine stores the current zoom, centre and rotation under the given name. + "view.saved.new" if p.get("zoom").is_none() => { + let Some(v) = app.view().copied() else { return Some(Err("no document".into())) }; + let mut params = if p.is_object() { p.clone() } else { json!({}) }; + params["center"] = json!([v.center.x, v.center.y]); + params["zoom"] = json!(v.zoom); + params["rotation"] = json!(v.rotation); + app.session.execute(id, ¶ms).map_err(|e| e.to_string()) + } + // A saved view: its zoom, centre and rotation, in the current window. + id if saved_view_slot(id).is_some() => { + let saved = saved_view(app, id).and_then(|sv| crate::state::View::saved(sv).map(|to| (sv.name.clone(), to))); + match (saved, app.view_mut()) { + (Some((name, to)), Some(v)) => { + (v.center, v.zoom, v.rotation) = (to.center, to.zoom, to.rotation); + Ok(json!({ "name": name })) + } + _ => Err("no such view".into()), + } + } + id if saved_selection_slot(id).is_some() => match saved_selection_name(app, id) { + Some(name) => app.run("select.recall", json!({ "name": name })), + None => Err("no such saved selection".into()), + }, + "view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel), + "view.snapToGlyph" => flag(&mut app.ui.view.snap_to_glyph), + // The preference itself (General › Use Precise Cursors), so the command, the dialog and the saved + // preferences share one value. + "view.preciseCursors" => { + let on = !app.session.prefs.use_precise_cursors; + app.run("prefs.set", json!({"key": "usePreciseCursors", "value": on})).map(|_| { + crate::prefs_dialog::snapshot(app); + json!(on) + }) + } + "view.textThreads" => flag(&mut app.ui.view.text_threads), + "type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars), + "type.bold" => crate::panels::character::toggle_face(app, Face::Bold), + "type.italic" => crate::panels::character::toggle_face(app, Face::Italic), + id if let Some((_, justify)) = crate::panels::paragraph::ALIGN_COMMANDS.iter().find(|c| c.0 == id) => { + crate::panels::paragraph::align(app, justify) + } + "type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p), + "type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p), + "type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app), + id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b), + "view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator), + "view.livePaintGaps" => flag(&mut app.ui.view.live_paint_gaps), + "effect.last" => match app.last_effect.clone() { + Some((e, params)) => { + let label = vectorcraft_effects::effect_info(&e).map(|i| i.label.trim_end_matches('…').to_string()).unwrap_or_else(|| e.clone()); + let mut fields = params.as_object().cloned().unwrap_or_default(); + fields.insert("__effect".into(), json!(e)); + fields.insert("__label".into(), json!(label)); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields }); + Ok(Value::Null) + } + None => Err("no effect applied yet".into()), + }, + id if id.starts_with("type.recentFont") => { + let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); + match n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned() { + Some(font) => app.run("text.setStyle", json!({ "font": font })), + None => Err("no such recent font".into()), + } + } + "view.edges" => flag(&mut app.ui.view.edges), + "view.artboards" => flag(&mut app.ui.view.artboards), + "view.template" => flag(&mut app.ui.view.templates), + "view.rulers" => flag(&mut app.ui.view.rulers), + "view.videoRulers" => { + let v = &mut app.ui.view; + let show = !(v.video_rulers && v.rulers); + v.video_rulers = show; + v.rulers |= show; + Ok(json!(show)) + } + "view.boundingBox" => flag(&mut app.ui.view.bounding_box), + "view.guides" => { + let r = flag(&mut app.ui.view.guides); + // Hidden ruler guides and guide paths cannot stay selected: their selection overlay + // would otherwise reveal geometry that Hide Guides just hid. + let hidden_selection = !app.ui.view.guides + && app.session.active().is_some_and(|d| { + !d.selection.guides.is_empty() + || d.selection + .objects + .iter() + .any(|id| matches!(d.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Path { guide: true, .. }))) + }); + if hidden_selection { + // Deselecting a document that is open cannot fail. + let _ = app.run("select.none", json!({})); + } + r + } + "view.smartGuides" => flag(&mut app.ui.view.smart_guides), + "view.grid" => flag(&mut app.ui.view.grid), + "view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid), + "view.snapToPoint" => flag(&mut app.ui.view.snap_to_point), + "view.zoomIn" | "view.zoomOut" => { + let up = id == "view.zoomIn"; + let print = app.session.prefs.display_print_size; + // Selection & Anchor Display › Zoom to Selection: the selection comes to the middle. + let focus = if app.session.prefs.zoom_to_selection { app.selection_box().map(|b| b.center()) } else { None }; + match app.view_mut() { + Some(v) => { + v.zoom = next_zoom(v.zoom, up); + if let Some(c) = focus { + v.center = c; + } + Ok(json!({"zoom": crate::state::zoom_display_percent(v.zoom, print)})) + } + None => Err("no document".into()), + } + } + "view.setZoom" => { + let print = app.session.prefs.display_print_size; + let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print); + let center = + p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + match app.view_mut() { + Some(v) => { + v.zoom = z.clamp(0.0313, 640.0); + v.fitted = true; + if let Some(c) = center { + v.center = c; + } + Ok(Value::Null) + } + None => Err("no document".into()), + } + } + "view.fitArtboard" | "view.fitAll" | "view.actualSize" => { + crate::canvas::fit(app, id); + Ok(Value::Null) + } + "view.goToArtboard" => go_to_artboard(app, p), + "view.presentation" => { + app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 }; + Ok(json!(app.ui.screen_mode)) + } + "view.screenMode" => { + app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { + Some(m) => m.min(3) as u8, + // F cycles the three screen modes. Without a mode, the command also goes from + // Presentation Mode (not in the cycle) back to Normal. + None if app.ui.screen_mode >= 2 => 0, + None => app.ui.screen_mode + 1, + }; + Ok(json!(app.ui.screen_mode)) + } + // View › Rotate View and the status bar's angle menu: the view turns about the center of the + // canvas to the angle, normalized as a Rotate View drag normalizes it. + "view.rotate" => { + let key = "angle"; + let Some(angle) = p.get(key).and_then(Value::as_f64).filter(|a| a.is_finite()) else { + return Some(Err(format!("`{key}` must be a number"))); + }; + match app.view_mut() { + Some(v) => { + v.rotation = vectorcraft_geom::normalize_deg(angle); + Ok(json!({ "angle": v.rotation })) + } + None => Err("no document".into()), + } + } + // View › Rotate View to Selection: the view turns about the center of the canvas to the angle + // of the selection's bounding box, so the box is square to the window. + "view.rotateToSelection" => { + let Some(st) = app.session.active() else { return Some(Err("no document".into())) }; + if st.selection.objects.is_empty() { + return Some(Err("nothing selected".into())); + } + let angle = vectorcraft_geom::normalize_deg(st.doc.bbox_angle(&st.selection.objects)); + match app.view_mut() { + Some(v) => { + v.rotation = angle; + Ok(json!({ "angle": angle })) + } + None => Err("no document".into()), + } + } + "view.rotateReset" => { + if let Some(v) = app.view_mut() { + v.rotation = 0.0; + } + Ok(Value::Null) + } + "window.control" => flag(&mut app.ui.control_bar), + "window.toolbar" => flag(&mut app.ui.toolbar), + "window.toolbarColumns" => opt_bool(p, "double").map(|on| { + let on = on.unwrap_or(!app.ui.toolbar_double); + app.ui.toolbar_double = on; + json!(on) + }), + "window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced), + "imageTrace.preview" => opt_bool(p, "on").map(|on| { + let on = on.unwrap_or(!app.ui.image_trace_preview); + app.ui.image_trace_preview = on; + json!(on) + }), + "window.floatTools" => { + let floating = match opt_bool(p, "floating") { + Ok(on) => on, + Err(e) => return Some(Err(e)), + }; + let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) }; + crate::toolbar::float_group(app, &tool, floating).map(Value::Bool) + } + "window.taskBar" => flag(&mut app.ui.task_bar), + "window.taskBar.pin" => { + let place = &mut app.ui.task_bar_place; + opt_bool(p, "pinned").map(|pinned| { + let pinned = pinned.unwrap_or(!place.pinned); + // Pinning holds the bar where it shows; unpinning lets it follow the selection from + // there (the bar turns its pinned spot into an offset when it is next drawn). + if pinned && !place.pinned { + place.pin_at = place.shown_at; + } + place.pinned = pinned; + json!(pinned) + }) + } + "window.taskBar.reset" => { + app.ui.task_bar_place = Default::default(); + Ok(Value::Null) + } + "window.dock" => { + let ui = &mut app.ui; + // The bars the last Tab hid show again, unless the toolbar or the Control bar was shown + // another way since then (Window › Tools, Window › Control). Panels shown with Shift+Tab + // in between stay shown. Switching workspaces clears the record (`workspaces::apply`). + let hid = ui.tab_hid.take().filter(|h| (h.dock || h.toolbar || h.control_bar) && !ui.toolbar && !ui.control_bar); + let on = if let Some(h) = hid { + ui.dock |= h.dock; + ui.toolbar = h.toolbar; + ui.control_bar = h.control_bar; + true + } else if ui.dock || ui.toolbar || ui.control_bar { + ui.tab_hid = Some(crate::state::TabHid { dock: ui.dock, toolbar: ui.toolbar, control_bar: ui.control_bar }); + ui.dock = false; + ui.toolbar = false; + ui.control_bar = false; + ui.open_panel = None; + false + } else { + ui.dock = true; + ui.toolbar = true; + ui.control_bar = true; + true + }; + Ok(json!(on)) + } + "window.panels" => { + let on = !app.ui.dock; + app.ui.dock = on; + if !on { + app.ui.open_panel = None; + } + Ok(json!(on)) + } + "window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) { + Some(b) => { + app.ui.brightness = b; + app.session.prefs.ui_brightness = b.id().into(); + app.canvas.key = None; + Ok(json!(b.id())) + } + None => Err("brightness must be dark|mediumDark|mediumLight|light".into()), + }, + "window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()), + "tool.select" => match s("tool") { + Some(t) if vectorcraft_tools::tool_info(&t).is_some() => { + app.select_tool(&t); + Ok(json!({"tool": app.session.tool_id()})) + } + Some(t) => Err(format!("unknown tool `{t}`")), + None => Err("missing `tool`".into()), + }, + "tool.setOption" => app.session.set_tool_option_cmd(p), + "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), + "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), + "ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()), + "ui.cropImage" => { + app.select_tool(vectorcraft_tools::cropimage::ID); + Ok(json!({"tool": app.session.tool_id()})) + } + "ui.paramDialog" => { + let cmd = s("command").unwrap_or_default(); + let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); + fields.insert("__command".into(), json!(cmd)); + fields.insert("__label".into(), json!(s("label").unwrap_or(cmd.clone()))); + app.ui.dialog = Some(crate::state::Dialog { kind: "command".into(), fields }); + Ok(Value::Null) + } + "effect.applyLast" => match app.last_effect.clone() { + Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})), + None => Err("no effect applied yet".into()), + }, + "file.export.pdf" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::open_save_pdf(app, &json!({})), + "file.export.pdf" => io::export_pdf(app, p.clone()), + "help.about" => { + app.ui.about = true; + Ok(Value::Null) + } + "help.checkForUpdates" => crate::updates_ui::check(app), + "help.commandPalette" => { + app.ui.palette_open = !app.ui.palette_open; + app.ui.palette_query.clear(); + Ok(Value::Null) + } + // Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`). + "file.close" => { + let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index())?; + crate::unsaved::close(app, i) + } + "file.closeAll" => crate::unsaved::close_all(app, "closeAll"), + "app.quit" => crate::unsaved::close_all(app, "quit"), + "ui.swatchOptions" => match s("name") { + Some(name) => crate::dialogs::swatch_options::open(app, &name), + None => Err("missing `name`".into()), + }, + "ui.newSwatch" => { + let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref()) + } + "ui.newColorGroup" => { + let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::new_color_group::open(app, names.unwrap_or_default()) + } + "ui.colorPicker" => crate::dialogs::open_color_picker(app, p), + "ui.sampleScreenColor" => crate::screen_color::start(app, p), + "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), + "ui.symbolOptions" => { + let name = s("name"); + let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false); + if edit || name.is_some() { + crate::dialogs::symbol_options::open_edit(app, name.as_deref()) + } else { + crate::dialogs::symbol_options::open(app) + } + } + "ui.newBrush" => crate::dialogs::new_brush::open(app, p.get("ids").cloned()), + "ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()), + "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), + "ui.colorGuideOptions" => { + crate::dialogs::color_guide_options::open(app); + Ok(Value::Null) + } + "ui.colorBalanceDialog" => { + crate::dialogs::color_balance::open(app); + Ok(Value::Null) + } + "ui.saturateDialog" => { + crate::dialogs::saturate::open(app); + Ok(Value::Null) + } + "window.swatchLibrary" => crate::panels::swatches::open_library(app, p), + "window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")), + "ui.saveSwatchLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_swatch_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::swatches::USER_SLOT) => match crate::panels::swatches::user_library(app, id) { + Some(l) => crate::panels::swatches::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, + "ui.mergeGraphicStyles" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::graphic_style_options::open_merge(app, names.unwrap_or_default()) + } + "ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app), + "ui.layerOptions" => crate::dialogs::layer_options::open(app, p), + "ui.newLayer" => crate::dialogs::layer_options::open_new(app, p), + "ui.newArtboard" => crate::dialogs::artboard_options::open_new(app), + "ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app), + "ui.layersExpand" => crate::panels::layers::expand(app, p), + "ui.layersFilter" => crate::panels::layers::set_filter(app, p), + "ui.flattenTransparencyDialog" => { + crate::dialogs::flatten::open(app); + Ok(Value::Null) + } + "ui.flattenerPresetsDialog" => { + crate::dialogs::flattener_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p), + "window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p), + "ui.libraryPanel" => crate::panels::library_panel::command(app, p), + "ui.libraryPanel.locate" => crate::panels::library_panel::locate(app, p), + "window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")), + "ui.saveGraphicStyleLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_style_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) { + Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, + "window.symbolLibrary" => crate::panels::symbols::open_library(app, p), + "window.symbolLibrary.other" => crate::panels::symbols::other_library(app, s("path")), + "ui.saveSymbolLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_symbol_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::symbols::USER_SLOT) => match crate::panels::symbols::user_library(app, id) { + Some(l) => crate::panels::symbols::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, + "window.brushLibrary" => crate::panels::brushes::open_library(app, p), + "window.brushLibrary.other" => crate::panels::brushes::other_library(app, s("path")), + "ui.saveBrushLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_brush_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::brushes::USER_SLOT) => match crate::panels::brushes::user_library(app, id) { + Some(l) => crate::panels::brushes::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, + "ui.expandDialog" => crate::dialogs::expand::open(app), + "ui.simplifyAuto" => crate::dialogs::simplify_slider::auto(app), + "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::more_options(app), + "attributes.openUrl" => crate::panels::attributes::open_url(app, p), + "ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p), + "ui.spotColors" => crate::dialogs::spot_colors::open(app), + "ui.menuDialog" => match s("command").as_deref() { + Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), + c => match c.and_then(|c| menu_dialog(app, c)) { + Some((kind, fields)) => { + app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); + Ok(Value::Null) + } + None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), + }, + }, + "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), + "ui.corners" => crate::dialogs::corners::open(app, p), + "ui.hyphenation" => crate::dialogs::hyphenation::open(app), + "ui.justification" => crate::dialogs::justification::open(app), + "ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p), + "ui.colorPanel" => crate::panels::color::command(app, p), + "ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p), + "file.exportAs" if s("path").is_none() => { + crate::dialogs::open_export_as(app, s("format").as_deref(), p); + Ok(Value::Null) + } + "file.exportAs" => io::export(app, s("format").as_deref(), s("path"), p), + "ui.fileInfoDialog" => crate::dialogs::file_info::open(app), + "ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app), + "ui.effectGalleryDialog" => crate::dialogs::effect_gallery::open(app, p), + "ui.pdfPresetsDialog" => { + crate::dialogs::pdf_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.pdfPresetDialog" => crate::dialogs::open_pdf_preset(app, p), + // Save for Office Documents…: its dialog; with options, pick the file (or write `path`). + "document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app), + "document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })), + "ui.dxfOptionsDialog" if app.session.active().is_none() => Err("no document".into()), + "ui.dxfOptionsDialog" => { + crate::dialogs::dxf_options::open(app, p); + Ok(Value::Null) + } + "links.editOriginal" => crate::panels::links::open_file(app, p, false), + "links.reveal" => crate::panels::links::open_file(app, p, true), + "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), + "ui.findLinksInFolder" => { + crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned()) + } + "ui.packageDialog" => crate::dialogs::package::open(app), + "ui.batchDialog" => { + let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; + let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; + crate::dialogs::action_batch::open(app, set, action) + } + "ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p), + "file.showPackage" => crate::dialogs::package::show_package(app, p), + "docInfo.save" => crate::panels::doc_info::save_report(app, p), + // Slice Options… and Divide Slices…: their dialogs, on the selected slices. + "object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_options(app), + "object.slice.divide" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_divide(app), + "ui.epsOptionsDialog" if app.session.active().is_none() => Err("no document".into()), + "ui.epsOptionsDialog" => { + crate::dialogs::eps_options::open(app, p); + Ok(Value::Null) + } + // Save for Web: its dialog; with settings, write the files. + "file.saveForWeb" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::save_for_web::open(app), + "file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()), + "file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p), + // File → Export Selection…: the selection becomes one asset (`multiple: true`: one per + // object), shown checked on Export for Screens' Assets tab. + "file.exportSelection" => { + app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| { + let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + crate::dialogs::open_export_for_screens_assets(app, Some(&ids)); + json!({ "assets": ids }) + }) + } + "css.copy" => crate::panels::css_properties::copy(app, p), + "css.exportFile" => crate::panels::css_properties::export_file(app, p), + // File → Print…: its dialog; with params, print (or save the job as a PDF) without it. + "file.print" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::print::open(app), + "file.print" => crate::print::run(app, p), + "print.printers" => Ok(crate::print::printers(app)), + "print.printerSetup" => { + let printer = s("printer").filter(|n| !n.is_empty()); + match app.services.print.as_mut() { + Some(service) => service.setup(printer.as_deref()).map(|_| Value::Null), + None => Err("printing isn't available here".into()), + } + } + "ui.printPresetsDialog" => { + crate::dialogs::print_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.printPresetDialog" => crate::dialogs::print::open_preset(app, p), + "plugin.dialog" => crate::dialogs::plugin::open(app, p), + "ui.installPlugin" => io::install_plugin(app, p), + "plugin.cancelRuns" => Ok(crate::plugin_runs::cancel_all(app)), + "plugin.hideRuns" => Ok(crate::plugin_runs::hide_all(app)), + "ui.checkScript" => crate::dialogs::script_check::command(app, p), + "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), + "ui.perspectivePresetsDialog" => { + crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.savePerspectivePreset" => crate::dialogs::perspective_grid::open_save(app), + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => match crate::dialogs::perspective_presets::slot_preset(app, id) { + Some(name) => app.run("perspective.grid.preset", json!({ "name": name })), + None => Err("no such perspective grid preset".into()), + }, + "ui.blendOptions" => crate::dialogs::blend_options::open(app), + "ui.livePaintGapOptions" => crate::dialogs::live_paint_gaps::open(app), + "ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p), + _ => return None, + }; + Some(r) +} + +/// `view.goToArtboard`: make artboard `index` (a number, or first / previous / next / last from +/// the current one) active, synchronizing the Artboard tool when in use, and fit it in the window. +fn go_to_artboard(app: &mut VectorcraftApp, p: &Value) -> Result { + let n = app.session.active().map(|d| d.doc.artboards.len()).ok_or("no document open")?; + let last = n.checked_sub(1).ok_or("the document has no artboards")?; + let current = app.view().map_or(0, |v| v.artboard).min(last); + let to = match p.get("index") { + Some(Value::String(s)) => match s.as_str() { + "first" => 0, + "previous" => current.saturating_sub(1), + "next" => current + 1, + "last" => last, + other => return Err(format!("index must be a number or first, previous, next or last, not `{other}`")), + }, + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("index must be a number or first, previous, next or last")?, + None => return Err("missing index".into()), + } + .min(last); + crate::panels::artboards::select(app, to); + crate::canvas::fit(app, "view.fitArtboard"); + Ok(json!({ "index": to })) +} diff --git a/crates/ui-egui/src/menus/slots.rs b/crates/ui-egui/src/menus/slots.rs new file mode 100644 index 000000000..8ecc79b9f --- /dev/null +++ b/crates/ui-egui/src/menus/slots.rs @@ -0,0 +1,169 @@ +use super::*; + +/// The active document's perspective grid (View → Perspective Grid toggles). +pub(super) fn perspective_grid(app: &VectorcraftApp) -> Option { + app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc)) +} + +/// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them). +pub(super) const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [ + "file.openRecent1", + "file.openRecent2", + "file.openRecent3", + "file.openRecent4", + "file.openRecent5", + "file.openRecent6", + "file.openRecent7", + "file.openRecent8", + "file.openRecent9", + "file.openRecent10", + "file.openRecent11", + "file.openRecent12", + "file.openRecent13", + "file.openRecent14", + "file.openRecent15", + "file.openRecent16", + "file.openRecent17", + "file.openRecent18", + "file.openRecent19", + "file.openRecent20", + "file.openRecent21", + "file.openRecent22", + "file.openRecent23", + "file.openRecent24", + "file.openRecent25", + "file.openRecent26", + "file.openRecent27", + "file.openRecent28", + "file.openRecent29", + "file.openRecent30", +]; + +/// The saved selection a `select.recallN` slot names (none past the document's last). +pub(super) fn saved_selection_name(app: &VectorcraftApp, id: &str) -> Option { + let n = saved_selection_slot(id)?.checked_sub(1)?; + app.session.active()?.doc.saved_selections.get(n).map(|x| x.name.clone()) +} + +/// The recent file a `file.openRecentN` slot names (none past the preference's count). +pub(super) fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> { + let n: usize = id.strip_prefix("file.openRecent")?.parse().ok()?; + io::recent_files(app).get(n.checked_sub(1)?) +} + +/// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type. +pub(super) const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [ + [ + "ui.perspectiveUserPreset1.1", + "ui.perspectiveUserPreset1.2", + "ui.perspectiveUserPreset1.3", + "ui.perspectiveUserPreset1.4", + "ui.perspectiveUserPreset1.5", + ], + [ + "ui.perspectiveUserPreset2.1", + "ui.perspectiveUserPreset2.2", + "ui.perspectiveUserPreset2.3", + "ui.perspectiveUserPreset2.4", + "ui.perspectiveUserPreset2.5", + ], + [ + "ui.perspectiveUserPreset3.1", + "ui.perspectiveUserPreset3.2", + "ui.perspectiveUserPreset3.3", + "ui.perspectiveUserPreset3.4", + "ui.perspectiveUserPreset3.5", + ], +]; + +/// Select → saved selections: the n-th saved selection of the active document. +pub(super) const SAVED_SELECTION_IDS: [&str; vectorcraft_engine::doc::SavedSelection::MAX] = [ + "select.recall1", + "select.recall2", + "select.recall3", + "select.recall4", + "select.recall5", + "select.recall6", + "select.recall7", + "select.recall8", + "select.recall9", + "select.recall10", + "select.recall11", + "select.recall12", + "select.recall13", + "select.recall14", + "select.recall15", + "select.recall16", + "select.recall17", + "select.recall18", + "select.recall19", + "select.recall20", + "select.recall21", + "select.recall22", + "select.recall23", + "select.recall24", + "select.recall25", +]; + +/// View → saved views: the n-th saved view of the active document. +pub(super) const SAVED_VIEW_IDS: [&str; vectorcraft_engine::doc::SavedView::MAX] = [ + "view.goto1", + "view.goto2", + "view.goto3", + "view.goto4", + "view.goto5", + "view.goto6", + "view.goto7", + "view.goto8", + "view.goto9", + "view.goto10", + "view.goto11", + "view.goto12", + "view.goto13", + "view.goto14", + "view.goto15", + "view.goto16", + "view.goto17", + "view.goto18", + "view.goto19", + "view.goto20", + "view.goto21", + "view.goto22", + "view.goto23", + "view.goto24", + "view.goto25", +]; + +/// The 1-based slot a `view.gotoN` id stands for. +pub(super) fn saved_view_slot(id: &str) -> Option { + id.strip_prefix("view.goto")?.parse().ok() +} + +/// The saved view a `view.gotoN` slot names (none past the document's last). +pub(super) fn saved_view<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a vectorcraft_engine::doc::SavedView> { + app.session.active()?.doc.views.get(saved_view_slot(id)?.checked_sub(1)?) +} + +/// The 1-based slot a `select.recallN` id stands for. +pub(super) fn saved_selection_slot(id: &str) -> Option { + id.strip_prefix("select.recall")?.parse().ok() +} + +/// Type → Recent Fonts slots (Preferences › Type › Number of Recent Fonts shows up to 15). +pub(super) const RECENT_FONT_IDS: [&str; crate::MAX_RECENT_FONTS] = [ + "type.recentFont1", + "type.recentFont2", + "type.recentFont3", + "type.recentFont4", + "type.recentFont5", + "type.recentFont6", + "type.recentFont7", + "type.recentFont8", + "type.recentFont9", + "type.recentFont10", + "type.recentFont11", + "type.recentFont12", + "type.recentFont13", + "type.recentFont14", + "type.recentFont15", +]; diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs new file mode 100644 index 000000000..a4998e993 --- /dev/null +++ b/crates/ui-egui/src/menus/state.rs @@ -0,0 +1,275 @@ +use super::run::normalize_panel; +use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; +use super::*; + +/// Checked state for toggle items. +pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { + if id == "app.language" { + let lang = p.get("lang").and_then(Value::as_str).unwrap_or(""); + let pref = app.session.prefs.interface_language.as_str(); + return Some(if lang.eq_ignore_ascii_case("auto") { + crate::i18n::Lang::from_code(pref).is_none() + } else { + crate::i18n::Lang::from_code(pref).is_some_and(|l| l.code().eq_ignore_ascii_case(lang)) + }); + } + let v = &app.ui.view; + // An item that can be on or off is `Some` whatever the document and selection (off with none): + // the macOS menu bar can't change an item's kind without rebuilding. + // The first type object (of type on a path, with `onpath`) among the selected objects and the + // objects inside them, in the order the type commands act on them. + let selected_text = |onpath: bool| { + let st = app.session.active()?; + let mut found = None; + for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + n.walk(&mut |c| { + if found.is_none() + && let vectorcraft_engine::doc::NodeKind::Text(t) = &c.kind + && (!onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. })) + { + found = Some(t); + } + }); + if found.is_some() { + break; + } + } + found + }; + Some(match id { + "type.orientation.vertical" | "type.orientation.horizontal" => { + selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical")) + } + // Checked when the first type object `selected_text` finds has it on. + "type.opticalMarginAlignment" => selected_text(false).is_some_and(|t| t.optical_margin), + "view.outline" => v.outline, + "view.pixelPreview" => v.pixel_preview, + "view.trimView" => v.trim_view, + "view.snapToPixel" => v.snap_to_pixel, + "view.snapToGlyph" => v.snap_to_glyph, + // Type on a Path effect of the selected path type. + "type.pathOptions" if p.get("effect").is_some() => { + selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())) + } + "view.smartGuides" => v.smart_guides, + "view.grid" => v.grid, + "view.snapToGrid" => v.snap_to_grid, + "view.snapToPoint" => v.snap_to_point, + "view.rulers" => v.rulers, + // A View › Rotate View item is checked when the active view is turned to its angle; without + // a document no item is checked. + "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), + "window.control" => app.ui.control_bar, + "window.toolbar" => app.ui.toolbar, + "window.toolbarAdvanced" => app.ui.toolbar_advanced, + "window.toolbarColumns" => app.ui.toolbar_double, + "window.editToolbar" => app.ui.tools_drawer, + "window.floatTools" => { + let tool = p.get("tool").and_then(Value::as_str).unwrap_or(""); + app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool)) + } + "window.taskBar" => app.ui.task_bar, + "window.taskBar.pin" => app.ui.task_bar_place.pinned, + "window.panel" => { + let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); + normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id)) + } + "window.collapseDock" => crate::panel_docking::dock_collapsed(&app.ui), + "window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), + "window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness), + "view.slices.lock" => app.session.slices_locked(), + "object.slice.clipToArtboard" => app.session.active().is_some_and(|d| d.doc.slices_clip_to_artboard), + "view.proofColors" => vectorcraft_render::proof::view().proof_colors, + "view.overprintPreview" => vectorcraft_render::proof::view().overprint, + "view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()), + "perspective.grid.snap" => perspective_grid(app).is_some_and(|g| g.snap), + "perspective.grid.lockStation" => perspective_grid(app).is_some_and(|g| g.lock_station), + "file.documentColorMode" | "object.convertDocumentColorMode" => { + let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk); + p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" }) + } + // The ruler context menu lists the document units, the current one checked. + "document.setUnits" => { + p.get("units").and_then(Value::as_str).and_then(vectorcraft_engine::doc::Unit::named) == Some(app.session.general_unit()) + } + _ => return None, + }) +} + +/// Menu slots that are left out while they have nothing to show (unused saved views, recent files, +/// User Defined swatch and graphic style libraries). +pub(super) fn hidden_when_disabled(id: &str) -> bool { + id.starts_with("view.goto") + || saved_selection_slot(id).is_some() + || id.starts_with("file.openRecent") + || id.starts_with(crate::panels::swatches::USER_SLOT) + || id.starts_with(crate::panels::graphic_styles::USER_SLOT) + || id.starts_with(crate::panels::symbols::USER_SLOT) + || id.starts_with(crate::panels::brushes::USER_SLOT) + || id.starts_with(crate::dialogs::perspective_presets::SLOT) +} + +/// Menu items another item stands in for right now: Envelope Distort's Reset with Warp and Reset +/// with Mesh take the place of Make with Warp and Make with Mesh while an envelope is selected. +fn swapped_out(app: &VectorcraftApp, id: &str) -> bool { + let reset = match id { + "object.envelope.makeWithWarp" | "object.envelope.makeWithMesh" => false, + "object.envelope.resetWithWarp" | "object.envelope.resetWithMesh" => true, + _ => return false, + }; + enabled(app, "object.envelope.release") != reset +} + +/// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on +/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item +/// another stands in for ([`swapped_out`]). +pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option)> { + let en = enabled(app, id); + if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { + return None; + } + Some((en, checked(app, id, p))) +} + +/// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins +/// list them (the command palette's cache follows it). +pub fn plugin_revision() -> u64 { + vectorcraft_plugins::registry::revision() +} + +/// Effective shortcut of a command: the user's override (Edit → Keyboard Shortcuts) or the default. +pub fn shortcut_of(id: &str) -> Option<&'static str> { + crate::shortcut_editor::command_shortcut(id) +} + +/// Is a command currently enabled? +/// Commands that would act on the text being typed, held back while an IME composes in the Type +/// tool (its marked text isn't committed yet): Undo/Redo — the native menu takes ⌘Z ahead of the +/// IME —, the clipboard and the selection. +/// Clear History also waits: it ends the typing session as Undo does. +pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool { + app.session.tool_composing() + && (matches!( + id, + "edit.undo" | "edit.redo" | "edit.cut" | "edit.copy" | "edit.clear" | "edit.duplicate" | "select.all" | "select.none" | "select.inverse" + ) || id == "edit.clearHistory" + || id.starts_with("edit.paste")) +} + +/// Engine commands that take `ids` and otherwise act on the Appearance panel's subject (the +/// targeted layer, group or object, else the selection). Without `ids` and without a subject they +/// are off, as the panel's buttons are: in menus, in the command palette and on their keys. +pub(super) const SUBJECT_COMMANDS: [&str; 2] = ["appearance.addFill", "appearance.addStroke"]; + +/// Is there an Appearance panel subject: a targeted layer, group or object, or a selected one? +pub(super) fn has_subject(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| !st.selection.subjects().is_empty()) +} + +/// Can command `id` run now? An engine command asks the engine, a UI command the app's state; +/// none that would act on text an input method is still composing. +pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { + if waits_for_ime(app, id) { + return false; + } + if SUBJECT_COMMANDS.contains(&id) { + return has_subject(app); + } + if let Some(c) = vectorcraft_engine::find_command(id) { + // The system clipboard's contents can be pasted with an empty internal clipboard. + return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste"); + } + match id { + // Save is off for a clean document that already has its own file. + "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()), + "type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(), + id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(), + "type.applyBullets" | "type.applyNumbers" | "type.convertListToText" => crate::panels::character::text_style(app).is_some(), + id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()), + "file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()), + "file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()), + "plugin.cancelRuns" => crate::plugin_runs::any_running(app), + "plugin.hideRuns" => crate::plugin_runs::any_shown(app), + "file.place" + | "file.export.svg" + | "file.export.png" + | "file.exportAs" + | "file.exportForScreens" + | "file.documentSetup" + | "view.zoomIn" + | "view.zoomOut" + | "view.fitArtboard" + | "view.fitAll" + | "view.actualSize" + | "view.rotate" => app.session.active().is_some(), + "view.rotateToSelection" => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()), + id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), + "file.clearRecent" => !app.ui.recent_files.is_empty(), + id if id.starts_with("type.recentFont") => { + id["type.recentFont".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.recent_fonts().len()) && app.session.active().is_some() + } + id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).is_some(), + id if saved_view_slot(id).is_some() => saved_view(app, id).is_some(), + "effect.dialog" | "ui.recolorDialog" | "ui.effectGalleryDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.cropImage" => selected_image(app, |_| true), + "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), + "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), + "ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(), + "ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), + "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), + "ui.graphicStyleOptions" => app.session.active().is_some(), + "ui.newArtboard" => app.session.active().is_some(), + "ui.sampleScreenColor" => crate::screen_color::available(app), + "ui.newBrush" => app.session.active().is_some(), + "ui.brushOptions" => crate::dialogs::brush_options::available(app), + "ui.hyphenation" | "ui.justification" => crate::panels::character::text_style(app).is_some(), + "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.saveSwatchLibrary" => app.session.active().is_some(), + id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(), + "ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.saveGraphicStyleLibrary" => app.session.active().is_some(), + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(), + "ui.saveSymbolLibrary" => app.session.active().is_some_and(|d| !d.doc.symbols.is_empty()), + id if id.starts_with(crate::panels::symbols::USER_SLOT) => crate::panels::symbols::user_library(app, id).is_some(), + "ui.saveBrushLibrary" => crate::panels::brushes::has_brushes(app), + id if id.starts_with(crate::panels::brushes::USER_SLOT) => crate::panels::brushes::user_library(app, id).is_some(), + "ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.spotColors" => app.session.active().is_some(), + "ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.simplifyAuto" | "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::showing(app), + "ui.dxfOptionsDialog" => app.session.active().is_some(), + "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), + "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), + "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), + "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), + "ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()), + "ui.epsOptionsDialog" => app.session.active().is_some(), + "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), + "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "css.copy" | "css.exportFile" => app.session.active().is_some(), + "print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()), + "plugin.dialog" => app.session.active().is_some(), + "ui.perspectiveGridDialog" | "ui.savePerspectivePreset" => app.session.active().is_some(), + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { + app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some() + } + "ui.perspectivePlane" => app.session.active().is_some(), + // Only one window per document so far: greyed out, as Window › Arrange's items are (#3724). + "window.newWindow" => false, + _ => true, + } +} + +/// Is a placed document selected? +fn selected_placed(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| { + st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::PlacedDocument(_)))) + }) +} + +/// Is an image `keep` accepts selected? +pub(super) fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObject) -> bool) -> bool { + app.session.active().is_some_and(|st| { + st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(&n.kind, vectorcraft_doc::NodeKind::Image(im) if keep(im)))) + }) +} diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs new file mode 100644 index 000000000..56544260c --- /dev/null +++ b/crates/ui-egui/src/menus/submenus.rs @@ -0,0 +1,210 @@ +use super::*; + +/// Type → Size presets. +pub(super) const TYPE_SIZES: [(&str, u32); 14] = [ + ("6 pt", 6), + ("8 pt", 8), + ("9 pt", 9), + ("10 pt", 10), + ("11 pt", 11), + ("12 pt", 12), + ("14 pt", 14), + ("18 pt", 18), + ("24 pt", 24), + ("30 pt", 30), + ("36 pt", 36), + ("48 pt", 48), + ("60 pt", 60), + ("72 pt", 72), +]; + +/// Type → Type on a Path effects (label, `type.pathOptions` effect), also the choices of its +/// Options dialog. +pub(crate) const PATH_EFFECTS: &[(&str, &str)] = + &[("Rainbow", "rainbow"), ("Skew", "skew"), ("3D Ribbon", "3dRibbon"), ("Stair Step", "stairStep"), ("Gravity", "gravity")]; + +pub(super) const INSERT_SPECIAL: &[(&str, &str)] = &[ + ("Bullet", "bullet"), + ("Copyright Symbol", "copyright"), + ("Ellipsis", "ellipsis"), + ("Paragraph Symbol", "paragraph"), + ("Registered Trademark Symbol", "registered"), + ("Section Symbol", "section"), + ("Trademark Symbol", "trademark"), + ("Em Dash", "emDash"), + ("En Dash", "enDash"), + ("Discretionary Hyphen", "discretionaryHyphen"), + ("Nonbreaking Hyphen", "nonBreakingHyphen"), + ("Double Left Quotation Marks", "doubleLeftQuote"), + ("Double Right Quotation Marks", "doubleRightQuote"), + ("Single Left Quotation Mark", "singleLeftQuote"), + ("Single Right Quotation Mark", "singleRightQuote"), +]; + +pub(super) const INSERT_WHITESPACE: &[(&str, &str)] = &[ + ("Em Space", "emSpace"), + ("En Space", "enSpace"), + ("Hair Space", "hairSpace"), + ("Sixth Space", "sixthSpace"), + ("Thin Space", "thinSpace"), + ("Nonbreaking Space", "nonBreakingSpace"), + ("Figure Space", "figureSpace"), + ("Punctuation Space", "punctuationSpace"), + ("Third Space", "thirdSpace"), + ("Quarter Space", "quarterSpace"), +]; + +pub(super) const INSERT_BREAK: &[(&str, &str)] = &[("Forced Line Break", "forcedLineBreak"), ("Tab", "tab")]; + +pub(super) fn insert_items(list: &[(&'static str, &'static str)]) -> Vec { + list.iter().map(|(l, c)| cp(l, "type.insert", json!({ "char": c }))).collect() +} + +/// Type → Font: one item per available family, the installed fonts included. Built again only when +/// the fonts or Show Font Names in English change (the menu tree is built every frame); labels are +/// interned once each, so rebuilding doesn't allocate forever. The command still sets the English +/// family name ([`vectorcraft_text::FontDb::canonical`]). +pub(super) fn font_items(english_names: bool) -> Vec { + static NAMES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeSet::new()); + static ITEMS: std::sync::Mutex<(u64, bool, Vec)> = std::sync::Mutex::new((u64::MAX, true, Vec::new())); + let db = vectorcraft_text::FontDb::global(); + // Read first: fonts that load meanwhile make the next frame build the list again. + let generation = db.generation(); + let fams = db.menu_family_list(); + let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] }; + if items.0 != generation || items.1 != english_names { + let list = fams + .iter() + .map(|f| { + let english = f.as_str(); + let shown = db.family_display_name(english, english_names); + let label: &'static str = match names.get(shown.as_str()) { + Some(n) => n, + None => { + let n: &'static str = Box::leak(shown.into_boxed_str()); + names.insert(n); + n + } + }; + let font: &'static str = match names.get(english) { + Some(n) => n, + None => { + let n: &'static str = Box::leak(english.to_string().into_boxed_str()); + names.insert(n); + n + } + }; + cp(label, "text.setStyle", json!({ "font": font })) + }) + .collect(); + *items = (generation, english_names, list); + } + items.2.clone() +} + +/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the +/// vector effects): (submenu, the catalogue's menu path of its effects). +/// The raster Stylize submenu shares its path with the vector one and lists only the effects at +/// that path that [`vectorcraft_effects::PIXEL_EFFECTS`] names. +const RASTER_MENUS: [(&str, &[&str]); 10] = [ + ("Artistic", &["Effect", "Artistic"]), + ("Blur", &["Effect", "Blur"]), + ("Brush Strokes", &["Effect", "Brush Strokes"]), + ("Distort", &["Effect", "Distort"]), + ("Pixelate", &["Effect", "Pixelate"]), + ("Sharpen", &["Effect", "Sharpen"]), + ("Sketch", &["Effect", "Sketch"]), + ("Stylize", &["Effect", "Stylize"]), + ("Texture", &["Effect", "Texture"]), + ("Video", &["Effect", "Video"]), +]; + +/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects. +pub(super) fn effect_menu() -> Vec { + let cat = vectorcraft_effects::effect_catalog(); + let mut out = vec![ + c("Apply Last Effect", "effect.applyLast"), + c("Last Effect…", "effect.last"), + Sep, + c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), + Sep, + Item::Header("Vector Effects"), + ]; + // Submenus (and Crop Marks and Rasterize…, items of their own) in the reference app's order. + let order = [ + "3D and Materials", + "Color Adjustments", + "Convert to Shape", + "Crop Marks", + "Distort & Transform", + "Path", + "Pathfinder", + "Rasterize…", + "Stylize", + "SVG Filters", + "Warp", + ]; + let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label); + // An effect goes in the vector submenu its path names unless it is a Photoshop-style effect, + // which goes in the raster submenu of the same name when `RASTER_MENUS` has one (Stylize › + // Glowing Edges). + let in_vector_menu = + |e: &vectorcraft_effects::EffectInfo| order.iter().any(|s| e.menu == ["Effect", *s]) && !vectorcraft_effects::PIXEL_EFFECTS.contains(&e.id); + let item = |e: &vectorcraft_effects::EffectInfo| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + // No options (Effect → Pathfinder, Crop Marks): apply directly, like Illustrator. + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }; + // The effects whose catalogue menu path is `path`. + // `vector` picks the ones for the vector submenu or the ones for the raster submenu. + let items_at = + |path: &[&str], vector: bool| -> Vec { cat.iter().filter(|e| e.menu == path && in_vector_menu(e) == vector).map(item).collect() }; + for sub_name in order { + if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { + out.push(item(e)); + continue; + } + let items = items_at(&["Effect", sub_name], true); + if items.is_empty() { + let placeholder = match sub_name { + "3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")], + "SVG Filters" => vec![todo("Apply SVG Filter…")], + _ => continue, + }; + out.push(sub(sub_name, placeholder)); + } else { + let mut items = items; + if sub_name == "3D and Materials" { + // In the panel's order of 3D types, then Materials (not built yet). + let rank = |label: &str| ["Extrude & Bevel…", "Revolve…", "Inflate…", "Rotate…"].iter().position(|l| *l == label).unwrap_or(9); + items.sort_by_key(|i| match i { + Item::Cmd(label, ..) => rank(label), + _ => 9, + }); + items.push(todo("Materials…")); + } + out.push(sub(sub_name, items)); + } + } + // Live-effect plug-ins close the vector effects. + out.extend(crate::dialogs::plugin::effect_menu()); + let raster: Vec = RASTER_MENUS + .iter() + .filter_map(|(name, path)| { + let items = items_at(path, false); + (!items.is_empty()).then(|| sub(name, items)) + }) + .collect(); + if !raster.is_empty() { + out.push(Sep); + out.push(Item::Header("Raster Effects")); + out.push(c("Effect Gallery…", "ui.effectGalleryDialog")); + out.extend(raster); + } + // Anything not placed above (future effects) still shows up. + let placed = |e: &vectorcraft_effects::EffectInfo| top_level(e) || in_vector_menu(e) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path); + for e in cat.iter().filter(|e| !placed(e)) { + out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); + } + out +} diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs new file mode 100644 index 000000000..d806eac4a --- /dev/null +++ b/crates/ui-egui/src/menus/tree.rs @@ -0,0 +1,711 @@ +use super::slots::{PERSPECTIVE_SLOTS, RECENT_FONT_IDS, RECENT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS}; +use super::submenus::{INSERT_BREAK, INSERT_SPECIAL, INSERT_WHITESPACE, PATH_EFFECTS, TYPE_SIZES, effect_menu, font_items, insert_items}; +use super::*; + +/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see +/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`). +/// `english_names` is Preferences › Type › Show Font Names in English (the Type → Font labels +/// follow it; tests use the default `true`). +pub fn menu_tree() -> Vec<(&'static str, Vec)> { + menu_tree_named(true) +} + +pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { + let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id, "toggle": true })); + vec![ + ( + // The application menu: About and Discord only. The in-window bar never shows it (File + // is first there, as in Illustrator); on macOS `native_menu::mac_layout` composes the + // App menu from this entry and the other menus (About from Help, Settings from Edit, + // Quit from File, Language and Appearance synthesized). It stays first: `from_tree` + // gives the first menu the App role. + "VectorCraft", + vec![c("About VectorCraft", "help.about"), c("Join Our Discord", "help.discord")], + ), + ( + "File", + vec![ + c("New…", "file.newDialog"), + c("New from Template…", "file.newFromTemplate"), + c("Open…", "file.open"), + sub("Open Recent Files", { + let mut v: Vec = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect(); + v.push(Sep); + v.push(c("Clear Recent Files", "file.clearRecent")); + v + }), + c("Show in Folder", "file.reveal"), + Sep, + c("Close", "file.close"), + c("Close All", "file.closeAll"), + c("Save", "file.save"), + c("Save As…", "file.saveAs"), + c("Save a Copy…", "file.saveCopy"), + c("Save as Template…", "file.saveAsTemplate"), + c("Save as PDF…", "file.export.pdf"), + c("Save for Office Documents…", "document.exportForOffice"), + c("Revert", "file.revert"), + Sep, + c("Place…", "file.place"), + Sep, + sub( + "Export", + vec![ + c("Export for Screens…", "file.exportForScreens"), + c("Export As…", "file.exportAs"), + c("Export As SVG…", "file.export.svg"), + c("Export As PNG…", "file.export.png"), + c("Save for Web (Legacy)…", "file.saveForWeb"), + ], + ), + c("Export Selection…", "file.exportSelection"), + Sep, + c("Package…", "ui.packageDialog"), + sub("Scripts", vec![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]), + Sep, + c("Document Setup…", "file.documentSetup"), + sub( + "Document Color Mode", + vec![ + cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})), + cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})), + ], + ), + c("File Info…", "file.info"), + Sep, + c("Print…", "file.print"), + Sep, + c("Exit", "app.quit"), + ], + ), + ( + "Edit", + vec![ + c("Undo", "edit.undo"), + c("Redo", "edit.redo"), + Sep, + c("Cut", "edit.cut"), + c("Copy", "edit.copy"), + c("Paste", "edit.paste"), + c("Paste in Front", "edit.pasteInFront"), + c("Paste in Back", "edit.pasteInBack"), + c("Paste in Place", "edit.pasteInPlace"), + c("Paste on All Artboards", "edit.pasteOnAllArtboards"), + c("Paste without Formatting", "edit.pasteWithoutFormatting"), + c("Clear", "edit.clear"), + Sep, + cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})), + cp("Find Next", "edit.findNext", json!({"find": ""})), + sub("Spelling", vec![todo("Auto Spell Check"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")]), + Sep, + sub( + "Edit Colors", + vec![ + c("Recolor Artwork…", "ui.recolorDialog"), + sub( + "Recolor with Preset", + vec![ + cp("1 Color Job…", "ui.recolorDialog", json!({"colors": 1})), + cp("2 Color Job…", "ui.recolorDialog", json!({"colors": 2})), + cp("3 Color Job…", "ui.recolorDialog", json!({"colors": 3})), + cp("Color Library…", "ui.recolorDialog", json!({"library": ""})), + ], + ), + c("Adjust Color Balance…", "ui.colorBalanceDialog"), + c("Blend Front to Back", "edit.colors.blendFrontToBack"), + c("Blend Horizontally", "edit.colors.blendHorizontally"), + c("Blend Vertically", "edit.colors.blendVertically"), + c("Convert to CMYK", "edit.colors.toCMYK"), + c("Convert to Grayscale", "edit.colors.toGrayscale"), + c("Convert to RGB", "edit.colors.toRGB"), + c("Invert Colors", "edit.colors.invert"), + cp( + "Overprint Black…", + "edit.colors.overprintBlack", + json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}), + ), + c("Saturate…", "ui.saturateDialog"), + ], + ), + c("Edit Original", "links.editOriginal"), + Sep, + c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), + c("Print Presets…", "ui.printPresetsDialog"), + c("PDF Presets…", "ui.pdfPresetsDialog"), + c("Perspective Grid Presets…", "ui.perspectivePresetsDialog"), + Sep, + c("Color Settings…", "edit.colorSettings"), + c("Assign Profile…", "edit.assignProfile"), + Sep, + c("Keyboard Shortcuts…", "edit.keyboardShortcuts"), + c("Preferences…", "edit.preferences"), + ], + ), + ( + "Object", + vec![ + sub("Transform", transform_items()), + sub("Arrange", arrange_items()), + sub( + "Align", + vec![ + cp("Horizontal Align Left", "object.align", json!({"horizontal": "left"})), + cp("Horizontal Align Center", "object.align", json!({"horizontal": "center"})), + cp("Horizontal Align Right", "object.align", json!({"horizontal": "right"})), + Sep, + cp("Horizontal & Vertical Align Center", "object.align", json!({"horizontal": "center", "vertical": "center"})), + Sep, + cp("Vertical Align Top", "object.align", json!({"vertical": "top"})), + cp("Vertical Align Center", "object.align", json!({"vertical": "center"})), + cp("Vertical Align Bottom", "object.align", json!({"vertical": "bottom"})), + ], + ), + Sep, + c("Group", "object.group"), + c("Ungroup", "object.ungroup"), + sub( + "Lock", + vec![c("Selection", "object.lock"), c("All Artwork Above", "object.lock.above"), c("Other Layers", "object.lock.otherLayers")], + ), + c("Unlock All", "object.unlockAll"), + sub( + "Hide", + vec![c("Selection", "object.hide"), c("All Artwork Above", "object.hide.above"), c("Other Layers", "object.hide.otherLayers")], + ), + c("Show All", "object.showAll"), + Sep, + c("Expand…", "ui.expandDialog"), + c("Expand Appearance", "effect.expandAppearance"), + c("Crop Image", "ui.cropImage"), + c("Rasterize…", "object.rasterize"), + cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})), + cp( + "Create Object Mosaic…", + "object.createObjectMosaic", + json!({"columns": 10, "rows": 10, "spacingX": 0, "spacingY": 0, "gray": false, "deleteRaster": false}), + ), + c("Vector Halftone…", "object.vectorHalftone"), + c("Create Trim Marks", "object.createTrimMarks"), + c("Flatten Transparency…", "ui.flattenTransparencyDialog"), + Sep, + c("Make Pixel Perfect", "object.makePixelPerfect"), + Sep, + sub( + "Slice", + vec![ + c("Make", "object.slice.make"), + c("Release", "object.slice.release"), + c("Create from Guides", "object.slice.fromGuides"), + c("Create from Selection", "object.slice.fromSelection"), + Sep, + c("Duplicate Slice", "object.slice.duplicate"), + c("Combine Slices", "object.slice.combine"), + c("Divide Slices…", "object.slice.divide"), + Sep, + c("Delete All", "object.slice.deleteAll"), + c("Slice Options…", "object.slice.options"), + c("Clip to Artboard", "object.slice.clipToArtboard"), + ], + ), + Sep, + sub( + "Path", + vec![ + c("Join", "path.join"), + c("Average…", "path.average"), + Sep, + c("Outline Stroke", "object.path.outlineStroke"), + c("Offset Path…", "object.path.offsetPath"), + c("Reverse Path Direction", "path.reverse"), + Sep, + c("Simplify…", "object.path.simplify"), + c("Add Anchor Points", "object.path.addAnchorPoints"), + c("Remove Anchor Points", "path.removeAnchors"), + c("Divide Objects Below", "object.path.divideObjectsBelow"), + c("Split Into Grid…", "object.path.splitIntoGrid"), + Sep, + cp("Clean Up…", "object.path.cleanUp", json!({"strayPoints": true, "unpaintedObjects": true, "emptyTextPaths": true})), + ], + ), + sub("Shape", vec![c("Convert to Shape", "object.shape.convertToShape"), c("Expand Shape", "object.expandShape")]), + sub( + "Pattern", + vec![c("Make", "object.pattern.make"), c("Edit Pattern", "object.pattern.edit"), c("Tile Edge Color…", "ui.tileEdgeColor")], + ), + sub( + "Repeat", + vec![ + c("Radial", "object.repeat.radial"), + c("Grid", "object.repeat.grid"), + c("Mirror", "object.repeat.mirror"), + Sep, + c("Release", "object.repeat.release"), + c("Options…", "object.repeat.options"), + ], + ), + sub( + "Intertwine", + vec![c("Make", "object.intertwine.make"), c("Edit", "object.intertwine.edit"), c("Release", "object.intertwine.release")], + ), + sub("Objects on Path", vec![c("Attach", "object.objectsOnPath.attach"), c("Expand", "object.objectsOnPath.expand")]), + sub( + "Blend", + vec![ + c("Make", "object.blend.make"), + c("Release", "object.blend.release"), + Sep, + c("Blend Options…", "object.blend.options"), + Sep, + c("Expand", "object.blend.expand"), + Sep, + c("Replace Spine", "object.blend.replaceSpine"), + c("Reverse Spine", "object.blend.reverseSpine"), + c("Reverse Front to Back", "object.blend.reverseFrontToBack"), + ], + ), + sub( + "Envelope Distort", + vec![ + // While an envelope is selected, Reset takes Make's place (`swapped_out`). + c("Make with Warp…", "object.envelope.makeWithWarp"), + c("Reset with Warp…", "object.envelope.resetWithWarp"), + c("Make with Mesh…", "object.envelope.makeWithMesh"), + c("Reset with Mesh…", "object.envelope.resetWithMesh"), + c("Make with Top Object", "object.envelope.makeWithTopObject"), + Sep, + c("Release", "object.envelope.release"), + c("Envelope Options…", "object.envelope.options"), + c("Expand", "object.envelope.expand"), + Sep, + c("Edit Contents", "object.envelope.editContents"), + ], + ), + sub( + "Perspective", + vec![ + c("Attach to Active Plane", "perspective.attach"), + c("Release with Perspective", "perspective.release"), + c("Move Plane to Match Object", "perspective.plane.matchObject"), + c("Edit Text", "perspective.editText"), + ], + ), + sub( + "Live Paint", + vec![ + c("Make", "livePaint.make"), + c("Merge", "livePaint.merge"), + c("Release", "livePaint.release"), + Sep, + c("Gap Options…", "ui.livePaintGapOptions"), + Sep, + c("Expand", "livePaint.expand"), + ], + ), + sub( + "Image Trace", + vec![ + // Traced at once with the Default preset, as the Control bar's Image Trace + // button does; other presets come from its arrow or the Image Trace panel + // (#543: the "…" form asked for a preset's name in a text box). + cp("Make", "imageTrace.make", json!({"preset": "Default"})), + cp("Make and Expand", "imageTrace.makeAndExpand", json!({"preset": "Default"})), + c("Release", "imageTrace.release"), + c("Expand", "imageTrace.expand"), + ], + ), + sub( + "Text Wrap", + vec![ + c("Make", "object.textWrap.make"), + c("Release", "object.textWrap.release"), + c("Text Wrap Options…", "object.textWrap.options"), + ], + ), + Sep, + sub( + "Clipping Mask", + vec![ + c("Make", "object.clippingMask.make"), + c("Release", "object.clippingMask.release"), + c("Edit Contents", "object.clippingMask.editContents"), + c("Edit Clipping Path", "object.clippingMask.editMask"), + ], + ), + sub("Compound Path", vec![c("Make", "object.compoundPath.make"), c("Release", "object.compoundPath.release")]), + sub( + "Artboards", + vec![ + c("Convert to Artboards", "artboard.convertToArtboards"), + c("Rearrange All Artboards…", "artboard.rearrange"), + Sep, + c("Fit to Artwork Bounds", "artboard.fitToArt"), + c("Fit to Selected Art", "artboard.fitToSelection"), + ], + ), + sub( + "Graph", + vec![ + c("Type…", "graph.setType"), + c("Data…", "graph.setData"), + c("Design…", "graph.design"), + c("Column…", "graph.column"), + c("Marker…", "graph.marker"), + ], + ), + sub( + "Collect for Export", + vec![ + cp("As Single Asset", "assets.add", json!({"multiple": false})), + cp("As Multiple Assets", "assets.add", json!({"multiple": true})), + ], + ), + Sep, + sub("Plug-ins", crate::dialogs::plugin::object_menu()), + ], + ), + ( + "Type", + vec![ + sub("Font", font_items(english_names)), + sub("Recent Fonts", RECENT_FONT_IDS.iter().map(|id| c("Recent Font", id)).collect()), + sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()), + c("Bold", "type.bold"), + c("Italic", "type.italic"), + Sep, + panel("Glyphs", "glyphs"), + sub("Insert Special Character", insert_items(INSERT_SPECIAL)), + sub("Insert Whitespace Character", insert_items(INSERT_WHITESPACE)), + sub("Insert Break Character", insert_items(INSERT_BREAK)), + Sep, + c("Convert To Area Type", "type.convertToAreaType"), + c("Convert To Point Type", "type.convertToPointType"), + c("Area Type Options…", "text.areaOptions"), + sub( + "Type on a Path", + PATH_EFFECTS + .iter() + .map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect }))) + .chain([Sep, c("Type on a Path Options…", "type.pathOptions")]) + .collect(), + ), + sub( + "Threaded Text", + vec![ + c("Create", "text.thread.create"), + c("Release Selection", "text.thread.releaseSelection"), + c("Remove Threading", "text.thread.remove"), + ], + ), + c("Fit Headline", "text.fitHeadline"), + Sep, + c("Find Font…", "type.findFont"), + sub( + "Change Case", + vec![ + cp("UPPERCASE", "type.changeCase", json!({"case": "upper"})), + cp("lowercase", "type.changeCase", json!({"case": "lower"})), + cp("Title Case", "type.changeCase", json!({"case": "title"})), + cp("Sentence case", "type.changeCase", json!({"case": "sentence"})), + ], + ), + cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})), + sub( + "Bullets and Numbering", + vec![ + c("Apply Bullets", "type.applyBullets"), + c("Apply Numbers", "type.applyNumbers"), + c("Convert to Text", "type.convertListToText"), + ], + ), + Sep, + c("Create Outlines", "type.createOutlines"), + c("Scale Text Attributes", "type.scaleTextAttributes"), + c("Optical Margin Alignment", "type.opticalMarginAlignment"), + Sep, + c("Fill with Placeholder Text", "type.fillPlaceholder"), + c("Insert Inline Symbol", "text.insertInline"), + Sep, + c("Show Hidden Characters", "type.hiddenCharacters"), + sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]), + ], + ), + ("Select", { + let mut v = vec![ + c("All", "select.all"), + c("All on Active Artboard", "select.allOnArtboard"), + c("Deselect", "select.none"), + c("Reselect", "select.reselect"), + Sep, + c("Inverse", "select.inverse"), + Sep, + c("Next Object Above", "select.nextAbove"), + c("Next Object Below", "select.nextBelow"), + Sep, + sub( + "Same", + vec![ + c("Appearance", "select.same.appearance"), + c("Appearance Attribute", "select.same.appearanceAttribute"), + c("Blending Mode", "select.same.blendingMode"), + c("Fill & Stroke", "select.same.fillAndStroke"), + c("Fill Color", "select.same.fillColor"), + c("Opacity", "select.same.opacity"), + c("Stroke Color", "select.same.strokeColor"), + c("Stroke Weight", "select.same.strokeWeight"), + c("Graphic Style", "select.same.graphicStyle"), + c("Shape", "select.same.shapeType"), + c("Symbol Instance", "select.same.symbolInstance"), + Sep, + c("Font Family", "select.same.fontFamily"), + c("Font Family & Style", "select.same.fontFamilyStyle"), + c("Font Family, Style & Size", "select.same.fontFamilyStyleSize"), + c("Font Size", "select.same.fontSize"), + c("Text Fill Color", "select.same.textFillColor"), + c("Text Stroke Color", "select.same.textStrokeColor"), + ], + ), + sub( + "Object", + vec![ + c("All on Same Layers", "select.object.allOnSameLayers"), + c("Direction Handles", "select.object.directionHandles"), + Sep, + c("Brush Strokes", "select.object.brushStrokes"), + c("Bristle Brush Strokes", "select.object.bristleBrushStrokes"), + c("Clipping Masks", "select.object.clippingMasks"), + c("Slices", "select.object.slices"), + c("Stray Points", "select.object.strayPoints"), + c("Open Paths", "select.object.openPaths"), + Sep, + c("All Text Objects", "select.object.textObjects"), + c("Point Text Objects", "select.object.pointText"), + c("Area Text Objects", "select.object.areaText"), + ], + ), + c("Start Global Edit", "select.globalEdit"), + Sep, + c("Save Selection…", "select.save"), + c("Update Selection", "select.update"), + c("Edit Selection…", "select.editSaved"), + Sep, + ]; + v.extend(SAVED_SELECTION_IDS.iter().map(|id| c("Saved Selection", id))); + v + }), + ("Effect", effect_menu()), + ("View", { + let mut v = vec![ + c("Outline", "view.outline"), + c("Overprint Preview", "view.overprintPreview"), + c("Pixel Preview", "view.pixelPreview"), + c("Trim View", "view.trimView"), + c("Presentation Mode", "view.presentation"), + sub( + "Screen Mode", + vec![ + cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})), + cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})), + cp("Full Screen Mode", "view.screenMode", json!({"mode": 2})), + ], + ), + Sep, + sub( + "Proof Setup", + vec![ + cp("Working CMYK", "view.proofSetup", json!({"target": "workingCmyk", "proof": true})), + cp("Legacy Macintosh RGB (Gamma 1.8)", "view.proofSetup", json!({"target": "legacyMacRgb", "proof": true})), + cp("Internet Standard RGB (sRGB)", "view.proofSetup", json!({"target": "srgb", "proof": true})), + cp("Monitor RGB", "view.proofSetup", json!({"target": "monitorRgb", "proof": true})), + cp("Color blindness – Protanopia-type", "view.proofSetup", json!({"target": "protanopia", "proof": true})), + cp("Color blindness – Deuteranopia-type", "view.proofSetup", json!({"target": "deuteranopia", "proof": true})), + cp("Color blindness – Tritanopia-type", "view.proofSetup", json!({"target": "tritanopia", "proof": true})), + cp("Customize…", "window.panel", json!({"panel": "separations"})), + ], + ), + c("Proof Colors", "view.proofColors"), + Sep, + c("Zoom In", "view.zoomIn"), + c("Zoom Out", "view.zoomOut"), + c("Fit Artboard in Window", "view.fitArtboard"), + c("Fit All in Window", "view.fitAll"), + c("Actual Size", "view.actualSize"), + Sep, + sub("Rotate View", crate::state::ROTATE_VIEW_ANGLES.iter().map(|(l, a)| cp(l, "view.rotate", json!({ "angle": a }))).collect()), + c("Rotate View to Selection", "view.rotateToSelection"), + c("Reset Rotate View", "view.rotateReset"), + Sep, + c("Hide Edges", "view.edges"), + c("Hide Artboards", "view.artboards"), + c("Show Print Tiling", "view.printTiling"), + c("Hide Slices", "view.slices.hide"), + c("Lock Slices", "view.slices.lock"), + c("Hide Template", "view.template"), + Sep, + sub( + "Rulers", + vec![ + c("Show Rulers", "view.rulers"), + c("Change to Global Rulers", "view.globalRulers"), + c("Show Video Rulers", "view.videoRulers"), + ], + ), + c("Hide Bounding Box", "view.boundingBox"), + c("Show Transparency Grid", "view.transparencyGrid"), + c("Hide Text Threads", "view.textThreads"), + c("Hide Gradient Annotator", "view.gradientAnnotator"), + c("Hide Corner Widget", "view.cornerWidget"), + c("Show Live Paint Gaps", "view.livePaintGaps"), + Sep, + sub( + "Guides", + vec![ + c("Hide Guides", "view.guides"), + c("Lock Guides", "view.guides.lock"), + c("Make Guides", "view.guides.make"), + c("Release Guides", "view.guides.release"), + c("Clear Guides", "view.guides.clear"), + ], + ), + c("Smart Guides", "view.smartGuides"), + sub( + "Perspective Grid", + vec![ + c("Show Grid", "perspective.grid.show"), + c("Show Rulers", "perspective.grid.rulers"), + c("Snap to Grid", "perspective.grid.snap"), + c("Lock Grid", "perspective.grid.lock"), + c("Lock Station Point", "perspective.grid.lockStation"), + Sep, + c("Define Grid…", "ui.perspectiveGridDialog"), + sub("One Point Perspective", crate::dialogs::perspective_presets::menu(1, PERSPECTIVE_SLOTS[0])), + sub("Two Point Perspective", crate::dialogs::perspective_presets::menu(2, PERSPECTIVE_SLOTS[1])), + sub("Three Point Perspective", crate::dialogs::perspective_presets::menu(3, PERSPECTIVE_SLOTS[2])), + Sep, + c("Save Grid as Preset…", "ui.savePerspectivePreset"), + ], + ), + c("Show Grid", "view.grid"), + c("Snap to Grid", "view.snapToGrid"), + c("Snap to Pixel", "view.snapToPixel"), + c("Snap to Point", "view.snapToPoint"), + c("Snap to Glyph", "view.snapToGlyph"), + Sep, + c("New View…", "view.saved.new"), + c("Edit Views…", "view.saved.edit"), + Sep, + ]; + v.extend(SAVED_VIEW_IDS.iter().map(|id| c("Saved View", id))); + v + }), + ( + "Window", + vec![ + c("New Window", "window.newWindow"), + sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]), + sub("Workspace", crate::workspaces::menu_items()), + Sep, + c("Control", "window.control"), + c("Contextual Task Bar", "window.taskBar"), + c("Tools", "window.toolbar"), + sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns")]), + Sep, + panel("Actions", "actions"), + panel("Align", "align"), + panel("Appearance", "appearance"), + panel("Artboards", "artboards"), + panel("Asset Export", crate::panels::asset_export::ID), + panel("Attributes", "attributes"), + panel("Blend", crate::panels::blend::ID), + panel("Brushes", "brushes"), + panel("Color", "color"), + panel("Color Guide", "colorGuide"), + panel("Color Themes", "colorThemes"), + panel("CSS Properties", crate::panels::css_properties::ID), + panel("Document Info", "docInfo"), + panel("Flattener Preview", crate::panels::flattener_preview::ID), + panel("Gradient", "gradient"), + panel("Graphic Styles", "graphicStyles"), + panel("History", "history"), + panel("Image Trace", "imageTrace"), + panel("Info", "info"), + panel("Layers", "layers"), + panel("Libraries", "libraries"), + panel("Links", crate::panels::links::ID), + panel("Magic Wand", "magicWand"), + panel("Navigator", "navigator"), + panel("Pathfinder", "pathfinder"), + panel("Pattern Options", "patternOptions"), + panel("Properties", "properties"), + panel("Separations Preview", "separations"), + panel("Stroke", "stroke"), + panel("SVG Interactivity", crate::panels::svg_interactivity::ID), + panel("Swatches", "swatches"), + panel("Symbols", "symbols"), + panel("Transform", "transform"), + panel("Transparency", "transparency"), + sub( + "Type", + vec![ + panel("Character", "character"), + panel("Character Styles", "charStyles"), + panel("Glyphs", "glyphs"), + panel("OpenType", "openType"), + panel("Paragraph", "paragraph"), + panel("Paragraph Styles", "paraStyles"), + panel("Tabs", "tabs"), + ], + ), + panel("Variables", crate::panels::variables::ID), + Sep, + sub("Brush Libraries", crate::panels::brushes::window_menu()), + sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), + sub("Swatch Libraries", crate::panels::swatches::window_menu()), + sub("Symbol Libraries", crate::panels::symbols::window_menu()), + ], + ), + ( + "Help", + vec![ + c("Join Our Discord", "help.discord"), + c("ArtCraft Website", "help.website"), + c("VectorCraft on getartcraft.com", "help.appPage"), + c("VectorCraft on GitHub", "help.github"), + Sep, + c("Search Commands…", "help.commandPalette"), + todos("VectorCraft Help…", "F1"), + Sep, + c("Check for Updates…", "help.checkForUpdates"), + c("About VectorCraft", "help.about"), + ], + ), + ] +} + +/// Object → Transform (also in the canvas context menu). +pub(super) fn transform_items() -> Vec { + vec![ + c("Transform Again", "object.transformAgain"), + Sep, + c("Move…", "object.move"), + c("Rotate…", "object.rotate"), + c("Reflect…", "object.reflect"), + c("Scale…", "object.scale"), + c("Shear…", "object.shear"), + Sep, + c("Transform Each…", "object.transformEach"), + Sep, + c("Reset Bounding Box", "object.resetBoundingBox"), + ] +} + +/// Object → Arrange (also in the canvas context menu). +pub(super) fn arrange_items() -> Vec { + vec![ + c("Bring to Front", "object.arrange.bringToFront"), + c("Bring Forward", "object.arrange.bringForward"), + c("Send Backward", "object.arrange.sendBackward"), + c("Send to Back", "object.arrange.sendToBack"), + Sep, + c("Send to Current Layer", "object.arrange.sendToCurrentLayer"), + ] +} diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs new file mode 100644 index 000000000..9867228b5 --- /dev/null +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -0,0 +1,1227 @@ +/// UI-level commands: (id, label, shortcut, params doc). +pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ( + "app.language", + "Interface Language", + "", + "{lang: auto|} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, es, zh-hant)", + ), + ( + "file.open", + "Open…", + "Cmd+O", + "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", + ), + ( + "file.save", + "Save", + "Cmd+S", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + ), + ( + "file.place", + "Place…", + "Cmd+Shift+P", + "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)", + ), + ("file.openRecent1", "Open Recent File 1", "", "{}"), + ("file.openRecent2", "Open Recent File 2", "", "{}"), + ("file.openRecent3", "Open Recent File 3", "", "{}"), + ("file.openRecent4", "Open Recent File 4", "", "{}"), + ("file.openRecent5", "Open Recent File 5", "", "{}"), + ("file.openRecent6", "Open Recent File 6", "", "{}"), + ("file.openRecent7", "Open Recent File 7", "", "{}"), + ("file.openRecent8", "Open Recent File 8", "", "{}"), + ("file.openRecent9", "Open Recent File 9", "", "{}"), + ("file.openRecent10", "Open Recent File 10", "", "{}"), + ("view.goto1", "Saved View 1", "", "{} go to the 1. saved view"), + ("view.goto2", "Saved View 2", "", "{} go to the 2. saved view"), + ("view.goto3", "Saved View 3", "", "{} go to the 3. saved view"), + ("view.goto4", "Saved View 4", "", "{} go to the 4. saved view"), + ("view.goto5", "Saved View 5", "", "{} go to the 5. saved view"), + ("view.goto6", "Saved View 6", "", "{} go to the 6. saved view"), + ("view.goto7", "Saved View 7", "", "{} go to the 7. saved view"), + ("view.goto8", "Saved View 8", "", "{} go to the 8. saved view"), + ("view.goto9", "Saved View 9", "", "{} go to the 9. saved view"), + ("view.goto10", "Saved View 10", "", "{} go to the 10. saved view"), + ("view.goto11", "Saved View 11", "", "{} go to the 11. saved view"), + ("view.goto12", "Saved View 12", "", "{} go to the 12. saved view"), + ("view.goto13", "Saved View 13", "", "{} go to the 13. saved view"), + ("view.goto14", "Saved View 14", "", "{} go to the 14. saved view"), + ("view.goto15", "Saved View 15", "", "{} go to the 15. saved view"), + ("view.goto16", "Saved View 16", "", "{} go to the 16. saved view"), + ("view.goto17", "Saved View 17", "", "{} go to the 17. saved view"), + ("view.goto18", "Saved View 18", "", "{} go to the 18. saved view"), + ("view.goto19", "Saved View 19", "", "{} go to the 19. saved view"), + ("view.goto20", "Saved View 20", "", "{} go to the 20. saved view"), + ("view.goto21", "Saved View 21", "", "{} go to the 21. saved view"), + ("view.goto22", "Saved View 22", "", "{} go to the 22. saved view"), + ("view.goto23", "Saved View 23", "", "{} go to the 23. saved view"), + ("view.goto24", "Saved View 24", "", "{} go to the 24. saved view"), + ("view.goto25", "Saved View 25", "", "{} go to the 25. saved view"), + ("select.recall1", "Saved Selection 1", "", "{} select the 1. saved selection"), + ("select.recall2", "Saved Selection 2", "", "{} select the 2. saved selection"), + ("select.recall3", "Saved Selection 3", "", "{} select the 3. saved selection"), + ("select.recall4", "Saved Selection 4", "", "{} select the 4. saved selection"), + ("select.recall5", "Saved Selection 5", "", "{} select the 5. saved selection"), + ("select.recall6", "Saved Selection 6", "", "{} select the 6. saved selection"), + ("select.recall7", "Saved Selection 7", "", "{} select the 7. saved selection"), + ("select.recall8", "Saved Selection 8", "", "{} select the 8. saved selection"), + ("select.recall9", "Saved Selection 9", "", "{} select the 9. saved selection"), + ("select.recall10", "Saved Selection 10", "", "{} select the 10. saved selection"), + ("select.recall11", "Saved Selection 11", "", "{} select the 11. saved selection"), + ("select.recall12", "Saved Selection 12", "", "{} select the 12. saved selection"), + ("select.recall13", "Saved Selection 13", "", "{} select the 13. saved selection"), + ("select.recall14", "Saved Selection 14", "", "{} select the 14. saved selection"), + ("select.recall15", "Saved Selection 15", "", "{} select the 15. saved selection"), + ("select.recall16", "Saved Selection 16", "", "{} select the 16. saved selection"), + ("select.recall17", "Saved Selection 17", "", "{} select the 17. saved selection"), + ("select.recall18", "Saved Selection 18", "", "{} select the 18. saved selection"), + ("select.recall19", "Saved Selection 19", "", "{} select the 19. saved selection"), + ("select.recall20", "Saved Selection 20", "", "{} select the 20. saved selection"), + ("select.recall21", "Saved Selection 21", "", "{} select the 21. saved selection"), + ("select.recall22", "Saved Selection 22", "", "{} select the 22. saved selection"), + ("select.recall23", "Saved Selection 23", "", "{} select the 23. saved selection"), + ("select.recall24", "Saved Selection 24", "", "{} select the 24. saved selection"), + ("select.recall25", "Saved Selection 25", "", "{} select the 25. saved selection"), + ("type.recentFont1", "Recent Font 1", "", "{} apply the 1. most recently used font"), + ("type.recentFont2", "Recent Font 2", "", "{} apply the 2. most recently used font"), + ("type.recentFont3", "Recent Font 3", "", "{} apply the 3. most recently used font"), + ("type.recentFont4", "Recent Font 4", "", "{} apply the 4. most recently used font"), + ("type.recentFont5", "Recent Font 5", "", "{} apply the 5. most recently used font"), + ("type.recentFont6", "Recent Font 6", "", "{} apply the 6. most recently used font"), + ("type.recentFont7", "Recent Font 7", "", "{} apply the 7. most recently used font"), + ("type.recentFont8", "Recent Font 8", "", "{} apply the 8. most recently used font"), + ("type.recentFont9", "Recent Font 9", "", "{} apply the 9. most recently used font"), + ("type.recentFont10", "Recent Font 10", "", "{} apply the 10. most recently used font"), + ("type.recentFont11", "Recent Font 11", "", "{} apply the 11. most recently used font"), + ("type.recentFont12", "Recent Font 12", "", "{} apply the 12. most recently used font"), + ("type.recentFont13", "Recent Font 13", "", "{} apply the 13. most recently used font"), + ("type.recentFont14", "Recent Font 14", "", "{} apply the 14. most recently used font"), + ("type.recentFont15", "Recent Font 15", "", "{} apply the 15. most recently used font"), + ("file.clearRecent", "Clear Recent Files", "", "{}"), + ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), + ( + "ui.missingFontsDialog", + "Missing Fonts Dialog", + "", + "{folder?} open Missing Fonts (dialog `missingFonts`) for the fonts the active document uses that aren't available (text.missingFonts); with folder, it looks for their files there at once (text.findFontFiles). An error when no font is missing. Opening a document whose fonts are missing shows it after the missing linked file questions (dialog `missingLinks`). Disabled on the web", + ), + ( + "ui.findFontsInFolder", + "Find Fonts in Folder…", + "", + "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", + ), + ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), + ( + "file.export.svg", + "Export As SVG…", + "", + "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)", + ), + ("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"), + ( + "file.exportForScreens", + "Export for Screens…", + "Cmd+Alt+E", + "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})", + ), + ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), + ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), + ( + "file.newWithoutDialog", + "New Without Dialog", + "Cmd+Alt+N", + "{} makes a document with the settings the New Document dialog opens on (the first Recent entry, else the Print category's Letter), named after the next untitled document, without opening the dialog → {index, previewMode}", + ), + ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), + ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), + ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), + ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"), + ("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"), + ("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"), + ("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"), + ( + "shortcuts.preset", + "Keyboard Shortcut Set", + "", + "{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)", + ), + ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), + ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), + ( + "ui.shortcutImportDialog", + "Import Shortcut Set…", + "", + "{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \"\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too", + ), + ("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"), + ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), + ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), + ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), + ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ("view.snapToGlyph", "Snap to Glyph", "", "{} toggle: drawing snaps to live-text baselines and glyph bounds"), + ( + "view.preciseCursors", + "Precise Cursors", + "", + "{} toggle General › Use Precise Cursors: the drawing tools' pointers become a crosshair → true when on", + ), + ( + "imageTrace.preview", + "Image Trace Preview", + "", + "{on?: bool} turn the Image Trace panel's Preview on (true), off (false) or toggle it (omitted), as its checkbox does: on, setting changes trace the selected Image Trace object at once; off, they wait for Trace; returns the new state", + ), + ("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"), + ("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"), + ( + "view.livePaintGaps", + "Show Live Paint Gaps", + "", + "{} toggle drawing the gaps of the selected Live Paint groups (those livePaint.info lists) in each group's Gap Preview Color while edges show → true when on", + ), + ("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"), + ("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"), + ( + "type.alignCenter", + "Align center", + "Cmd+Shift+C", + "{} align the selected type's paragraphs (or those the Type tool's selection touches) centred", + ), + ("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"), + ("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"), + ("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"), + ( + "type.applyBullets", + "Apply Bullets", + "", + "{bullet?: \"dot\"|\"dash\"|\"circle\"|\"square\"|\"none\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \"bullet\"}); \"none\" turns the list off", + ), + ( + "type.applyNumbers", + "Apply Numbers", + "", + "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", + ), + ( + "type.convertListToText", + "Convert to Text", + "", + "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", + ), + ( + "align.horizontalLeft", + "Horizontal Align Left", + "", + "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalCenter", + "Horizontal Align Center", + "", + "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalRight", + "Horizontal Align Right", + "", + "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalVerticalCenter", + "Horizontal & Vertical Align Center", + "", + "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalTop", + "Vertical Align Top", + "", + "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalCenter", + "Vertical Align Center", + "", + "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalBottom", + "Vertical Align Bottom", + "", + "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.distributeVerticalTop", + "Vertical Distribute Top", + "", + "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalCenter", + "Vertical Distribute Center", + "", + "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalBottom", + "Vertical Distribute Bottom", + "", + "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalLeft", + "Horizontal Distribute Left", + "", + "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalCenter", + "Horizontal Distribute Center", + "", + "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalRight", + "Horizontal Distribute Right", + "", + "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalSpace", + "Vertical Distribute Space", + "", + "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalSpace", + "Horizontal Distribute Space", + "", + "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "type.bold", + "Bold", + "", + "{} the selected type (or the Type tool's selection) in its family's Bold face, or back to the regular face when it is bold; keeps italics. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+B", + ), + ( + "type.italic", + "Italic", + "", + "{} the selected type (or the Type tool's selection) in its family's Italic (or Oblique) face, or back upright when it is italic; keeps the weight. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+I", + ), + ("effect.last", "Last Effect…", "Cmd+Alt+Shift+E", "{} open the dialog of the last effect applied"), + ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), + ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), + ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), + ("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"), + ( + "view.actualSize", + "Actual Size", + "Cmd+1", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + ), + ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), + ( + "view.goToArtboard", + "Go to Artboard", + "", + "{index: 0-based number | \"first\" | \"previous\" | \"next\" | \"last\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}", + ), + ("view.edges", "Hide Edges", "Cmd+H", "{}"), + ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), + ( + "view.template", + "Hide Template", + "Cmd+Shift+W", + "{} template layers left off the canvas (Show Template draws them again, dimmed as before) → shown", + ), + ("view.rulers", "Show Rulers", "Cmd+R", "{}"), + ( + "view.videoRulers", + "Show Video Rulers", + "", + "{} the rulers show (as Show Rulers shows them) and count the active artboard in video pixels from its top-left corner, a pixel as wide as its Video Ruler Pixel Aspect Ratio says (artboard.setProps videoRulerPixelAspectRatio) and one point tall; again (Hide Video Rulers), they count in the General unit from the ruler origin as before → whether video rulers show", + ), + ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), + ("view.guides", "Hide Guides", "Cmd+;", "{}"), + ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), + ("view.grid", "Show Grid", "Cmd+'", "{}"), + ("view.snapToGrid", "Snap to Grid", "Cmd+Shift+'", "{}"), + ("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"), + ("view.presentation", "Presentation Mode", "Shift+F", "{}"), + ("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"), + ( + "view.rotate", + "Rotate View", + "", + "{angle: degrees, clockwise on screen as the status bar shows it} turn the view to that angle about the center of the canvas; returns {angle}, normalized to (-180, 180]", + ), + ( + "view.rotateToSelection", + "Rotate View to Selection", + "", + "{} turn the view about the center of the canvas to the angle the selected objects share (selectionRotation in document.inspect, 0 when they differ), so their bounding box is square to the window; returns {angle}", + ), + ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), + ("window.control", "Control", "", "{}"), + ("window.toolbar", "Tools", "", "{}"), + ( + "window.toolbarColumns", + "Toolbar: Single/Double Column", + "", + "{double?: bool} show the toolbar's tools in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", + ), + ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), + ( + "window.floatTools", + "Float Tool Group", + "", + "{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state", + ), + ( + "toolbar.show", + "Toolbar: Show Controls", + "", + "{control?: \"fillStroke\"|\"coloring\"|\"drawMode\"|\"screenMode\", on?: bool} show (true), hide (false) or toggle (omitted) one of the controls under the toolbar's tools (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the All Tools drawer's Show section does; saved with the preferences; returns every control's state", + ), + ( + "window.editToolbar", + "Edit Toolbar", + "", + "{open?: bool} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar, as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state", + ), + ( + "toolbar.addTool", + "Toolbar: Add Tool", + "", + "{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\"} put `tool` in the current (Basic or Advanced) toolbar, moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)", + ), + ( + "toolbar.removeTool", + "Toolbar: Remove Tool", + "", + "{tool: id, group?: bool} take `tool` (with `group`, its whole group) out of the current toolbar (one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots", + ), + ( + "toolbar.reset", + "Toolbar: Reset", + "", + "{} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes; returns its slots", + ), + ("window.taskBar", "Contextual Task Bar", "", "{}"), + ( + "window.taskBar.pin", + "Pin Bar Position", + "", + "{pinned?: bool} keep the Contextual Task Bar where it is instead of following the selection (true), let it follow the selection again from where it is (false) or toggle (omitted), as the bar's More Options menu does; returns the new state", + ), + ("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"), + ( + "window.dock", + "Panels", + "Tab", + "{} hide the panels, the toolbar and the Control bar, or show again the ones the last Tab hid (all three when none of them shows and Tab hid none); returns whether it showed them", + ), + ( + "window.panels", + "Panels Except Tools and Control", + "Shift+Tab", + "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", + ), + ( + "window.panel", + "Show Panel", + "", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + ), + ( + "window.panel.layout", + "Change Panel Layout", + "", + "{action} serialized docking action, including floating geometry, divider sizes and accordion state", + ), + ( + "window.panel.move", + "Move Panel", + "", + "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel, group?: bool, placement?} move a panel (with `group`, its whole group) into the group holding `anchor`: as a tab (before the `before` panel; null appends), a split beside it, or (`above` / `below`) a group of its own above or below it in its dock column or floating window; `placement` (a serialized Placement) replaces zone and before", + ), + ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), + ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), + ( + "window.panel.collapse", + "Collapse Panel Group", + "", + "{panel, collapsed?: bool} minimize the group holding a panel to its tabs (in a floating window, the window to its title bar), or restore it, as a double-click on a tab or the drag bar does; the same as window.panel.minimize {panel, minimized}. Without `collapsed` it toggles. Returns {panel, collapsed, layout}", + ), + ( + "window.panel.float", + "Float Panel", + "", + "{panel: id or \"tools\", x?, y?, width?, height?, group?: bool, column?: bool or n, onto?: id, above?: id, below?: id, collapsed?: bool} float a panel (with `group`, its whole group; with `column`, its whole dock column or floating window) as a floating window with its top-left corner at x, y in window points (default: inside the dock area, cascaded), as dragging its tab out does; a whole floating window keeps its rect unless given one. `onto` makes it a tab of the floating group holding that panel instead; `above` / `below` a group of its own above or below the group holding that panel, in its floating window or dock column. `collapsed` then minimizes (true) or restores (false) its window or group. A hidden panel shows first. \"tools\" floats the Tools panel at x, y. Returns {layout, closed, floating}", + ), + ( + "window.panel.dock", + "Dock Panel", + "", + "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window. Returns {layout, closed, floating}", + ), + ( + "window.collapseDock", + "Collapse Panels to Icons", + "", + "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", + ), + ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), + ( + "window.workspace", + "Workspace", + "", + "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)", + ), + ( + "window.workspace.reset", + "Reset Essentials", + "", + "{} reset the current workspace to the way it was saved, dropping the layout changes made in it", + ), + ("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"), + ("window.workspace.manage", "Manage Workspaces…", "", "{}"), + ("window.workspace.delete", "Delete Workspace", "", "{name}"), + ("window.workspace.rename", "Rename Workspace", "", "{name, to}"), + ("window.workspace.list", "List Workspaces", "", "{}"), + ("window.newWindow", "New Window", "", "{}"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ( + "tool.setOption", + "Tool Option", + "", + "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen", + ), + ( + "effect.dialog", + "Effect…", + "", + "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}", + ), + ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), + ( + "ui.recolorDialog", + "Recolor Artwork…", + "", + "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name; or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", + ), + ( + "ui.cropImage", + "Crop Image", + "", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + ), + ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), + ( + "file.export.pdf", + "Save as PDF…", + "", + "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing; viewAfterSaving opens the file) → {path, bytes, warnings}", + ), + ("help.about", "About VectorCraft", "", "{}"), + ("help.checkForUpdates", "Check for Updates…", "", "{}"), + ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), + ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), + ( + "ui.swatchOptions", + "Swatch Options…", + "", + "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit) or a gradient (the same dialog with `name` only; it shows the gradient), pattern editing for a pattern", + ), + ( + "ui.newSwatch", + "New Swatch…", + "", + "{spot?, global?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new); a color opens as a spot color with `spot`, and as a global process color with `global` alone", + ), + ( + "ui.newColorGroup", + "New Color Group…", + "", + "{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)", + ), + ( + "ui.colorPicker", + "Color Picker…", + "", + "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", + ), + ( + "ui.sampleScreenColor", + "Sample Color from Screen", + "", + "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", + ), + ( + "ui.newBrush", + "New Brush…", + "", + "{ids?} (default: the selected art) open New Brush, what the Brushes panel's New Brush button and art dropped on the panel open (dialog `newBrush`, field `type`: calligraphic|scatter|art|bristle|pattern, Art when there is art, else Calligraphic; scatter, art and pattern need art, else OK gives the error `nothing selected`); OK opens that type's Brush Options for the new brush (dialog `brushOptions` with the fields of ui.brushOptions and `__create`; name: New Brush, numbered when taken; the `ids` given in `__ids`), whose OK runs brush.new {type, name, params, ids?} (a name another brush has gets a number) and whose Cancel adds no brush", + ), + ( + "ui.brushOptions", + "Brush Options…", + "", + "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift, keyColor, and tips: true opens Colorization Tips (what each method does, beside a sample of the brush painted with it), as the Tips button beside Method does; OK runs brush.options", + ), + ( + "ui.graphicStyleOptions", + "Graphic Style Options…", + "", + "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", + ), + ( + "ui.symbolOptions", + "Symbol Options…", + "F8", + "{name?} open Symbol Options (dialog `symbolOptions`, fields `name`, `exportType`: movieClip|graphic, `registration` 0..8). Without `name` and with a selection: New Symbol (default name the first free \"New Symbol\"/\"New Symbol N\", Export Type Movie Clip, Registration centre); OK runs symbol.new {name, exportType, registration}. With `edit: true` or `name`: edit that symbol; OK runs symbol.options {name, newName?, exportType, registration}", + ), + ( + "tool.options", + "Tool Options…", + "", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values})", + ), + ( + "ui.colorGuideOptions", + "Color Guide Options…", + "", + "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)", + ), + ( + "ui.colorBalanceDialog", + "Adjust Color Balance…", + "", + "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step", + ), + ( + "ui.saturateDialog", + "Saturate…", + "", + "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", + ), + ( + "window.swatchLibrary", + "Swatch Library", + "", + "{library: id or name (see swatch.library.list), or the id of a persistent panel's missing library | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}", + ), + ( + "window.swatchLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel", + ), + ( + "ui.libraryPanel", + "Library Panel Options", + "", + "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}", + ), + ( + "ui.libraryPanel.locate", + "Locate Library…", + "", + "{panel: an open library panel's id, path?} (default: pick a file) Locate Library… of a persistent library panel whose library's file is missing: load the library from the file, as Other Library… does, and show it in the panel, which keeps its view, Show Find Field and Persistent. When the file's library has a panel open already, the panel closes and that one is brought forward, made persistent when the closed one was → what ui.libraryPanel answers", + ), + ( + "ui.saveSwatchLibrary", + "Save Swatch Library…", + "", + "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", + ), + ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), + ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), + ("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"), + ("window.userSwatchLibrary4", "User Swatch Library 4", "", "{} open the 4. User Defined swatch library"), + ("window.userSwatchLibrary5", "User Swatch Library 5", "", "{} open the 5. User Defined swatch library"), + ("window.userSwatchLibrary6", "User Swatch Library 6", "", "{} open the 6. User Defined swatch library"), + ("window.userSwatchLibrary7", "User Swatch Library 7", "", "{} open the 7. User Defined swatch library"), + ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), + ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), + ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), + ( + "ui.mergeGraphicStyles", + "Merge Graphic Styles…", + "", + "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)", + ), + ( + "ui.layerOptions", + "Options for Selection…", + "", + "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step)", + ), + ( + "ui.newLayer", + "New Layer Options…", + "Cmd+Alt+L", + "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields", + ), + ( + "ui.newArtboard", + "New Artboard Options…", + "", + "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing", + ), + ( + "ui.layersPanelOptions", + "Panel Options…", + "", + "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, locateSelected (Locate Selected Objects, off by default: when the last selected object changes outside the panel, on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as after layer.locate, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off; a change made during a drag is located when the drag ends), rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)", + ), + ( + "ui.layersExpand", + "Expand Layers Panel Rows", + "", + "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}", + ), + ( + "ui.layersFilter", + "Filter Layers Panel Rows", + "", + "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}", + ), + ( + "ui.tileEdgeColor", + "Tile Edge Color…", + "", + "{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \"Light Blue\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode", + ), + ( + "ui.flattenTransparencyDialog", + "Flatten Transparency…", + "", + "{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step", + ), + ( + "ui.flattenerPresetsDialog", + "Transparency Flattener Presets…", + "", + "{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export", + ), + ( + "ui.flattenerPreview", + "Flattener Preview", + "", + "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it", + ), + ( + "window.graphicStyleLibrary", + "Graphic Style Library", + "", + "{library: id or name (see graphicStyle.libraries), or the id of a persistent panel's missing library | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}", + ), + ( + "window.graphicStyleLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel", + ), + ( + "ui.saveGraphicStyleLibrary", + "Save Graphic Style Library…", + "", + "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", + ), + ( + "window.userGraphicStyleLibrary1", + "User Graphic Style Library 1", + "", + "{} open the 1. User Defined graphic style library (graphicStyle.libraries, category user)", + ), + ("window.userGraphicStyleLibrary2", "User Graphic Style Library 2", "", "{} open the 2. User Defined graphic style library"), + ("window.userGraphicStyleLibrary3", "User Graphic Style Library 3", "", "{} open the 3. User Defined graphic style library"), + ("window.userGraphicStyleLibrary4", "User Graphic Style Library 4", "", "{} open the 4. User Defined graphic style library"), + ("window.userGraphicStyleLibrary5", "User Graphic Style Library 5", "", "{} open the 5. User Defined graphic style library"), + ("window.userGraphicStyleLibrary6", "User Graphic Style Library 6", "", "{} open the 6. User Defined graphic style library"), + ("window.userGraphicStyleLibrary7", "User Graphic Style Library 7", "", "{} open the 7. User Defined graphic style library"), + ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), + ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), + ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), + ( + "window.symbolLibrary", + "Symbol Library", + "", + "{library: id or name (see symbol.libraries) | null (close)} open a symbol library in a library panel of its own (UI state `library_panels`, kind symbols); clicking a symbol there runs symbol.addFromLibrary, dragging one onto the canvas runs it with place → {open, panel, name, count}", + ), + ( + "window.symbolLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load the symbols of another document or a saved symbol library (engine: symbol.loadLibrary), and open them in a library panel", + ), + ( + "ui.saveSymbolLibrary", + "Save Symbols…", + "", + "{names?: [the symbol selected in the Symbols panel]} open Save Symbol Library (dialog `saveSymbolLibrary`: name, user: save to the user library folder, selectedOnly); OK runs symbol.saveLibrary", + ), + ("window.userSymbolLibrary1", "User Symbol Library 1", "", "{} open the 1. User Defined symbol library (symbol.libraries, category user)"), + ("window.userSymbolLibrary2", "User Symbol Library 2", "", "{} open the 2. User Defined symbol library"), + ("window.userSymbolLibrary3", "User Symbol Library 3", "", "{} open the 3. User Defined symbol library"), + ("window.userSymbolLibrary4", "User Symbol Library 4", "", "{} open the 4. User Defined symbol library"), + ("window.userSymbolLibrary5", "User Symbol Library 5", "", "{} open the 5. User Defined symbol library"), + ("window.userSymbolLibrary6", "User Symbol Library 6", "", "{} open the 6. User Defined symbol library"), + ("window.userSymbolLibrary7", "User Symbol Library 7", "", "{} open the 7. User Defined symbol library"), + ("window.userSymbolLibrary8", "User Symbol Library 8", "", "{} open the 8. User Defined symbol library"), + ("window.userSymbolLibrary9", "User Symbol Library 9", "", "{} open the 9. User Defined symbol library"), + ("window.userSymbolLibrary10", "User Symbol Library 10", "", "{} open the 10. User Defined symbol library"), + ( + "window.brushLibrary", + "Brush Library", + "", + "{library: id or name (see brush.libraries) | null (close)} open a brush library in a library panel of its own (UI state `library_panels`, kind brushes); clicking a brush there runs brush.addFromLibrary (with apply when paths are selected), dragging one onto a path runs it with apply on that path, dragging one onto the Brushes panel adds it → {open, panel, name, count}", + ), + ( + "window.brushLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load the brushes of another document or a saved brush library (engine: brush.loadLibrary), and open them in a library panel", + ), + ( + "ui.saveBrushLibrary", + "Save Brushes…", + "", + "{names?: [the brushes selected in the Brushes panel]} open Save Brush Library (dialog `saveBrushLibrary`: name, user: save to the user library folder, selectedOnly); OK runs brush.saveLibrary", + ), + ("window.userBrushLibrary1", "User Brush Library 1", "", "{} open the 1. User Defined brush library (brush.libraries, category user)"), + ("window.userBrushLibrary2", "User Brush Library 2", "", "{} open the 2. User Defined brush library"), + ("window.userBrushLibrary3", "User Brush Library 3", "", "{} open the 3. User Defined brush library"), + ("window.userBrushLibrary4", "User Brush Library 4", "", "{} open the 4. User Defined brush library"), + ("window.userBrushLibrary5", "User Brush Library 5", "", "{} open the 5. User Defined brush library"), + ("window.userBrushLibrary6", "User Brush Library 6", "", "{} open the 6. User Defined brush library"), + ("window.userBrushLibrary7", "User Brush Library 7", "", "{} open the 7. User Defined brush library"), + ("window.userBrushLibrary8", "User Brush Library 8", "", "{} open the 8. User Defined brush library"), + ("window.userBrushLibrary9", "User Brush Library 9", "", "{} open the 9. User Defined brush library"), + ("window.userBrushLibrary10", "User Brush Library 10", "", "{} open the 10. User Defined brush library"), + ( + "ui.expandDialog", + "Expand…", + "", + "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step", + ), + ( + "attributes.openUrl", + "Browser", + "", + "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}", + ), + ( + "ui.javascriptFilesDialog", + "Link JavaScript Files…", + "", + "{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected", + ), + ( + "ui.spotColors", + "Spot Colors…", + "", + "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step", + ), + ( + "ui.menuDialog", + "Menu Dialog", + "", + "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", + ), + ( + "ui.widthPointEdit", + "Width Point Edit…", + "", + "{id, index?: existing width point, t?: 0..1 for a new point, adjustAdjoining?: bool (false)} open Width Point Edit on path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking the stroke opens it too, Shift on a width point enabling adjoining adjustment): OK runs stroke.widthPoint.set; discard: true (Delete) removes an existing point or discards a prospective point", + ), + ( + "ui.corners", + "Corners…", + "", + "{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape", + ), + ( + "ui.simplifyAuto", + "Auto-Simplify", + "", + "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, opening at the level object.path.simplify {auto: true} picks for the selection, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open", + ), + ( + "ui.simplifyMoreOptions", + "More Options", + "", + "{} the Simplify dialog (dialog `simplify`: tolerance (pt), angleThreshold, straightLines, showOriginal, retain: ticked, OK makes Object › Path › Simplify open this dialog with these fields next time) takes the open Simplify slider's place, at the slider's tolerance → {tolerance}; an error when the slider isn't open", + ), + ( + "ui.justification", + "Justification…", + "", + "{} open Justification (the Paragraph panel menu's Justification…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, with Preview (dialog `justification`: wordMin, wordDesired, wordMax 0..1000, letterMin, letterDesired, letterMax -100..500, glyphMin, glyphDesired, glyphMax 50..200 (percent), autoLeading 0..500 (percent), singleWord (fullJustify|alignLeft|alignCenter|alignRight), preview): OK runs text.setFormat with the values that changed, as one undo step, and is disabled while a row's Minimum exceeds its Desired or Desired its Maximum; an error with no type selected", + ), + ( + "ui.hyphenation", + "Hyphenation…", + "", + "{} open Hyphenation (the Paragraph panel menu's Hyphenation…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows (dialog `hyphenation`: hyphenate, wordsLongerThan 1..25, afterFirst 1..15, beforeLast 1..15, hyphenLimit 0..25 (0: no limit), hyphenationZone (pt), hyphenateCapitalized): OK runs text.setFormat with the values that changed, as one undo step; an error with no type selected", + ), + ( + "ui.colorGuideLimit", + "Limit Color Guide to Library", + "", + "{library: swatch library id or name (see swatch.library.list) | \"document\" (the document's swatches) | \"\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}", + ), + ( + "ui.colorPanel", + "Color Panel Options", + "", + "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", + ), + ( + "ui.savePdfDialog", + "Save PDF Dialog", + "", + "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)", + ), + ( + "file.exportAs", + "Export As…", + "", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}", + ), + ( + "ui.fileInfoDialog", + "File Info Dialog", + "", + "{} open File Info (dialog `fileInfo`: the file.info fields, plus __keyword, keywords typed but not added yet); OK runs file.info. The File Info… menu item opens it too", + ), + ( + "ui.rasterEffectsSettingsDialog", + "Document Raster Effects Settings Dialog", + "", + "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too", + ), + ( + "ui.effectGalleryDialog", + "Effect Gallery Dialog", + "", + "{} open Effect Gallery (dialog `effectGallery`: browse raster filters, build a stack, OK applies it as separate effects in one undo step). Effect → Effect Gallery… opens it too", + ), + ( + "ui.pdfPresetsDialog", + "PDF Presets…", + "", + "{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export", + ), + ( + "ui.pdfPresetDialog", + "PDF Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets", + ), + ("file.openRecent11", "Open Recent File 11", "", "{}"), + ("file.openRecent12", "Open Recent File 12", "", "{}"), + ("file.openRecent13", "Open Recent File 13", "", "{}"), + ("file.openRecent14", "Open Recent File 14", "", "{}"), + ("file.openRecent15", "Open Recent File 15", "", "{}"), + ("file.openRecent16", "Open Recent File 16", "", "{}"), + ("file.openRecent17", "Open Recent File 17", "", "{}"), + ("file.openRecent18", "Open Recent File 18", "", "{}"), + ("file.openRecent19", "Open Recent File 19", "", "{}"), + ("file.openRecent20", "Open Recent File 20", "", "{}"), + ("file.openRecent21", "Open Recent File 21", "", "{}"), + ("file.openRecent22", "Open Recent File 22", "", "{}"), + ("file.openRecent23", "Open Recent File 23", "", "{}"), + ("file.openRecent24", "Open Recent File 24", "", "{}"), + ("file.openRecent25", "Open Recent File 25", "", "{}"), + ("file.openRecent26", "Open Recent File 26", "", "{}"), + ("file.openRecent27", "Open Recent File 27", "", "{}"), + ("file.openRecent28", "Open Recent File 28", "", "{}"), + ("file.openRecent29", "Open Recent File 29", "", "{}"), + ("file.openRecent30", "Open Recent File 30", "", "{}"), + ("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"), + ( + "file.cancelExport", + "Cancel Export", + "", + "{} stop every export running in the background (Background Export): an export stops between artboards and writes no file once cancelled → {cancelled: n}", + ), + ( + "file.exportInBackground", + "Continue in Background", + "", + "{} close the export progress dialog; the exports go on, shown in the status bar → {hidden: n}", + ), + ( + "ui.dxfOptionsDialog", + "DXF Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", + ), + ( + "links.editOriginal", + "Edit Original", + "", + "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", + ), + ( + "links.reveal", + "Show in Folder", + "", + "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}", + ), + ( + "ui.placementOptionsDialog", + "Placement Options…", + "", + "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", + ), + ( + "ui.findLinksInFolder", + "Find Linked Files in Folder…", + "", + "{folder?, ids?: [image or placed document ids]} the Links panel's Find in Folder…: asks for a folder (or takes folder), then opens Find Linked Files (dialog `findLinks`) looking in it and its subfolders for the files of linked objects `ids` (default: the selected linked images and placed documents, else every missing file; links.findFiles). An error when there is nothing to look for. Disabled where there is no folder picker (the web)", + ), + ( + "ui.packageDialog", + "Package…", + "Cmd+Alt+Shift+P", + "{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage", + ), + ( + "ui.batchDialog", + "Batch…", + "", + "{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides", + ), + ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), + ( + "docInfo.save", + "Save Document Info…", + "", + "{path?, selectionOnly?} write Document Info's text report (document.info {format: \"text\"}) to path, else a picked file (the web downloads it) → {path}", + ), + ( + "ui.epsOptionsDialog", + "EPS Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too", + ), + ( + "file.saveForWeb", + "Save for Web (Legacy)…", + "Cmd+Alt+Shift+S", + "{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}", + ), + ( + "file.saveForWeb.browser", + "Preview in Browser", + "", + "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}", + ), + ( + "file.exportSelection", + "Export Selection…", + "", + "{multiple?: false} collect the selected objects as assets (assets.add: one of them all, as one image; true: one per object) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead", + ), + ( + "css.copy", + "Copy Selected Style", + "", + "{scope?: selection|all (default selection), …css.selection options} copy the CSS of the selection (css.selection; all: css.generate) to the clipboard (CSS Properties panel) → {css, rules: n}", + ), + ( + "css.exportFile", + "Export CSS…", + "", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + ), + ( + "file.print", + "Print…", + "Cmd+P", + "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", + ), + ( + "print.printers", + "Printers", + "", + "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}", + ), + ("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"), + ( + "ui.printPresetsDialog", + "Print Presets…", + "", + "{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export", + ), + ( + "ui.printPresetDialog", + "Print Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets", + ), + ( + "plugin.dialog", + "Plug-in…", + "", + "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", + ), + ( + "ui.installPlugin", + "Install Plug-in…", + "", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks", + ), + ( + "plugin.cancelRuns", + "Cancel Plug-in Run", + "", + "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", + ), + ( + "plugin.hideRuns", + "Continue in Background", + "", + "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}", + ), + ( + "ui.checkScript", + "Check Script…", + "", + "{path? | data? | dataBase64?, name?, includes?: true with a path} check a script written for the reference app without running it (script.check; with a path, the files its #include lines name are checked too) and show the report (dialog `scriptCheck`: `report`, script.check's result; Copy Report copies it as JSON); without a file pick one (the web opens the browser's file picker; File › Open of a .jsx, .jsxinc or .jsxbin file shows the report too)", + ), + ( + "ui.perspectiveGridDialog", + "Define Perspective Grid…", + "", + "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define", + ), + ( + "ui.perspectivePresetsDialog", + "Perspective Grid Presets…", + "", + "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*", + ), + ( + "ui.savePerspectivePreset", + "Save Grid as Preset…", + "", + "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields", + ), + ( + "ui.perspectiveUserPreset1.1", + "One Point Perspective Preset 1", + "", + "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.2", + "One Point Perspective Preset 2", + "", + "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.3", + "One Point Perspective Preset 3", + "", + "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.4", + "One Point Perspective Preset 4", + "", + "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.5", + "One Point Perspective Preset 5", + "", + "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.1", + "Two Point Perspective Preset 1", + "", + "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.2", + "Two Point Perspective Preset 2", + "", + "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.3", + "Two Point Perspective Preset 3", + "", + "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.4", + "Two Point Perspective Preset 4", + "", + "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.5", + "Two Point Perspective Preset 5", + "", + "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.1", + "Three Point Perspective Preset 1", + "", + "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.2", + "Three Point Perspective Preset 2", + "", + "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.3", + "Three Point Perspective Preset 3", + "", + "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.4", + "Three Point Perspective Preset 4", + "", + "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.5", + "Three Point Perspective Preset 5", + "", + "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.blendOptions", + "Blend Options…", + "", + "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with", + ), + ( + "ui.livePaintGapOptions", + "Gap Options…", + "", + "{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step", + ), + ( + "ui.perspectivePlane", + "Perspective Plane Options…", + "", + "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move", + ), +];