vectorcraft/crates/ui-egui/src/menus.rs

2918 lines
145 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The menu bar (Illustrator's menu tree) and UI-level commands.
//!
//! Items bound to a command id run through [`VectorcraftApp::run`]. Items not implemented yet are
//! listed (disabled, with their shortcut) so the full surface is visible and discoverable; the
//! parity tracker drives them to "done".
use serde::Serialize;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::SaveMode;
use crate::VectorcraftApp;
use crate::io;
use crate::state::{DockTab, ICON_PANELS, next_zoom};
use crate::theme::{self, Brightness, Tokens};
use crate::widgets;
#[derive(Clone, Debug)]
pub enum Item {
/// (label, command id, params)
Cmd(&'static str, &'static str, Value),
/// Not implemented yet: (label, shortcut)
Todo(&'static str, &'static str),
Sub(&'static str, Vec<Item>),
Sep,
/// Section header (disabled label, e.g. "Vector Effects").
Header(&'static str),
}
fn c(label: &'static str, id: &'static str) -> Item {
Item::Cmd(label, id, Value::Null)
}
fn cp(label: &'static str, id: &'static str, p: Value) -> Item {
Item::Cmd(label, id, p)
}
fn todo(label: &'static str) -> Item {
Item::Todo(label, "")
}
fn todos(label: &'static str, sc: &'static str) -> Item {
Item::Todo(label, sc)
}
fn sub(label: &'static str, items: Vec<Item>) -> Item {
Item::Sub(label, items)
}
/// Window → … Libraries until the code-generated libraries land (disabled entries).
fn library_placeholders() -> Vec<Item> {
vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")]
}
use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("app.language", "Interface Language", "", "{lang: en|ja} — persistent interface language"),
("file.open", "Open…", "Cmd+O", "{path?}"),
(
"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"),
("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"),
("file.clearRecent", "Clear Recent Files", "", "{}"),
("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"),
("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); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager)",
),
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
("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:<id>, 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?}"),
("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.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"),
("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"),
("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", "{}"),
("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"),
("view.edges", "Hide Edges", "Cmd+H", "{}"),
("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"),
("view.rulers", "Show Rulers", "Cmd+R", "{}"),
("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.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"),
("window.control", "Control", "", "{}"),
("window.toolbar", "Tools", "", "{}"),
("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{}"),
("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"),
("window.taskBar", "Contextual Task Bar", "", "{}"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"),
("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"),
("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)",
),
("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.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?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new)",
),
(
"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), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke",
),
(
"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)",
),
(
"tool.options",
"Tool Options…",
"",
"{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), 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})",
),
(
"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) | null (close)} open the read-only library panel on a swatch library (UI state `library_panel`); clicking a swatch there runs swatch.library.add with apply → {open, name, count}",
),
(
"window.swatchLibrary.other",
"Other Library…",
"",
"{path?} (default: pick a file) load a .vcswatches or .gpl library, or another document's swatches (engine: swatch.library.load), and open it in the library panel",
),
(
"ui.saveSwatchLibrary",
"Save Swatch Library…",
"",
"{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|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.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) | null (close)} open the read-only library panel on a graphic style library (UI state `library_panel`, kind graphicStyles); clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add) → {open, 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 the 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"),
(
"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) → {url}",
),
(
"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|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, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; 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} open Width Point Edit for width point `index` of path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking a width point with the Width tool opens it too): OK runs stroke.widthPoint.set, discard: true (the Delete button) stroke.widthPoint.remove",
),
(
"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.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.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}"),
(
"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) → {path}; edits saved there 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.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",
),
("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?: true} collect the selected objects as assets (assets.add: one per object; false: one of them all) 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…]}",
),
(
"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 {settings? (over the document'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} 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)",
),
(
"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.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",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if id == "app.language" {
let language = p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse);
let Some(language) = language else { return Some(Err("lang must be en or ja".into())) };
app.ui.language = language;
return Some(Ok(json!(language)));
}
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)) {
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)
}
"app.home" => {
app.ui.home = Some(home_key(app));
Ok(Value::Null)
}
"file.open" => match s("path") {
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
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),
id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() {
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
None => Err("no such recent file".into()),
},
"type.findFont" => {
crate::find_font::open(app);
Ok(Value::Null)
}
"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, &params).map_err(|e| e.to_string())
}
id if id.starts_with("view.goto") => {
let n: usize = id["view.goto".len()..].parse().unwrap_or(0);
let saved = app.session.active().and_then(|d| n.checked_sub(1).and_then(|i| d.doc.views.get(i)).cloned());
match (saved, app.view_mut()) {
(Some(sv), Some(v)) => {
v.center = sv.center;
v.zoom = sv.zoom;
v.rotation = sv.rotation;
Ok(json!({ "name": sv.name }))
}
_ => Err("no such view".into()),
}
}
"view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel),
"view.textThreads" => flag(&mut app.ui.view.text_threads),
"type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars),
"view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator),
"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.ui.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.rulers" => flag(&mut app.ui.view.rulers),
"view.boundingBox" => flag(&mut app.ui.view.bounding_box),
"view.guides" => flag(&mut app.ui.view.guides),
"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";
match app.view_mut() {
Some(v) => {
v.zoom = next_zoom(v.zoom, up);
Ok(json!({"zoom": v.zoom * 100.0}))
}
None => Err("no document".into()),
}
}
"view.setZoom" => {
let z = p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0) / 100.0;
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.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,
None => (app.ui.screen_mode + 1) % 3,
};
Ok(json!(app.ui.screen_mode))
}
"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" => flag(&mut app.ui.toolbar_double),
"window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced),
"window.taskBar" => flag(&mut app.ui.task_bar),
"window.dock" => {
let on = !(app.ui.dock && app.ui.toolbar);
app.ui.dock = on;
app.ui.toolbar = on;
app.ui.control_bar = on;
Ok(json!(on))
}
"window.panel" => {
let panel = s("panel").unwrap_or_default();
match panel.as_str() {
"properties" => {
app.ui.dock_tab = DockTab::Properties;
Ok(Value::Null)
}
"layers" => {
app.ui.dock_tab = DockTab::Layers;
Ok(Value::Null)
}
"libraries" => {
app.ui.dock_tab = DockTab::Libraries;
Ok(Value::Null)
}
p if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
app.ui.dock = true;
Ok(json!({"open": app.ui.open_panel}))
}
other => Err(format!("unknown panel `{other}`")),
}
}
"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("multiple windows land with M11.5".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.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.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" => crate::dialogs::new_swatch::open(app, p.get("spot").and_then(Value::as_bool).unwrap_or(false), 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.graphicStyleOptions" => crate::dialogs::graphic_style_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.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),
"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()),
},
"ui.expandDialog" => crate::dialogs::expand::open(app),
"attributes.openUrl" => crate::panels::attributes::open_url(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(menu_dialog) {
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.colorGuideLimit" => crate::panels::color_guide::set_limit(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.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.packageDialog" => crate::dialogs::package::open(app),
"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 assets, 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(true) })).map(|r| {
let ids: Vec<u64> = 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, s("path")),
"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.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p),
_ => return None,
};
Some(r)
}
/// Checked state for toggle items.
pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
if id == "app.language" {
return Some(p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse) == Some(app.ui.language));
}
let v = &app.ui.view;
Some(match id {
"type.orientation.vertical" | "type.orientation.horizontal" => {
let st = app.session.active()?;
let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(vectorcraft_engine::doc::NodeKind::Text(t)) => Some(t),
_ => None,
})?;
t.vertical == (id == "type.orientation.vertical")
}
"view.outline" => v.outline,
"view.pixelPreview" => v.pixel_preview,
"view.trimView" => v.trim_view,
"view.snapToPixel" => v.snap_to_pixel,
// Type on a Path effect of the selected path type.
"type.pathOptions" if p.get("effect").is_some() => {
let st = app.session.active()?;
let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(vectorcraft_engine::doc::NodeKind::Text(t)) if matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. }) => Some(t),
_ => None,
})?;
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,
"window.control" => app.ui.control_bar,
"window.toolbar" => app.ui.toolbar,
"window.toolbarAdvanced" => app.ui.toolbar_advanced,
"window.taskBar" => app.ui.task_bar,
"window.panel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
match panel {
"properties" => app.ui.dock_tab == DockTab::Properties,
"layers" => app.ui.dock_tab == DockTab::Layers,
"libraries" => app.ui.dock_tab == DockTab::Libraries,
_ => app.ui.open_panel.as_deref() == Some(panel),
}
}
"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)?.snap,
"perspective.grid.lockStation" => perspective_grid(app)?.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" })
}
_ => return None,
})
}
/// The active document's perspective grid (View → Perspective Grid toggles).
fn perspective_grid(app: &VectorcraftApp) -> Option<vectorcraft_tools::distort::perspective::PerspectiveGrid> {
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.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(),
"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.ui.recent_fonts.get(i)).cloned().unwrap_or_else(|| "—".into())
}
id if id.starts_with("view.goto") => {
let n: usize = id["view.goto".len()..].parse().unwrap_or(0);
app.session.active().and_then(|d| n.checked_sub(1).and_then(|i| d.doc.views.get(i)).map(|v| v.name.clone())).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.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()
}
"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::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(),
}
}
/// 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")
|| 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::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 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)?)
}
/// A menu slot hidden while it is empty ([`hidden_when_disabled`]) rather than shown disabled (the
/// native menu leaves it out).
pub fn hidden_slot(app: &VectorcraftApp, id: &str) -> bool {
hidden_when_disabled(id) && !enabled(app, id)
}
/// How many saved-view, recent-file and User Defined library slots are listed: a menu that can't
/// hide items (the native one) is rebuilt when this changes.
pub fn listed_slots(app: &VectorcraftApp) -> usize {
use vectorcraft_engine::cmd::{stylelib, swatchlib};
let user = |libs: Vec<swatchlib::LibraryInfo>| libs.iter().filter(|l| l.category == "user").count().min(10);
let views = app.session.active().map_or(0, |d| d.doc.views.len().min(10));
views
+ io::recent_files(app).len().min(RECENT_IDS.len())
+ user(swatchlib::libraries(&app.session))
+ user(stylelib::libraries(&app.session))
+ crate::dialogs::perspective_presets::listed_slots(app)
}
/// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins
/// list them, so a menu built once (the native one) is rebuilt.
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",
],
];
/// Type → Recent Fonts slots.
const RECENT_FONT_IDS: [&str; 10] = [
"type.recentFont1",
"type.recentFont2",
"type.recentFont3",
"type.recentFont4",
"type.recentFont5",
"type.recentFont6",
"type.recentFont7",
"type.recentFont8",
"type.recentFont9",
"type.recentFont10",
];
/// 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.
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.starts_with("edit.paste"))
}
pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
if waits_for_ime(app, id) {
return false;
}
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()),
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
"file.place"
| "file.export.svg"
| "file.export.png"
| "file.exportAs"
| "file.exportForScreens"
| "file.documentSetup"
| "view.zoomIn"
| "view.zoomOut"
| "view.fitArtboard"
| "view.fitAll"
| "view.actualSize" => app.session.active().is_some(),
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::<usize>().is_ok_and(|n| n >= 1 && n <= app.ui.recent_fonts.len()) && app.session.active().is_some()
}
id if id.starts_with("view.goto") => {
id["view.goto".len()..].parse::<usize>().is_ok_and(|n| n >= 1 && app.session.active().is_some_and(|d| n <= d.doc.views.len()))
}
"effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"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.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
"ui.graphicStyleOptions" => app.session.active().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.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.dxfOptionsDialog" => app.session.active().is_some(),
"links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()),
"ui.placementOptionsDialog" => selected_image(app, |_| true),
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
"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(),
_ => true,
}
}
/// 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))))
})
}
pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id }));
vec![
(
"VectorCraft",
vec![
c("About VectorCraft", "help.about"),
c("Join Our Discord", "help.discord"),
Sep,
c("Settings…", "edit.preferences"),
sub("Language", vec![cp("English", "app.language", json!({"lang": "en"})), cp("日本語", "app.language", json!({"lang": "ja"}))]),
Sep,
sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()),
Sep,
c("Quit VectorCraft", "app.quit"),
],
),
(
"File",
vec![
c("New…", "file.newDialog"),
c("New from Template…", "file.newFromTemplate"),
c("Open…", "file.open"),
sub("Open Recent Files", {
let mut v: Vec<Item> = 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![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"),
],
),
(
"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"})),
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", "object.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.deleteAnchors"),
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(
"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,
todo("Gap Options…"),
Sep,
c("Expand", "livePaint.expand"),
],
),
sub(
"Image Trace",
vec![
cp("Make…", "imageTrace.make", json!({"preset": "Black and White Logo"})),
cp("Make and Expand…", "imageTrace.makeAndExpand", json!({"preset": "6 Colors"})),
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"),
cp("Rearrange All Artboards…", "artboard.rearrange", json!({"columns": 2, "spacing": 20, "moveArtwork": true})),
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"), todo("Design…"), todo("Column…"), todo("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()),
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()),
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",
vec![
cp("Rainbow", "type.pathOptions", json!({"effect": "rainbow"})),
cp("Skew", "type.pathOptions", json!({"effect": "skew"})),
cp("3D Ribbon", "type.pathOptions", json!({"effect": "3dRibbon"})),
cp("Stair Step", "type.pathOptions", json!({"effect": "stairStep"})),
cp("Gravity", "type.pathOptions", json!({"effect": "gravity"})),
Sep,
cp("Type on a Path Options…", "type.pathOptions", json!({"start": 0})),
],
),
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"})),
Sep,
c("Create Outlines", "type.createOutlines"),
todo("Optical Margin Alignment"),
Sep,
c("Fill with Placeholder Text", "type.fillPlaceholder"),
Sep,
c("Show Hidden Characters", "type.hiddenCharacters"),
sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]),
],
),
(
"Select",
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"),
],
),
todo("Start Global Edit"),
Sep,
c("Save Selection…", "select.save"),
todo("Edit Selection…"),
],
),
("Effect", effect_menu()),
(
"View",
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("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,
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"),
Sep,
sub("Rulers", vec![c("Show Rulers", "view.rulers"), todos("Change to Global Rulers", "Cmd+Alt+R"), todo("Show Video Rulers")]),
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"),
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"),
todo("Snap to Glyph"),
Sep,
c("New View…", "view.saved.new"),
c("Edit Views…", "view.saved.edit"),
Sep,
c("Saved View 1", "view.goto1"),
c("Saved View 2", "view.goto2"),
c("Saved View 3", "view.goto3"),
c("Saved View 4", "view.goto4"),
c("Saved View 5", "view.goto5"),
c("Saved View 6", "view.goto6"),
c("Saved View 7", "view.goto7"),
c("Saved View 8", "view.goto8"),
c("Saved View 9", "view.goto9"),
c("Saved View 10", "view.goto10"),
],
),
(
"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("Single / 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("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"),
todo("SVG Interactivity"),
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"),
],
),
todo("Variables"),
Sep,
sub("Brush Libraries", library_placeholders()),
sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()),
sub("Swatch Libraries", crate::panels::swatches::window_menu()),
sub("Symbol Libraries", library_placeholders()),
],
),
(
"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("About VectorCraft", "help.about"),
],
),
]
}
/// Object → Transform (also in the canvas context menu).
fn transform_items() -> Vec<Item> {
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<Item> {
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<Item> {
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]);
}
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 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"));
}
if any(|k| matches!(k, NodeKind::Compound { .. })) {
v.push(c("Release Compound Path", "object.compoundPath.release"));
}
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<Item>) -> Vec<Item> {
let mut out: Vec<Item> = 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);
});
}
/// 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 mut clicked: Option<(String, Value)> = None;
let tree = menu_tree();
let end = egui::MenuBar::new()
.ui(ui, |ui| {
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 {
egui::RichText::new(app.ui.language.tr(title)).font(theme::semibold(13.0)).color(t.text)
} else {
egui::RichText::new(app.ui.language.tr(title)).size(13.0).color(t.text)
};
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
}
ui.cursor().min.x
})
.inner;
if let Some((id, p)) = clicked {
invoke(app, &id, p);
}
end
}
/// 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 it in items {
match it {
Item::Sep => {
ui.separator();
}
Item::Header(h) => {
ui.label(egui::RichText::new(app.ui.language.tr(h)).size(11.0).color(t.text_dim));
}
Item::Sub(label, children) => {
ui.menu_button(app.ui.language.tr(label), |ui| {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(200.0);
render_items(app, ui, children, checks, clicked);
});
});
}
Item::Todo(label, sc) => {
ui.add_enabled_ui(false, |ui| {
ui.add(egui::Button::new(app.ui.language.tr(label)).shortcut_text(pretty_shortcut(sc)));
})
.response
.on_disabled_hover_text("Coming soon — tracked in the parity plan");
}
Item::Cmd(label, id, p) => {
let en = enabled(app, id);
// Unused saved-view and recent-file slots are hidden (only the real ones are listed).
if (!en && hidden_when_disabled(id)) || swapped_out(app, id) {
continue;
}
let label = dynamic_label(app, id, label);
let label = app.ui.language.tr(&label).to_string();
let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default();
let chk = checks.then(|| checked(app, id, p)).flatten();
let text = match chk {
Some(true) => format!("✓ {label}"),
Some(false) => format!(" {label}"),
None => label,
};
let r = ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc));
if r.clicked() {
*clicked = Some(click_target(label_of(it), id, p));
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<u64>, usize) {
(app.session.active().map(|d| d.uid), app.session.documents().len())
}
fn label_of(it: &Item) -> &'static str {
match it {
Item::Cmd(l, _, _) => l,
_ => "",
}
}
/// 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 (kind, fields) the menu item of command `id` opens, as the reference app's does.
fn menu_dialog(id: &str) -> Option<(&'static str, Value)> {
Some(match id {
"object.move" => ("move", json!({"dx": "0 pt", "dy": "0 pt"})),
"object.rotate" => ("rotate", json!({"angle": 0})),
"object.scale" => ("scale", json!({"sx": 100, "sy": 100, "uniform": true})),
"object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::fields()),
"object.reflect" => ("reflect", json!({"axis": "vertical"})),
"object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})),
"path.average" => ("average", json!({"axis": "both"})),
"object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})),
"object.path.simplify" => ("simplify", json!({"tolerance": "1 pt"})),
"object.path.splitIntoGrid" => ("splitIntoGrid", json!({"rows": 2, "columns": 2, "gutter": "12 pt"})),
"object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()),
_ => return None,
})
}
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).
pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
if waits_for_ime(app, id) {
return;
}
if let Some((kind, fields)) = menu_dialog(id)
&& p.as_object().is_none_or(|o| o.is_empty())
{
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;
}
// 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: a dialog with the selected area type's current values.
if id == "text.areaOptions" && 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": "Area Type Options", "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()) {
match app.session.execute(id, &json!({})) {
Ok(v) => {
let (label, fields) = if id == "graph.setData" { ("Graph Data", json!({"csv": v["csv"]})) } else { ("Graph Type", v) };
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
return;
}
// New View… / Edit Views…: name dialogs.
if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) {
let n = app.session.active().map_or(0, |d| d.doc.views.len()) + 1;
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": format!("View {n}")}}));
return;
}
if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) {
let first = app.session.active().and_then(|d| d.doc.views.first().map(|v| v.name.clone()));
match first {
Some(name) => {
let _ = app
.run("ui.paramDialog", json!({"command": id, "label": "Edit Views", "params": {"name": name, "newName": "", "delete": false}}));
}
None => app.status("No saved views (View → New View…)"),
}
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;
}
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<String>,
pub label: String,
pub command: Option<String>,
pub params: Value,
pub enabled: bool,
pub shortcut: String,
}
/// Flattened menu for `ui.menu.list`.
pub fn menu_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
let mut out = vec![];
for (title, items) in menu_tree() {
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<MenuEntry> {
let mut out = vec![];
flatten(app, vec![], &context_items(app), &mut out);
out
}
fn flatten(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
for it in items {
match it {
Item::Cmd(_, id, _) if (hidden_when_disabled(id) && !enabled(app, id)) || swapped_out(app, id) => {}
Item::Cmd(l, id, p) => out.push(MenuEntry {
path: path.clone(),
label: dynamic_label(app, id, l),
command: Some(id.to_string()),
params: p.clone(),
enabled: enabled(app, id),
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),
];
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<Item> {
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 change (the menu tree is built every frame); labels are interned once each, so
/// rebuilding doesn't allocate forever.
fn font_items() -> Vec<Item> {
static NAMES: std::sync::Mutex<std::collections::BTreeSet<&'static str>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
static ITEMS: std::sync::Mutex<(u64, Vec<Item>)> = std::sync::Mutex::new((u64::MAX, 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.family_list();
let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] };
if items.0 != generation {
let list = fams
.iter()
.map(|f| {
let label: &'static str = match names.get(f.as_str()) {
Some(n) => n,
None => {
let n: &'static str = Box::leak(f.clone().into_boxed_str());
names.insert(n);
n
}
};
cp(label, "text.setStyle", json!({ "font": label }))
})
.collect();
*items = (generation, list);
}
items.1.clone()
}
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
fn effect_menu() -> Vec<Item> {
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, an item of its own) in the reference app's order.
let order = [
"3D and Materials",
"Color Adjustments",
"Convert to Shape",
"Crop Marks",
"Distort & Transform",
"Path",
"Pathfinder",
"Stylize",
"SVG Filters",
"Warp",
"Blur",
];
let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label);
for sub_name in order {
if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) {
out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })));
continue;
}
let items: Vec<Item> = cat
.iter()
.filter(|e| e.menu.last().copied() == Some(sub_name))
.map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
// No options (Effect → Pathfinder): 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 })),
})
.collect();
if sub_name == "Blur" {
// Live-effect plug-ins close the vector effects.
out.extend(crate::dialogs::plugin::effect_menu());
if !items.is_empty() {
out.push(Sep);
out.push(Item::Header("Raster Effects"));
out.push(sub(sub_name, items));
}
continue;
}
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 {
out.push(sub(sub_name, items));
}
}
// Anything not placed above (future effects) still shows up.
for e in cat.iter().filter(|e| !top_level(e) && !e.menu.last().is_some_and(|m| order.contains(m))) {
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,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn last_effect_dialog_and_view_toggles() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
assert!(!enabled(&app, "effect.last"));
app.last_effect = Some(("distort.roughen".into(), json!({"size": 9})));
assert!(enabled(&app, "effect.last"));
assert_eq!(dynamic_label(&app, "effect.last", "Last Effect…"), "Last Effect: Roughen…");
app.run("effect.last", json!({})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.str("__effect"), d.f64("size", 0.0)), ("effect", "distort.roughen".to_string(), 9.0));
for (id, on, off) in [
("view.textThreads", "Hide Text Threads", "Show Text Threads"),
("view.gradientAnnotator", "Hide Gradient Annotator", "Show Gradient Annotator"),
("type.hiddenCharacters", "Show Hidden Characters", "Hide Hidden Characters"),
] {
assert_eq!(dynamic_label(&app, id, ""), on);
app.run(id, json!({})).unwrap();
assert_eq!(dynamic_label(&app, id, ""), off);
}
}
#[test]
fn saved_views_store_and_restore_the_view() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
{
let v = app.view_mut().unwrap();
v.zoom = 3.0;
v.center = vectorcraft_geom::Point::new(120.0, 80.0);
}
app.run("view.saved.new", json!({"name": "Detail"})).unwrap();
assert!(enabled(&app, "view.goto1") && !enabled(&app, "view.goto2"));
assert_eq!(dynamic_label(&app, "view.goto1", ""), "Detail");
app.view_mut().unwrap().zoom = 0.5;
app.run("view.goto1", json!({})).unwrap();
let v = app.view().unwrap();
assert_eq!((v.zoom, v.center.x, v.center.y), (3.0, 120.0, 80.0));
// Unused slots stay out of the menu listing agents see.
assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2")));
}
#[test]
fn recent_fonts_and_corner_widget_toggle() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let fams = vectorcraft_text::FontDb::global().families();
let (a, b) = (fams[0].clone(), fams[fams.len() - 1].clone());
app.run("text.create", json!({"x": 10, "y": 10, "text": "Hi"})).unwrap();
app.run("text.setStyle", json!({"font": a})).unwrap();
app.run("text.setStyle", json!({"font": b})).unwrap();
assert_eq!(app.ui.recent_fonts, vec![b.clone(), a.clone()]);
assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), a);
assert!(enabled(&app, "type.recentFont2") && !enabled(&app, "type.recentFont3"));
app.run("type.recentFont2", json!({})).unwrap();
assert_eq!(app.ui.recent_fonts[0], a);
assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Hide Corner Widget");
app.run("view.cornerWidget", json!({})).unwrap();
assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Show Corner Widget");
}
#[test]
fn recent_files_track_opens_and_saves() {
let dir = std::env::temp_dir().join(format!("dc-recent-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut app = VectorcraftApp::new(
vectorcraft_engine::Session::new(),
crate::Services {
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
..Default::default()
},
);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let (a, b) = (dir.join("a.vectorcraft"), dir.join("b.vectorcraft"));
for p in [&a, &b, &a] {
app.run("file.saveAs", json!({"path": p.to_string_lossy()})).unwrap();
}
assert_eq!(app.ui.recent_files, [a.to_string_lossy(), b.to_string_lossy()]);
assert_eq!(dynamic_label(&app, "file.openRecent2", ""), "b.vectorcraft");
assert!(enabled(&app, "file.openRecent2") && !enabled(&app, "file.openRecent3"));
app.run("file.openRecent2", json!({})).unwrap();
assert_eq!(app.session.documents().len(), 2);
assert_eq!(app.ui.recent_files[0], b.to_string_lossy(), "reopening moves it to the top");
app.run("file.clearRecent", json!({})).unwrap();
assert!(app.ui.recent_files.is_empty() && app.run("file.openRecent1", json!({})).is_err());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn every_bound_menu_command_exists() {
fn walk(items: &[Item], bad: &mut Vec<String>) {
for it in items {
match it {
Item::Cmd(_, id, _)
if vectorcraft_engine::find_command(id).is_none()
&& !UI_COMMANDS.iter().any(|c| c.0 == *id)
&& !id.starts_with("object.path.")
&& *id != "type.createOutlines" =>
{
bad.push(id.to_string());
}
Item::Sub(_, ch) => walk(ch, bad),
_ => {}
}
}
}
let mut bad = vec![];
for (_, items) in menu_tree() {
walk(&items, &mut bad);
}
assert!(bad.is_empty(), "menu items bound to unknown commands: {bad:?}");
}
#[test]
fn shortcut_pretty() {
if cfg!(target_os = "macos") {
assert_eq!(pretty_shortcut("Cmd+Shift+]"), "⇧⌘]");
assert_eq!(pretty_shortcut("Cmd+Alt+2"), "⌥⌘2");
}
assert_eq!(pretty_shortcut(""), "");
}
#[test]
fn expand_appearance_is_an_object_menu_item_only() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let paths: Vec<Vec<String>> =
menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("effect.expandAppearance")).map(|e| e.path).collect();
assert_eq!(paths, [vec!["Object".to_string()]]);
}
#[test]
fn menus_are_as_wide_as_their_items() {
// Items size the popup to the widest label and shortcut instead of the widest a menu may be.
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let tree = menu_tree();
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
let id = egui::Id::new("test-menu");
for _ in 0..3 {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
egui::Area::new(id).show(ui.ctx(), |ui| {
egui::containers::menu::menu_style(ui.style_mut());
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, file, &mut None));
});
});
out.textures_delta.clear();
}
let w = ctx.memory(|m| m.area_rect(id)).unwrap().width();
assert!((230.0..340.0).contains(&w), "File menu is {w} pt wide");
}
#[test]
fn long_menus_scroll_inside_a_short_window() {
// The Window menu is taller than a 600 pt window: it stops above the bottom edge and
// scrolls instead of running off screen.
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let tree = menu_tree();
let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap();
let id = egui::Id::new("test-long-menu");
let rect = |height: f32| {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, height));
for _ in 0..3 {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
egui::Area::new(id).fixed_pos(egui::pos2(300.0, 30.0)).show(ui.ctx(), |ui| {
egui::containers::menu::menu_style(ui.style_mut());
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, window, &mut None));
});
});
out.textures_delta.clear();
}
ctx.memory(|m| m.area_rect(id)).unwrap()
};
let short = rect(600.0);
assert!(short.bottom() <= 600.0, "Window menu ends at {} in a 600 pt window", short.bottom());
// With room to spare it shows whole, taller than the short window allowed.
let tall = rect(3000.0);
assert!(tall.height() > short.height() + 100.0, "{} vs {}", tall.height(), short.height());
assert!(tall.bottom() < 3000.0);
}
}