refactor(ui-egui): move menus.rs items to menus/{bar,context,entries,invoke,labels,run,slots,state,submenus,tree,ui_commands}.rs

This commit is contained in:
aiongg
2026-10-26 21:45:33 +00:00
parent 5dfd01e1f1
commit 16cdbd1b33
12 changed files with 4453 additions and 4383 deletions
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
use super::entries::{Row, entry, pretty_shortcut};
use super::invoke::{field_event, focused_text_field, invoke, to_field};
use super::tree::menu_tree_named;
use super::*;
/// Render the menu bar.
/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width.
pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
// The press that opens a menu takes the keyboard from a focused text field: the field and its
// selection are kept while the menus are open, so their Select All, Cut, Copy and Paste act on
// its text (#516), the frame after the click that chose them (that click takes the keyboard
// from it again).
type Field = (egui::Id, egui::text_edit::TextEditState);
let (field_key, chosen_key) = (egui::Id::unique("menu-bar-field"), egui::Id::unique("menu-bar-field-event"));
if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) {
ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key));
state.store(&ctx, field);
to_field(&ctx, field, e);
}
if let Some(field) = focused_text_field(&ctx).and_then(|id| Some((id, egui::TextEdit::load_state(&ctx, id)?))) {
ctx.data_mut(|d| d.insert_temp::<Field>(field_key, field));
}
let mut clicked: Option<(String, Value)> = None;
let tree = menu_tree_named(app.session.prefs.font_names_in_english);
// No application menu in the window (as in Illustrator): the bar starts at File on every
// platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there.
let tree: Vec<(&str, Vec<Item>)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect();
// A menu bar for its layout and menu-button style, but the titles use `Popup::menu` directly:
// egui 0.37's `MenuButton` opens the title under a resting pointer while another menu is open,
// and the app switches titles on movement only (`switch_on_hover`, the native behaviour).
let (end, open) = egui::MenuBar::new()
.ui(ui, |ui| {
let mut titles = Vec::with_capacity(tree.len());
for (title, items) in tree.iter() {
let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text);
let resp = ui.add(egui::Button::new(text));
egui::Popup::menu(&resp).show(|ui| menu_body(app, ui, items, &mut clicked));
titles.push(resp);
}
(ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles))
})
.inner;
let field = ctx.data(|d| d.get_temp::<Field>(field_key));
if !open && focused_text_field(&ctx).is_none() {
ctx.data_mut(|d| d.remove::<Field>(field_key));
}
if let Some((id, p)) = clicked {
match field.and_then(|f| Some((f, field_event(app, &id)?))) {
Some(chosen) => {
ctx.data_mut(|d| d.insert_temp(chosen_key, chosen));
ctx.request_repaint();
}
None => invoke(app, &id, p),
}
}
end
}
/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no
/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must
/// not switch to the title under it).
pub(super) fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool {
title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id)
}
/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another
/// title opens that menu instead (egui alone needs a click on each title). Returns whether a menu
/// is open.
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool {
let ids: Vec<egui::Id> = titles.iter().map(egui::Popup::default_response_id).collect();
let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else {
return false;
};
// `Response::hovered` is false while a menu's popup is open, so hit-test the titles here.
// Only a moving pointer switches: one resting on a title leaves the open menu alone.
let moving = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO);
if let Some(p) = ctx.pointer_hover_pos().filter(|_| moving)
&& let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p))
&& i != open
&& let Some(id) = ids.get(i)
{
egui::Popup::open_id(ctx, *id);
ctx.request_repaint();
}
true
}
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt;
/// it scrolls when it is taller than the window.
pub(super) fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(230.0);
render_items(app, ui, items, true, clicked);
});
}
/// `items` as menu buttons; with `checks`, items that can be on or off show a check mark (or the
/// room for one).
pub(super) fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: bool, clicked: &mut Option<(String, Value)>) {
let t = Tokens::get(ui.ctx());
for e in items.iter().filter_map(|it| entry(app, it)) {
match e {
Entry::Sep => {
ui.separator();
}
Entry::Header(h) => {
ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim));
}
Entry::Sub(label, children) => {
ui.menu_button(label, |ui| {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(200.0);
render_items(app, ui, children, checks, clicked);
});
});
}
Entry::Item(row) => render_row(app, ui, &row, checks, clicked),
}
}
}
/// One menu item: its label (with a check mark, or the room for one, under `checks`), its shortcut,
/// and Type › Font's samples.
fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, clicked: &mut Option<(String, Value)>) {
let sc = row.shortcut.map(pretty_shortcut).unwrap_or_default();
let Some((id, p)) = row.command else {
ui.add_enabled_ui(false, |ui| {
ui.add(egui::Button::new(row.label.as_ref()).shortcut_text(sc));
})
.response
.on_disabled_hover_text(tl!("Coming soon — tracked in the parity plan"));
return;
};
let text = match row.checked.filter(|_| checks) {
Some(true) => format!("✓ {}", row.label),
Some(false) => format!(" {}", row.label),
None => row.label.to_string(),
};
// Type → Font: each family's sample after its name (Enable in-menu font previews).
let sampled = p.get("font").and_then(Value::as_str).filter(|_| id == "text.setStyle" && app.session.prefs.font_preview);
let r = match sampled {
Some(family) => {
let slot = egui::IdSalt::new(("font-sample", family));
let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE));
let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner;
if let Some(rect) = out.rect(slot) {
crate::font_menu::menu_item_sample(ui, rect, family);
}
out.response
}
None => ui.add_enabled(row.enabled, egui::Button::new(text).shortcut_text(sc)),
};
if r.clicked() {
*clicked = row.target();
ui.close();
}
}
/// What the Home screen remembers when it opens (`app.home`): the active document and the
/// document count. When either changes, the Home screen gives way to the document.
pub(crate) fn home_key(app: &VectorcraftApp) -> (Option<u64>, usize) {
(app.session.active().map(|d| d.uid), app.session.documents().len())
}
/// Whether the Home screen is up: chosen with the Home button (`app.home`), or no document is open
/// and Preferences › General › Show The Home Screen When No Documents Are Open is on (#394). Off,
/// an app with no document shows an empty window, and the Home button still opens the screen.
pub(crate) fn home_showing(app: &VectorcraftApp) -> bool {
app.ui.home.is_some() || (app.session.active().is_none() && app.session.prefs.show_home_screen)
}
+173
View File
@@ -0,0 +1,173 @@
use super::bar::render_items;
use super::state::enabled;
use super::tree::{arrange_items, transform_items};
use super::*;
/// The canvas context menu (right-click): what applies to the selection, or to the view when
/// nothing is selected. Commands that can't run now are left out rather than greyed out.
pub fn context_items(app: &VectorcraftApp) -> Vec<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]);
}
// Managing artboards from the canvas: with the Artboard tool, the menu locks and unlocks them
// (`artboard.setProps` with `locked` for each artboard named; an entry with none to change is left out).
if app.session.tool_id() == "artboard" {
let n = st.doc.artboards.len();
let sel = crate::panels::artboards::picked(app, n);
let others: Vec<usize> = (0..n).filter(|i| !sel.contains(i)).collect();
let entry = |label, idx: Vec<usize>, locked: bool| {
let list: Vec<Value> = idx.iter().map(|i| json!({ "index": i, "locked": locked })).collect();
(!list.is_empty()).then(|| cp(label, "artboard.setProps", json!({ "artboards": list })))
};
let lock: Vec<Item> =
[entry("Lock Selected", sel.clone(), true), entry("Lock All Except Selected", others, true), entry("Lock All", (0..n).collect(), true)]
.into_iter()
.flatten()
.collect();
let unlock: Vec<Item> = [entry("Unlock Selected", sel, false), entry("Unlock All", (0..n).collect(), false)].into_iter().flatten().collect();
for (label, items) in [("Lock Artboards", lock), ("Unlock Artboards", unlock)] {
if !items.is_empty() {
v.push(sub(label, items));
}
}
v.push(Sep);
}
// The locked objects under the pointer at the right-click, each unlocked as the Layers panel's
// lock column does. `unlock_item_label` shows each object's name.
let unlock: Vec<Item> = app
.ui
.context_locked
.iter()
.filter(|(uid, _)| *uid == st.uid)
.flat_map(|(_, ids)| ids)
.filter(|id| st.doc.node(**id).is_some_and(|n| n.locked))
.map(|id| cp("Unlock", "layer.setProps", json!({ "ids": [id.0], "locked": false })))
.collect();
if !unlock.is_empty() {
v.extend([sub("Unlock", unlock), Sep]);
}
if roots.is_empty() {
v.extend([
c("Paste", "edit.paste"),
Sep,
c("Zoom In", "view.zoomIn"),
c("Zoom Out", "view.zoomOut"),
c("Fit Artboard in Window", "view.fitArtboard"),
Sep,
c("Show Rulers", "view.rulers"),
c("Show Grid", "view.grid"),
c("Hide Guides", "view.guides"),
c("Lock Guides", "view.guides.lock"),
Sep,
c("Select All", "select.all"),
]);
} else {
v.extend([c("Cut", "edit.cut"), c("Copy", "edit.copy"), c("Paste", "edit.paste"), Sep]);
if let [one] = roots.as_slice()
&& one.is_container()
{
v.push(c("Isolate Selected Group", "object.isolate"));
}
if several {
v.push(c("Group", "object.group"));
}
if any(|k| matches!(k, NodeKind::Group { clip: false, .. })) {
v.push(c("Ungroup", "object.ungroup"));
}
let paths = any(|k| matches!(k, NodeKind::Path { .. }));
if paths {
v.extend([c("Join", "path.join"), c("Average…", "path.average")]);
}
if !st.selection.anchors.is_empty() {
v.extend([c("Remove Anchor Points", "path.removeAnchors"), c("Cut Path at Selected Anchor Points", "path.cutAtAnchors")]);
}
if several {
v.push(c("Make Clipping Mask", "object.clippingMask.make"));
}
if any(|k| matches!(k, NodeKind::Group { clip: true, .. })) {
v.push(c("Release Clipping Mask", "object.clippingMask.release"));
}
if several && paths {
v.push(c("Make Compound Path", "object.compoundPath.make"));
}
// Chooses the Objects on Path tool, whose click on a path attaches the selection to it.
if several {
v.push(c("Objects on Path", "object.objectsOnPath.attach"));
}
if any(|k| matches!(k, NodeKind::Compound { .. })) {
v.push(c("Release Compound Path", "object.compoundPath.release"));
}
// The paragraphs of the selected type, or those the Type tool's selection touches.
if crate::panels::character::text_style(app).is_some() {
v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")]));
}
v.extend([
c("Make Guides", "view.guides.make"),
Sep,
sub("Transform", transform_items()),
sub("Arrange", arrange_items()),
sub(
"Select",
vec![c("Next Object Above", "select.nextAbove"), c("Next Object Below", "select.nextBelow"), Sep, c("Deselect", "select.none")],
),
Sep,
c("Export Selection…", "file.exportSelection"),
]);
}
prune(app, v)
}
/// `items` without the commands that can't run now (Undo and Redo stay, greyed out), submenus
/// left empty, and separators that no longer separate anything.
fn prune(app: &VectorcraftApp, items: Vec<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);
});
}
/// The document units a right-click on a ruler offers: each runs `document.setUnits`, exactly as
/// Preferences ▸ Units ▸ General does, with the current one checked ([`checked`]).
pub fn ruler_unit_items() -> Vec<Item> {
vectorcraft_engine::doc::Unit::ALL.iter().map(|u| cp(u.label(), "document.setUnits", json!({ "units": u.label() }))).collect()
}
/// The ruler context menu's popup (right-click a ruler or the origin box); the unit clicked goes in
/// `clicked`, for [`invoke`].
pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(160.0);
render_items(app, ui, &ruler_unit_items(), true, clicked);
});
}
+147
View File
@@ -0,0 +1,147 @@
use super::context::context_items;
use super::invoke::click_target;
use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, unlock_item_label};
use super::state::{shortcut_of, shown_state};
use super::tree::menu_tree_named;
use super::*;
/// A menu item as it shows now ([`entry`]).
pub struct Row<'a> {
/// The command and its params; `None` for an item not implemented yet (always disabled).
pub command: Option<(&'static str, &'a Value)>,
/// The tree's own English label, which [`click_target`] reads.
pub source: &'static str,
/// The label in the UI language, as it reads now (Undo *Move*, Show/Hide…, a recent file).
pub label: std::borrow::Cow<'static, str>,
/// The shortcut as the registry writes it (`Cmd+Shift+Z`), the user's overrides applied.
pub shortcut: Option<&'static str>,
pub enabled: bool,
/// `Some` for an item that is on or off.
pub checked: Option<bool>,
}
impl Row<'_> {
/// What choosing it runs ([`click_target`]); `None` for an item not implemented yet.
pub fn target(&self) -> Option<(String, Value)> {
self.command.map(|(id, p)| click_target(self.source, id, p))
}
}
/// Item `it` as the menus show it now, or `None` while they leave it out ([`shown_state`]).
pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option<Entry<'a>> {
use crate::i18n::t;
Some(match it {
Item::Sep => Entry::Sep,
Item::Header(h) => Entry::Header(t(h)),
Item::Sub(label, children) => Entry::Sub(t(label), children),
Item::Todo(label, sc) => Entry::Item(Row {
command: None,
source: label,
label: t(label).into(),
shortcut: Some(*sc).filter(|s| !s.is_empty()),
enabled: false,
checked: None,
}),
Item::Cmd(label, id, p) => {
let (enabled, checked) = shown_state(app, id, p)?;
Entry::Item(Row {
command: Some((id, p)),
source: label,
label: effect_item_label(id, p)
.or_else(|| unlock_item_label(app, id, p, crate::i18n::current()))
.unwrap_or_else(|| display_label(app, id, label))
.into(),
shortcut: item_shortcut(id, p),
enabled,
checked,
})
}
})
}
/// `Cmd+Shift+]` → `⇧⌘]` on macOS, `Ctrl+Shift+]` elsewhere.
pub fn pretty_shortcut(s: &str) -> String {
if s.is_empty() {
return String::new();
}
if cfg!(target_os = "macos") {
let mut mods = String::new();
let parts: Vec<&str> = s.split('+').collect();
let (key, ms): (&str, &[&str]) = if s.ends_with("++") {
("+", parts.get(..parts.len().saturating_sub(2)).unwrap_or_default())
} else {
parts.split_last().map_or(("", &[][..]), |(k, rest)| (*k, rest))
};
for m in ["Ctrl", "Alt", "Shift", "Cmd"] {
if ms.contains(&m) {
mods.push_str(match m {
"Ctrl" => "⌃",
"Alt" => "⌥",
"Shift" => "⇧",
_ => "⌘",
});
}
}
format!("{mods}{key}")
} else {
s.replace("Cmd", "Ctrl")
}
}
#[derive(Serialize)]
pub struct MenuEntry {
pub path: Vec<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_named(app.session.prefs.font_names_in_english) {
flatten(app, vec![title.to_string()], &items, &mut out);
}
out
}
/// Flattened canvas context menu for `ui.contextMenu.list` (paths are its submenus).
pub fn context_entries(app: &VectorcraftApp) -> Vec<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(l, id, p) => {
let Some((enabled, _)) = shown_state(app, id, p) else { continue };
out.push(MenuEntry {
path: path.clone(),
label: unlock_item_label(app, id, p, crate::i18n::Lang::EN).unwrap_or_else(|| dynamic_label(app, id, l)),
command: Some(id.to_string()),
params: p.clone(),
enabled,
shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(),
})
}
Item::Todo(l, sc) => out.push(MenuEntry {
path: path.clone(),
label: l.to_string(),
command: None,
params: Value::Null,
enabled: false,
shortcut: sc.to_string(),
}),
Item::Sub(l, ch) => {
let mut p = path.clone();
p.push(l.to_string());
flatten(app, p, ch, out);
}
_ => {}
}
}
}
+279
View File
@@ -0,0 +1,279 @@
use super::state::{SUBJECT_COMMANDS, has_subject, waits_for_ime};
use super::*;
/// What a menu click runs: items whose label ends with "…" and that carry default params open a
/// generic parameter dialog (fields = the defaults) instead of running immediately.
pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) {
let dialog = label.ends_with('…')
&& p.as_object().is_some_and(|o| !o.is_empty())
&& !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog");
if dialog {
return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p}));
}
(id.to_string(), if p.is_null() { json!({}) } else { p.clone() })
}
/// The dialog of command `id` that shows the selection's current values (its query): its heading
/// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they
/// are.
fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> {
Some(match id {
// Whether the text overflows, and Shrink Text's factor, are read-only facts.
"text.areaOptions" => ("Area Type Options", &["overflow", "fitScale"]),
"type.pathOptions" => ("Type on a Path Options", &["start", "end"]),
_ => return None,
})
}
/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does.
pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> {
Some(match id {
// Move opens on the offset moved last and the checkboxes it was last confirmed with.
"object.move" => ("move", crate::dialogs::transform::move_fields(app)),
"object.rotate" => ("rotate", json!({"angle": 0})),
"object.scale" => ("scale", json!({"sx": 100, "sy": 100, "uniform": true})),
// Transform Each opens on the values it was last confirmed with (#1732).
"object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::open_fields(app)),
"object.reflect" => ("reflect", json!({"axis": "vertical", "angle": 90})),
"object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})),
"path.average" => ("average", json!({"axis": "both"})),
// Offset Path opens on the values it was last confirmed with.
"object.path.offsetPath" if app.ui.offset_path.is_object() => ("offsetPath", app.ui.offset_path.clone()),
"object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})),
// Simplify opens its slider, or the dialog on the fields it was last confirmed with when
// "Open this dialog with these settings next time" was ticked.
"object.path.simplify" if app.ui.simplify.is_object() => ("simplify", app.ui.simplify.clone()),
"object.path.simplify" => (crate::dialogs::simplify_slider::KIND, crate::dialogs::simplify_slider::fields(app)),
"object.path.splitIntoGrid" => {
// The grid starts at the size of the selection.
let size =
app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false)).map_or((0.0, 0.0), |b| (b.width(), b.height()));
("splitIntoGrid", crate::dialogs::path_ops::split_fields(size.0, size.1))
}
"object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()),
"artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::fields()),
_ => return None,
})
}
/// The event a focused text field takes for Edit-menu command `id` (Select All, Cut, Copy,
/// Paste), as the same keys would send it.
pub(super) fn field_event(app: &mut VectorcraftApp, id: &str) -> Option<egui::Event> {
Some(match id {
"select.all" => egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND },
"edit.copy" => egui::Event::Copy,
"edit.cut" => egui::Event::Cut,
"edit.paste" | "edit.pasteWithoutFormatting" => egui::Event::Paste(app.written_clipboard_text()?),
_ => return None,
})
}
/// Invoke an item clicked in the system menu bar (macOS; its key equivalents come back to egui as
/// key presses, see [`crate::native_menu`]). While a text field has the keyboard, Select All, Cut,
/// Copy and Paste act on the field's text, as their keys do. While its keys select every artboard
/// ([`crate::panels::artboards::selects_all`]), Select All does too. Everything else (and those
/// commands with no field focused) goes to [`invoke`].
pub fn invoke_from_system_menu(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str, p: Value) {
match focused_text_field(ctx).and_then(|f| Some((f, field_event(app, id)?))) {
Some((field, e)) => to_field(ctx, field, e),
None if id == "select.all" && crate::panels::artboards::selects_all(app, ctx) => crate::panels::artboards::select_all(app),
None => invoke(app, id, p),
}
}
/// The text field that has the keyboard, if any.
pub(super) fn focused_text_field(ctx: &egui::Context) -> Option<egui::Id> {
ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some())
}
/// Give text field `field` the keyboard (back) and event `e`, a [`field_event`], this frame.
pub(super) fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) {
ctx.memory_mut(|m| m.request_focus(field));
ctx.input_mut(|i| i.events.push(e));
}
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).
/// Object › Graph › Type… or Data… (`id`): its dialog, with the selected graph's current values
/// (the command's query).
pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result<Value, String> {
// Graph Data: the worksheet.
if id == "graph.setData" {
return crate::dialogs::graph_data::open(app);
}
let v = app.session.execute(id, &json!({})).map_err(|e| e.to_string())?;
let (label, fields) = ("Graph Type", v);
app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})).map(|_| json!({ "dialog": "command" }))
}
pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
if waits_for_ime(app, id) {
return;
}
if SUBJECT_COMMANDS.contains(&id) && p.get("ids").is_none() && p.get("id").is_none() && !has_subject(app) {
return;
}
// Params first: some dialogs' fields take a look through the session (Move's last move).
if p.as_object().is_none_or(|o| o.is_empty())
&& let Some((kind, fields)) = menu_dialog(app, id)
{
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
return;
}
// Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection.
if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::envelope::open(app, id) {
app.status(e);
}
return;
}
// Variables (data merge): a name and a kind, a name and a row of values.
if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
crate::dialogs::variables::open(app, id, None);
return;
}
// Pathfinder Options: a dialog with the current options.
if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) {
crate::dialogs::pathfinder_options::open(app);
return;
}
// Repeat Options: a dialog with the selected repeat's current values.
if id == "object.repeat.options"
&& p.as_object().is_none_or(|o| o.is_empty())
&& let Some(fields) = crate::panels::pattern_options::repeat_fields(app)
{
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields}));
return;
}
// Area Type Options and Type on a Path Options: dialogs with the selected type's current
// values (the command's query), less those the dialog leaves alone.
if let Some((label, hidden)) = queried_dialog(id)
&& p.as_object().is_none_or(|o| o.is_empty())
{
match app.session.execute(id, &json!({})) {
Ok(mut fields) => {
if let Some(o) = fields.as_object_mut() {
o.retain(|k, _| !hidden.contains(&k.as_str()));
}
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
return;
}
// Object → Graph → Type… / Data…: dialogs with the selected graph's current values.
if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = graph_dialog(app, id) {
app.status(e);
}
return;
}
// Object → Graph → Design… / Column… / Marker…: the document's graph designs.
if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) {
let r = match id {
"graph.design" => crate::dialogs::graph_design::open_design(app),
"graph.column" => crate::dialogs::graph_design::open_column(app),
_ => crate::dialogs::graph_design::open_marker(app),
};
if let Err(e) = r {
app.status(e);
}
return;
}
// New View…: a name dialog, starting from the first free "View N".
if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) {
let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedView::default_name(&d.doc.views)).unwrap_or_default();
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}}));
return;
}
// Edit Views…: the saved views in a list, to rename or delete.
if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::edit_views::open(app) {
app.status(e);
}
return;
}
// Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with
// the view's angle filled in.
if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.view_mut().map(|v| v.rotation) {
Some(angle) => {
app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle})));
}
None => app.status("no document"),
}
return;
}
// Save Selection…: a name dialog, starting from the first free "Selection N".
if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) {
let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default();
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}}));
return;
}
// Edit Selection…: the saved selections in a list, to rename or delete.
if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::edit_selection::open(app) {
app.status(e);
}
return;
}
// Document Raster Effects Settings and File Info: their dialogs.
if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) {
let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) };
if let Err(e) = r {
app.status(e);
}
return;
}
// Rasterize…: its options, starting from the document's raster effects settings.
if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute("document.rasterEffectsSettings", &json!({})) {
Ok(v) => {
let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]});
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params}));
}
Err(e) => app.status(e.to_string()),
}
return;
}
// Text Wrap Options: a dialog with the selected wrap object's current values.
if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute(id, &json!({})) {
Ok(fields) => {
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
return;
}
// Blend Options…: its dialog, on the selected blend's options.
if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::blend_options::open(app) {
app.status(e);
}
return;
}
// Expand…: its dialog.
if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::expand::open(app) {
app.status(e);
}
return;
}
// Help links: the command returns the URL; a menu click opens it (agents just get the URL).
if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") {
app.open_link(id);
return;
}
// Object → Align aligns to what the align buttons align to, unless the call names `to`. With
// anchors selected (Direct Selection) it sends its params unchanged, and the points align to
// their own bounds.
let follows_align_to = id == "object.align"
&& p.as_object().is_some_and(|o| !o.contains_key("to"))
&& app.session.active().is_some_and(|st| st.selection.anchors.is_empty());
let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p };
if let Err(e) = app.run(id, p) {
app.status(e);
} else if matches!(id, "object.pattern.make" | "object.pattern.edit") {
app.ui.open_panel = Some("patternOptions".into());
}
}
+333
View File
@@ -0,0 +1,333 @@
use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot};
use super::state::{hidden_when_disabled, shortcut_of};
use super::tree::menu_tree;
use super::ui_commands::UI_COMMANDS;
use super::*;
/// Label for toggles whose text flips (Outline/Preview, Hide/Show …).
pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
let v = &app.ui.view;
match id {
"view.outline" => if v.outline { "Preview" } else { "Outline" }.into(),
"window.workspace.reset" => format!("Reset {}", app.ui.workspace),
"view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(),
"view.template" => if v.templates { "Hide Template" } else { "Show Template" }.into(),
"view.cornerWidget" => if v.corner_widgets { "Hide Corner Widget" } else { "Show Corner Widget" }.into(),
"view.textThreads" => if v.text_threads { "Hide Text Threads" } else { "Show Text Threads" }.into(),
"type.hiddenCharacters" => if v.hidden_chars { "Hide Hidden Characters" } else { "Show Hidden Characters" }.into(),
"view.gradientAnnotator" => if v.gradient_annotator { "Hide Gradient Annotator" } else { "Show Gradient Annotator" }.into(),
"view.livePaintGaps" => if v.live_paint_gaps { "Hide Live Paint Gaps" } else { "Show Live Paint Gaps" }.into(),
"effect.last" => match &app.last_effect {
Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())),
None => label.into(),
},
id if id.starts_with("type.recentFont") => {
let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0);
n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into())
}
id if saved_view_slot(id).is_some() => saved_view(app, id).map(|v| v.name.clone()).unwrap_or_else(|| "—".into()),
id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()),
"view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(),
"view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(),
"view.videoRulers" => if v.video_rulers && v.rulers { "Hide Video Rulers" } else { "Show Video Rulers" }.into(),
"view.globalRulers" => if app.session.active().is_some_and(|d| d.doc.rulers == vectorcraft_doc::RulerMode::Global) {
"Change to Artboard Rulers"
} else {
"Change to Global Rulers"
}
.into(),
"view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(),
"view.transparencyGrid" => {
if app.session.active().is_some_and(|d| d.transparency_grid) { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into()
}
"view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(),
"view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(),
"view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(),
"view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(),
"view.printTiling" => if app.session.active().is_some_and(|d| d.print_tiling) { "Hide Print Tiling" } else { "Show Print Tiling" }.into(),
id if id.starts_with("file.openRecent") => recent_slot(app, id)
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
.unwrap_or_else(|| "—".into()),
// Edit Contents reads Edit Envelope while an envelope's contents are being edited.
"object.envelope.editContents" => {
let editing = app.session.active().is_some_and(|st| {
st.selection
.objects
.first()
.and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))
.is_some_and(|e| matches!(e.kind, vectorcraft_doc::NodeKind::Envelope { editing: true, .. }))
});
if editing { "Edit Envelope" } else { "Edit Contents" }.into()
}
"select.globalEdit" => {
if app.session.active().is_some_and(|d| d.global_edit.is_some()) { "Stop Global Edit" } else { "Start Global Edit" }.into()
}
"edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()),
"edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => {
crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => {
crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::panels::symbols::USER_SLOT) => {
crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::panels::brushes::USER_SLOT) => {
crate::panels::brushes::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => {
crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into())
}
"perspective.grid.show" => if perspective_grid(app).is_some_and(|g| g.visible) { "Hide Grid" } else { "Show Grid" }.into(),
"perspective.grid.rulers" => if perspective_grid(app).is_some_and(|g| g.rulers) { "Hide Rulers" } else { "Show Rulers" }.into(),
"perspective.grid.lock" => if perspective_grid(app).is_some_and(|g| g.locked) { "Unlock Grid" } else { "Lock Grid" }.into(),
_ => label.into(),
}
}
/// Does the menu item `id` show a name that is user, file or system data rather than an interface
/// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a
/// plug-in? Such names are shown as they are, never translated (a workspace the user calls
/// "Layers" stays "Layers").
pub(super) fn shows_a_name(id: &str, label: &str) -> bool {
const SLOTS: [&str; 8] = [
"file.openRecent",
"view.goto",
"type.recentFont",
crate::panels::swatches::USER_SLOT,
crate::panels::graphic_styles::USER_SLOT,
crate::panels::symbols::USER_SLOT,
crate::panels::brushes::USER_SLOT,
crate::dialogs::perspective_presets::SLOT,
];
SLOTS.iter().any(|s| id.starts_with(s))
|| saved_selection_slot(id).is_some()
|| matches!(id, "text.setStyle" | "plugin.dialog")
|| (id == "window.workspace" && !crate::workspaces::is_builtin(label))
|| (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label))
}
/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or
/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds
/// while another thread installs or removes plug-ins.
pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool {
id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some()
}
/// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled
/// around a name (Undo *Move*, Reset *Essentials*, Last Effect: *Drop Shadow*) are translated as
/// templates so the name can move; names that are user data (files, views, fonts, custom
/// workspaces, plug-ins: [`shows_a_name`]) pass through.
pub fn display_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
let lang = crate::i18n::current();
let fmt = crate::i18n::fmt;
match id {
"edit.undo" | "edit.redo" => {
let last = app.session.active().and_then(|d| if id == "edit.undo" { d.history.undo.last() } else { d.history.redo.last() });
// The step's name as the History panel lists it.
let name = crate::panels::history::shown;
match last {
Some(h) if id == "edit.undo" => fmt(tl!("Undo {name}"), &[("name", &name(h))]),
Some(h) => fmt(tl!("Redo {name}"), &[("name", &name(h))]),
None => tl!(if id == "edit.undo" { "Undo" } else { "Redo" }).to_string(),
}
}
"window.workspace.reset" => {
let name = &app.ui.workspace;
fmt(tl!("Reset {name}"), &[("name", if crate::workspaces::is_builtin(name) { tl!(name) } else { name })])
}
"effect.last" => match &app.last_effect {
Some((e, _)) => {
let name = vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str());
fmt(tl!("Last Effect: {name}"), &[("name", crate::i18n::tr_id(lang, e, name))])
}
None => crate::i18n::tr_id(lang, id, label).to_string(),
},
_ => {
let shown = dynamic_label(app, id, label);
if shows_a_name(id, &shown) { shown } else { crate::i18n::tr_id(lang, id, &shown).to_string() }
}
}
}
/// The label of an Effect menu item in the UI language, by the effect's own id. Every effect item
/// runs `effect.apply` or `effect.dialog`, so the command id can't tell them apart; an `@id` row
/// for the effect id (for example `pathfinder.add`) wins over the plain row of the English label.
/// `None` when the catalog has no such row, and the plain translation applies.
pub(super) fn effect_item_label(id: &str, p: &Value) -> Option<String> {
if !matches!(id, "effect.apply" | "effect.dialog") {
return None;
}
let effect = p.get("effect")?.as_str()?;
let shown = crate::i18n::tr_id(crate::i18n::current(), effect, "");
(!shown.is_empty()).then(|| shown.to_string())
}
/// The label of an item of the canvas context menu's Unlock submenu (`layer.setProps` with one id
/// and `locked: false`): the object's name as the Layers panel shows it in `lang`
/// ([`crate::panels::layers::shown_name`]). `None` for any other item, or when the object is gone.
pub(super) fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: crate::i18n::Lang) -> Option<String> {
if id != "layer.setProps" || p.get("locked") != Some(&Value::Bool(false)) {
return None;
}
let [one] = p.get("ids")?.as_array()?.as_slice() else { return None };
let n = app.session.active()?.doc.node(vectorcraft_doc::NodeId(one.as_u64()?))?;
Some(crate::panels::layers::shown_name(app, n, lang))
}
/// The shortcut a menu item shows: its command's (items without params), or a panel's for the
/// Window menu's `window.panel {panel}` items.
pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> {
match (id, p.get("panel").and_then(Value::as_str)) {
("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel),
_ if p.is_null() => shortcut_of(id),
_ => None,
}
}
/// Labels only the canvas context menu ([`context_items`]) shows, so the catalog tests can insist
/// every one of them is translated (a test checks the list against the menu).
pub const CONTEXT_LABELS: &[&str] = &[
"Isolate Selected Group",
"Exit Isolation Mode",
"Lock Artboards",
"Lock Selected",
"Lock All Except Selected",
"Lock All",
"Unlock Artboards",
"Unlock Selected",
"Unlock",
"Make Clipping Mask",
"Release Clipping Mask",
"Make Compound Path",
"Release Compound Path",
"Select All",
];
/// The labels [`dynamic_label`] can show in place of an item's own (Show/Hide pairs and the like),
/// so the catalog tests can insist every one of them is translated.
pub const DYNAMIC_LABELS: &[&str] = &[
"Preview",
"Outline",
"Hide Edges",
"Show Edges",
"Hide Corner Widget",
"Show Corner Widget",
"Hide Text Threads",
"Show Text Threads",
"Hide Hidden Characters",
"Show Hidden Characters",
"Hide Gradient Annotator",
"Show Gradient Annotator",
"Hide Live Paint Gaps",
"Show Live Paint Gaps",
"Hide Artboards",
"Show Artboards",
"Hide Rulers",
"Show Rulers",
"Hide Video Rulers",
"Show Video Rulers",
"Change to Global Rulers",
"Change to Artboard Rulers",
"Hide Bounding Box",
"Show Bounding Box",
"Hide Transparency Grid",
"Show Transparency Grid",
"Hide Guides",
"Show Guides",
"Hide Grid",
"Show Grid",
"Unlock Guides",
"Lock Guides",
"Show Slices",
"Hide Slices",
"Hide Template",
"Show Template",
"Hide Print Tiling",
"Show Print Tiling",
"Edit Envelope",
"Edit Contents",
"Start Global Edit",
"Stop Global Edit",
"Undo",
"Redo",
"Unlock Grid",
"Lock Grid",
];
/// Every English string the menus, the command palette, the panel registry, the toolbar and the
/// Preferences dialog can show: what a complete language catalog has to cover. Names that are user
/// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the
/// view angles are left out.
pub fn menu_strings() -> std::collections::BTreeSet<String> {
use std::collections::BTreeSet;
fn user_data(id: &str, params: &Value) -> bool {
hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params)
}
fn walk(items: &[Item], under: &str, out: &mut BTreeSet<String>) {
for it in items {
match it {
Item::Cmd(l, id, params) => {
let size = l.strip_suffix(" pt").is_some_and(|n| n.chars().all(|c| c.is_ascii_digit()));
// View › Rotate View lists angles: an optional minus sign, digits and a degree sign.
let angle = l
.strip_suffix('°')
.map(|n| n.strip_prefix('-').unwrap_or(n))
.is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
// VectorCraft › Language lists each language by its own name.
let language_name = *id == "app.language" && crate::i18n::Lang::all().any(|lang| lang.name() == *l);
if !user_data(id, params) && !size && !angle && !language_name && *l != "—" {
out.insert(l.to_string());
}
}
Item::Todo(l, _) | Item::Header(l) => {
out.insert(l.to_string());
}
Item::Sub(l, children) => {
out.insert(l.to_string());
// Type › Font lists the installed families: names, not UI text.
if !(under == "Type" && *l == "Font") {
walk(children, l, out);
}
}
Item::Sep => {}
}
}
}
let mut out = BTreeSet::new();
for (title, items) in menu_tree() {
out.insert(title.to_string());
walk(&items, title, &mut out);
}
out.extend(crate::native_menu::MAC_LABELS.iter().map(|s| s.to_string()));
out.extend(DYNAMIC_LABELS.iter().map(|s| s.to_string()));
out.extend(CONTEXT_LABELS.iter().map(|s| s.to_string()));
out.extend(UI_COMMANDS.iter().map(|c| c.1.to_string()));
for c in vectorcraft_engine::cmd::command_specs() {
out.insert(c.label.to_string());
out.extend(c.menu.iter().map(|m| m.to_string()));
}
out.extend(crate::state::all_panels().map(|(_, label)| label.to_string()));
out.extend(vectorcraft_tools::catalog::all_tools().map(|t| t.label.to_string()));
out.extend(crate::toolbar::BASIC.iter().map(|c| c.0.to_string()));
out.extend(vectorcraft_color::BlendMode::ALL.iter().map(|m| m.label().to_string()));
out.extend(vectorcraft_effects::effect_catalog().iter().map(|e| e.label.to_string()));
out.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|c| c.to_string()));
for sp in vectorcraft_engine::cmd::prefscmds::PREF_SPECS {
out.insert(sp.label.to_string());
if !sp.section.is_empty() {
out.insert(sp.section.to_string());
}
if let vectorcraft_engine::cmd::prefscmds::PrefKind::Choice(opts) = sp.kind {
out.extend(opts.iter().map(|o| o.1.to_string()));
}
}
out.extend(crate::prefs_dialog::UI_FIELDS.iter().map(|f| f.2.to_string()));
out.extend(crate::dialogs::button_labels().into_iter().map(str::to_string));
out.extend(crate::dialogs::FILM_GRAIN_FIELDS.iter().map(|(_, label, _)| label.to_string()));
out.extend(crate::dialogs::PAINT_DAUB_BRUSH_TYPE_LABELS.iter().map(|label| label.to_string()));
out.extend(crate::chrome::hint_strings().into_iter().map(str::to_string));
out.remove("");
out
}
+725
View File
@@ -0,0 +1,725 @@
use super::bar::home_key;
use super::invoke::menu_dialog;
use super::slots::{recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot};
use super::state::{checked, selected_image};
use super::ui_commands::UI_COMMANDS;
use super::*;
/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label,
/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`), or a library panel's id
/// as it is (`library:swatches:pastels`).
pub(crate) fn normalize_panel(input: &str) -> Option<&'static str> {
let name = input.trim();
crate::state::all_panels()
.find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label))
.map(|(id, _)| id)
.or_else(|| crate::panels::library_panel::intern(input))
}
/// An optional `{key?: bool}` param: none when it is omitted or null (the commands then toggle).
pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(format!("`{key}` must be true or false")),
}
}
/// Handle a UI command. `None` = not a UI command (the engine handles it).
/// `params` has only keys the params description of engine command `id` names, or the parameter
/// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their
/// descriptions haven't been audited against what they read.
pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Value) -> Result<(), String> {
if UI_COMMANDS.iter().any(|c| c.0 == id) {
return Ok(());
}
session.check_params(id, params).map_err(|e| e.to_string())
}
/// Runs `id` when the UI runs it itself: one of [`UI_COMMANDS`], or an engine command the UI
/// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the
/// engine.
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
// The Window menu's panel items toggle like its other checked items (#2361); the panel's
// other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it.
if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) {
let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default();
if app.ui.open_panel.as_deref() == Some(panel) {
app.ui.open_panel = None;
return Some(Ok(json!({"open": null})));
}
return Some(app.run("window.panel.close", json!({"panel": panel})));
}
if let Some(result) = crate::panel_docking::command(app, id, p) {
return Some(result);
}
if id == "app.language" {
let lang = p.get("lang").and_then(Value::as_str).unwrap_or("");
let value = if lang.eq_ignore_ascii_case("auto") {
"auto".to_string()
} else if let Some(l) = crate::i18n::Lang::from_code(lang) {
l.code().to_string()
} else {
let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::all().map(|l| l.code())).collect();
return Some(Err(format!("lang must be one of {}", codes.join(", "))));
};
return Some(app.run("prefs.set", json!({"key": "interfaceLanguage", "value": value})).map(|_| json!(value)));
}
if let Some(r) = crate::panels::character::intercept_text_command(app, id) {
return Some(r);
}
if let Some(r) = crate::shortcut_editor::run_command(app, id, p)
.or_else(|| crate::workspaces::run_command(app, id, p))
.or_else(|| crate::edit_toolbar::command(app, id, p))
{
return Some(r);
}
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let flag = |b: &mut bool| {
*b = !*b;
Ok(json!(*b))
};
let r = match id {
"file.newDialog" => {
crate::dialogs::open_new_document(app);
Ok(Value::Null)
}
"file.newWithoutDialog" => crate::dialogs::new_document_without_dialog(app),
"app.home" => {
app.ui.home = Some(home_key(app));
Ok(Value::Null)
}
"file.open" => match s("path") {
Some(path) => io::open_path(app, &path),
None => io::open_dialog(app).map(|_| Value::Null),
},
// Saves write through the app (save panel, download); with no path known the panel and the
// format's options dialog ask first.
"file.save" => io::save(app, SaveMode::Save, p, true),
"file.saveAs" => io::save(app, SaveMode::SaveAs, p, true),
"file.saveCopy" => io::save(app, SaveMode::Copy, p, true),
"file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true),
"document.exportSelection" => {
io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p}))
}
// Bytes sent by an agent go straight to the engine; a path or nothing opens it here.
"file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")),
"file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app),
"file.reveal" => io::reveal(app),
"file.cancelExport" => Ok(io::cancel_exports(app)),
"file.exportInBackground" => Ok(io::hide_exports(app)),
id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() {
Some(path) => io::open_recent_reporting(app, &path),
None => Err("no such recent file".into()),
},
"type.findFont" => {
crate::find_font::open(app);
Ok(Value::Null)
}
"ui.missingFontsDialog" => crate::dialogs::missing_fonts::open_command(app, s("folder")),
"ui.findFontsInFolder" => crate::dialogs::missing_fonts::find_in_folder_command(app, s("folder")),
"file.clearRecent" => {
app.ui.recent_files.clear();
Ok(Value::Null)
}
"file.recentFiles" => Ok(json!(io::recent_files(app))),
"file.place" => crate::place::run(app, p),
"file.place.queue" => crate::place::queue(app, p),
"file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => {
crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None);
Ok(Value::Null)
}
"file.export.svg" => io::export(app, Some("svg"), s("path"), p),
"file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => {
crate::dialogs::open_export_for_screens(app);
Ok(Value::Null)
}
"file.exportForScreens" => io::export_for_screens(app, p.clone()),
"file.export.png" if s("path").is_none() => io::target_path(app, None, "png").and_then(|path| {
let f = vectorcraft_engine::cmd::fileio::format("png").ok_or("no PNG encoder")?;
let mut params = if p.is_object() { p.clone() } else { json!({}) };
params["format"] = json!("png");
params["path"] = json!(path);
crate::dialogs::open_raster_options(app, f, params);
Ok(Value::Null)
}),
"file.export.png" => io::export(app, Some("png"), s("path"), p),
"file.documentSetup" => crate::dialogs::open_document_setup(app),
"edit.preferences" => {
crate::prefs_dialog::open(app, s("category").as_deref());
Ok(Value::Null)
}
// Edit → Color Settings… / Assign Profile… (no params): show the colour-management panel.
"edit.colorSettings" | "edit.assignProfile" if p.as_object().is_none_or(|o| o.is_empty()) => {
app.ui.open_panel = Some("separations".into());
app.ui.dock = true;
app.session.execute(id, p).map_err(|e| e.to_string())
}
"view.outline" => flag(&mut app.ui.view.outline),
"view.pixelPreview" => flag(&mut app.ui.view.pixel_preview),
"view.trimView" => flag(&mut app.ui.view.trim_view),
"view.cornerWidget" => flag(&mut app.ui.view.corner_widgets),
// New View: the engine stores the current zoom, centre and rotation under the given name.
"view.saved.new" if p.get("zoom").is_none() => {
let Some(v) = app.view().copied() else { return Some(Err("no document".into())) };
let mut params = if p.is_object() { p.clone() } else { json!({}) };
params["center"] = json!([v.center.x, v.center.y]);
params["zoom"] = json!(v.zoom);
params["rotation"] = json!(v.rotation);
app.session.execute(id, &params).map_err(|e| e.to_string())
}
// A saved view: its zoom, centre and rotation, in the current window.
id if saved_view_slot(id).is_some() => {
let saved = saved_view(app, id).and_then(|sv| crate::state::View::saved(sv).map(|to| (sv.name.clone(), to)));
match (saved, app.view_mut()) {
(Some((name, to)), Some(v)) => {
(v.center, v.zoom, v.rotation) = (to.center, to.zoom, to.rotation);
Ok(json!({ "name": name }))
}
_ => Err("no such view".into()),
}
}
id if saved_selection_slot(id).is_some() => match saved_selection_name(app, id) {
Some(name) => app.run("select.recall", json!({ "name": name })),
None => Err("no such saved selection".into()),
},
"view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel),
"view.snapToGlyph" => flag(&mut app.ui.view.snap_to_glyph),
// The preference itself (General › Use Precise Cursors), so the command, the dialog and the saved
// preferences share one value.
"view.preciseCursors" => {
let on = !app.session.prefs.use_precise_cursors;
app.run("prefs.set", json!({"key": "usePreciseCursors", "value": on})).map(|_| {
crate::prefs_dialog::snapshot(app);
json!(on)
})
}
"view.textThreads" => flag(&mut app.ui.view.text_threads),
"type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars),
"type.bold" => crate::panels::character::toggle_face(app, Face::Bold),
"type.italic" => crate::panels::character::toggle_face(app, Face::Italic),
id if let Some((_, justify)) = crate::panels::paragraph::ALIGN_COMMANDS.iter().find(|c| c.0 == id) => {
crate::panels::paragraph::align(app, justify)
}
"type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p),
"type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p),
"type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app),
id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b),
"view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator),
"view.livePaintGaps" => flag(&mut app.ui.view.live_paint_gaps),
"effect.last" => match app.last_effect.clone() {
Some((e, params)) => {
let label = vectorcraft_effects::effect_info(&e).map(|i| i.label.trim_end_matches('…').to_string()).unwrap_or_else(|| e.clone());
let mut fields = params.as_object().cloned().unwrap_or_default();
fields.insert("__effect".into(), json!(e));
fields.insert("__label".into(), json!(label));
fields.insert("preview".into(), json!(true));
app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields });
Ok(Value::Null)
}
None => Err("no effect applied yet".into()),
},
id if id.starts_with("type.recentFont") => {
let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0);
match n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned() {
Some(font) => app.run("text.setStyle", json!({ "font": font })),
None => Err("no such recent font".into()),
}
}
"view.edges" => flag(&mut app.ui.view.edges),
"view.artboards" => flag(&mut app.ui.view.artboards),
"view.template" => flag(&mut app.ui.view.templates),
"view.rulers" => flag(&mut app.ui.view.rulers),
"view.videoRulers" => {
let v = &mut app.ui.view;
let show = !(v.video_rulers && v.rulers);
v.video_rulers = show;
v.rulers |= show;
Ok(json!(show))
}
"view.boundingBox" => flag(&mut app.ui.view.bounding_box),
"view.guides" => {
let r = flag(&mut app.ui.view.guides);
// Hidden ruler guides and guide paths cannot stay selected: their selection overlay
// would otherwise reveal geometry that Hide Guides just hid.
let hidden_selection = !app.ui.view.guides
&& app.session.active().is_some_and(|d| {
!d.selection.guides.is_empty()
|| d.selection
.objects
.iter()
.any(|id| matches!(d.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Path { guide: true, .. })))
});
if hidden_selection {
// Deselecting a document that is open cannot fail.
let _ = app.run("select.none", json!({}));
}
r
}
"view.smartGuides" => flag(&mut app.ui.view.smart_guides),
"view.grid" => flag(&mut app.ui.view.grid),
"view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid),
"view.snapToPoint" => flag(&mut app.ui.view.snap_to_point),
"view.zoomIn" | "view.zoomOut" => {
let up = id == "view.zoomIn";
let print = app.session.prefs.display_print_size;
// Selection & Anchor Display › Zoom to Selection: the selection comes to the middle.
let focus = if app.session.prefs.zoom_to_selection { app.selection_box().map(|b| b.center()) } else { None };
match app.view_mut() {
Some(v) => {
v.zoom = next_zoom(v.zoom, up);
if let Some(c) = focus {
v.center = c;
}
Ok(json!({"zoom": crate::state::zoom_display_percent(v.zoom, print)}))
}
None => Err("no document".into()),
}
}
"view.setZoom" => {
let print = app.session.prefs.display_print_size;
let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print);
let center =
p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
match app.view_mut() {
Some(v) => {
v.zoom = z.clamp(0.0313, 640.0);
v.fitted = true;
if let Some(c) = center {
v.center = c;
}
Ok(Value::Null)
}
None => Err("no document".into()),
}
}
"view.fitArtboard" | "view.fitAll" | "view.actualSize" => {
crate::canvas::fit(app, id);
Ok(Value::Null)
}
"view.goToArtboard" => go_to_artboard(app, p),
"view.presentation" => {
app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 };
Ok(json!(app.ui.screen_mode))
}
"view.screenMode" => {
app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) {
Some(m) => m.min(3) as u8,
// F cycles the three screen modes. Without a mode, the command also goes from
// Presentation Mode (not in the cycle) back to Normal.
None if app.ui.screen_mode >= 2 => 0,
None => app.ui.screen_mode + 1,
};
Ok(json!(app.ui.screen_mode))
}
// View › Rotate View and the status bar's angle menu: the view turns about the center of the
// canvas to the angle, normalized as a Rotate View drag normalizes it.
"view.rotate" => {
let key = "angle";
let Some(angle) = p.get(key).and_then(Value::as_f64).filter(|a| a.is_finite()) else {
return Some(Err(format!("`{key}` must be a number")));
};
match app.view_mut() {
Some(v) => {
v.rotation = vectorcraft_geom::normalize_deg(angle);
Ok(json!({ "angle": v.rotation }))
}
None => Err("no document".into()),
}
}
// View › Rotate View to Selection: the view turns about the center of the canvas to the angle
// of the selection's bounding box, so the box is square to the window.
"view.rotateToSelection" => {
let Some(st) = app.session.active() else { return Some(Err("no document".into())) };
if st.selection.objects.is_empty() {
return Some(Err("nothing selected".into()));
}
let angle = vectorcraft_geom::normalize_deg(st.doc.bbox_angle(&st.selection.objects));
match app.view_mut() {
Some(v) => {
v.rotation = angle;
Ok(json!({ "angle": angle }))
}
None => Err("no document".into()),
}
}
"view.rotateReset" => {
if let Some(v) = app.view_mut() {
v.rotation = 0.0;
}
Ok(Value::Null)
}
"window.control" => flag(&mut app.ui.control_bar),
"window.toolbar" => flag(&mut app.ui.toolbar),
"window.toolbarColumns" => opt_bool(p, "double").map(|on| {
let on = on.unwrap_or(!app.ui.toolbar_double);
app.ui.toolbar_double = on;
json!(on)
}),
"window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced),
"imageTrace.preview" => opt_bool(p, "on").map(|on| {
let on = on.unwrap_or(!app.ui.image_trace_preview);
app.ui.image_trace_preview = on;
json!(on)
}),
"window.floatTools" => {
let floating = match opt_bool(p, "floating") {
Ok(on) => on,
Err(e) => return Some(Err(e)),
};
let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) };
crate::toolbar::float_group(app, &tool, floating).map(Value::Bool)
}
"window.taskBar" => flag(&mut app.ui.task_bar),
"window.taskBar.pin" => {
let place = &mut app.ui.task_bar_place;
opt_bool(p, "pinned").map(|pinned| {
let pinned = pinned.unwrap_or(!place.pinned);
// Pinning holds the bar where it shows; unpinning lets it follow the selection from
// there (the bar turns its pinned spot into an offset when it is next drawn).
if pinned && !place.pinned {
place.pin_at = place.shown_at;
}
place.pinned = pinned;
json!(pinned)
})
}
"window.taskBar.reset" => {
app.ui.task_bar_place = Default::default();
Ok(Value::Null)
}
"window.dock" => {
let ui = &mut app.ui;
// The bars the last Tab hid show again, unless the toolbar or the Control bar was shown
// another way since then (Window › Tools, Window › Control). Panels shown with Shift+Tab
// in between stay shown. Switching workspaces clears the record (`workspaces::apply`).
let hid = ui.tab_hid.take().filter(|h| (h.dock || h.toolbar || h.control_bar) && !ui.toolbar && !ui.control_bar);
let on = if let Some(h) = hid {
ui.dock |= h.dock;
ui.toolbar = h.toolbar;
ui.control_bar = h.control_bar;
true
} else if ui.dock || ui.toolbar || ui.control_bar {
ui.tab_hid = Some(crate::state::TabHid { dock: ui.dock, toolbar: ui.toolbar, control_bar: ui.control_bar });
ui.dock = false;
ui.toolbar = false;
ui.control_bar = false;
ui.open_panel = None;
false
} else {
ui.dock = true;
ui.toolbar = true;
ui.control_bar = true;
true
};
Ok(json!(on))
}
"window.panels" => {
let on = !app.ui.dock;
app.ui.dock = on;
if !on {
app.ui.open_panel = None;
}
Ok(json!(on))
}
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
Some(b) => {
app.ui.brightness = b;
app.session.prefs.ui_brightness = b.id().into();
app.canvas.key = None;
Ok(json!(b.id()))
}
None => Err("brightness must be dark|mediumDark|mediumLight|light".into()),
},
"window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()),
"tool.select" => match s("tool") {
Some(t) if vectorcraft_tools::tool_info(&t).is_some() => {
app.select_tool(&t);
Ok(json!({"tool": app.session.tool_id()}))
}
Some(t) => Err(format!("unknown tool `{t}`")),
None => Err("missing `tool`".into()),
},
"tool.setOption" => app.session.set_tool_option_cmd(p),
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
"ui.recolorDialog" => crate::dialogs::recolor::open(app, p),
"ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()),
"ui.cropImage" => {
app.select_tool(vectorcraft_tools::cropimage::ID);
Ok(json!({"tool": app.session.tool_id()}))
}
"ui.paramDialog" => {
let cmd = s("command").unwrap_or_default();
let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default();
fields.insert("__command".into(), json!(cmd));
fields.insert("__label".into(), json!(s("label").unwrap_or(cmd.clone())));
app.ui.dialog = Some(crate::state::Dialog { kind: "command".into(), fields });
Ok(Value::Null)
}
"effect.applyLast" => match app.last_effect.clone() {
Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})),
None => Err("no effect applied yet".into()),
},
"file.export.pdf" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::open_save_pdf(app, &json!({})),
"file.export.pdf" => io::export_pdf(app, p.clone()),
"help.about" => {
app.ui.about = true;
Ok(Value::Null)
}
"help.checkForUpdates" => crate::updates_ui::check(app),
"help.commandPalette" => {
app.ui.palette_open = !app.ui.palette_open;
app.ui.palette_query.clear();
Ok(Value::Null)
}
// Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`).
"file.close" => {
let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index())?;
crate::unsaved::close(app, i)
}
"file.closeAll" => crate::unsaved::close_all(app, "closeAll"),
"app.quit" => crate::unsaved::close_all(app, "quit"),
"ui.swatchOptions" => match s("name") {
Some(name) => crate::dialogs::swatch_options::open(app, &name),
None => Err("missing `name`".into()),
},
"ui.newSwatch" => {
let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref())
}
"ui.newColorGroup" => {
let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
crate::dialogs::new_color_group::open(app, names.unwrap_or_default())
}
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
"ui.sampleScreenColor" => crate::screen_color::start(app, p),
"ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()),
"ui.symbolOptions" => {
let name = s("name");
let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false);
if edit || name.is_some() {
crate::dialogs::symbol_options::open_edit(app, name.as_deref())
} else {
crate::dialogs::symbol_options::open(app)
}
}
"ui.newBrush" => crate::dialogs::new_brush::open(app, p.get("ids").cloned()),
"ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()),
"tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()),
"ui.colorGuideOptions" => {
crate::dialogs::color_guide_options::open(app);
Ok(Value::Null)
}
"ui.colorBalanceDialog" => {
crate::dialogs::color_balance::open(app);
Ok(Value::Null)
}
"ui.saturateDialog" => {
crate::dialogs::saturate::open(app);
Ok(Value::Null)
}
"window.swatchLibrary" => crate::panels::swatches::open_library(app, p),
"window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")),
"ui.saveSwatchLibrary" => {
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
crate::dialogs::save_swatch_library::open(app, names.unwrap_or_default())
}
id if id.starts_with(crate::panels::swatches::USER_SLOT) => match crate::panels::swatches::user_library(app, id) {
Some(l) => crate::panels::swatches::open_library(app, &json!({ "library": l.id })),
None => Err("no such user library".into()),
},
"ui.mergeGraphicStyles" => {
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
crate::dialogs::graphic_style_options::open_merge(app, names.unwrap_or_default())
}
"ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app),
"ui.layerOptions" => crate::dialogs::layer_options::open(app, p),
"ui.newLayer" => crate::dialogs::layer_options::open_new(app, p),
"ui.newArtboard" => crate::dialogs::artboard_options::open_new(app),
"ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app),
"ui.layersExpand" => crate::panels::layers::expand(app, p),
"ui.layersFilter" => crate::panels::layers::set_filter(app, p),
"ui.flattenTransparencyDialog" => {
crate::dialogs::flatten::open(app);
Ok(Value::Null)
}
"ui.flattenerPresetsDialog" => {
crate::dialogs::flattener_presets::open(app, s("selected").as_deref());
Ok(Value::Null)
}
"ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p),
"window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p),
"ui.libraryPanel" => crate::panels::library_panel::command(app, p),
"ui.libraryPanel.locate" => crate::panels::library_panel::locate(app, p),
"window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")),
"ui.saveGraphicStyleLibrary" => {
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
crate::dialogs::save_style_library::open(app, names.unwrap_or_default())
}
id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) {
Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })),
None => Err("no such user library".into()),
},
"window.symbolLibrary" => crate::panels::symbols::open_library(app, p),
"window.symbolLibrary.other" => crate::panels::symbols::other_library(app, s("path")),
"ui.saveSymbolLibrary" => {
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
crate::dialogs::save_symbol_library::open(app, names.unwrap_or_default())
}
id if id.starts_with(crate::panels::symbols::USER_SLOT) => match crate::panels::symbols::user_library(app, id) {
Some(l) => crate::panels::symbols::open_library(app, &json!({ "library": l.id })),
None => Err("no such user library".into()),
},
"window.brushLibrary" => crate::panels::brushes::open_library(app, p),
"window.brushLibrary.other" => crate::panels::brushes::other_library(app, s("path")),
"ui.saveBrushLibrary" => {
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
crate::dialogs::save_brush_library::open(app, names.unwrap_or_default())
}
id if id.starts_with(crate::panels::brushes::USER_SLOT) => match crate::panels::brushes::user_library(app, id) {
Some(l) => crate::panels::brushes::open_library(app, &json!({ "library": l.id })),
None => Err("no such user library".into()),
},
"ui.expandDialog" => crate::dialogs::expand::open(app),
"ui.simplifyAuto" => crate::dialogs::simplify_slider::auto(app),
"ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::more_options(app),
"attributes.openUrl" => crate::panels::attributes::open_url(app, p),
"ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p),
"ui.spotColors" => crate::dialogs::spot_colors::open(app),
"ui.menuDialog" => match s("command").as_deref() {
Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
c => match c.and_then(|c| menu_dialog(app, c)) {
Some((kind, fields)) => {
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
Ok(Value::Null)
}
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()),
},
},
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
"ui.corners" => crate::dialogs::corners::open(app, p),
"ui.hyphenation" => crate::dialogs::hyphenation::open(app),
"ui.justification" => crate::dialogs::justification::open(app),
"ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p),
"ui.colorPanel" => crate::panels::color::command(app, p),
"ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p),
"file.exportAs" if s("path").is_none() => {
crate::dialogs::open_export_as(app, s("format").as_deref(), p);
Ok(Value::Null)
}
"file.exportAs" => io::export(app, s("format").as_deref(), s("path"), p),
"ui.fileInfoDialog" => crate::dialogs::file_info::open(app),
"ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app),
"ui.effectGalleryDialog" => crate::dialogs::effect_gallery::open(app, p),
"ui.pdfPresetsDialog" => {
crate::dialogs::pdf_presets::open(app, s("selected").as_deref());
Ok(Value::Null)
}
"ui.pdfPresetDialog" => crate::dialogs::open_pdf_preset(app, p),
// Save for Office Documents…: its dialog; with options, pick the file (or write `path`).
"document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app),
"document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })),
"ui.dxfOptionsDialog" if app.session.active().is_none() => Err("no document".into()),
"ui.dxfOptionsDialog" => {
crate::dialogs::dxf_options::open(app, p);
Ok(Value::Null)
}
"links.editOriginal" => crate::panels::links::open_file(app, p, false),
"links.reveal" => crate::panels::links::open_file(app, p, true),
"ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p),
"ui.findLinksInFolder" => {
crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned())
}
"ui.packageDialog" => crate::dialogs::package::open(app),
"ui.batchDialog" => {
let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize;
let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize;
crate::dialogs::action_batch::open(app, set, action)
}
"ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p),
"file.showPackage" => crate::dialogs::package::show_package(app, p),
"docInfo.save" => crate::panels::doc_info::save_report(app, p),
// Slice Options… and Divide Slices…: their dialogs, on the selected slices.
"object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_options(app),
"object.slice.divide" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_divide(app),
"ui.epsOptionsDialog" if app.session.active().is_none() => Err("no document".into()),
"ui.epsOptionsDialog" => {
crate::dialogs::eps_options::open(app, p);
Ok(Value::Null)
}
// Save for Web: its dialog; with settings, write the files.
"file.saveForWeb" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::save_for_web::open(app),
"file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()),
"file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p),
// File → Export Selection…: the selection becomes one asset (`multiple: true`: one per
// object), shown checked on Export for Screens' Assets tab.
"file.exportSelection" => {
app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| {
let ids: Vec<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, p),
"plugin.cancelRuns" => Ok(crate::plugin_runs::cancel_all(app)),
"plugin.hideRuns" => Ok(crate::plugin_runs::hide_all(app)),
"ui.checkScript" => crate::dialogs::script_check::command(app, p),
"ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app),
"ui.perspectivePresetsDialog" => {
crate::dialogs::perspective_presets::open(app, s("selected").as_deref());
Ok(Value::Null)
}
"ui.savePerspectivePreset" => crate::dialogs::perspective_grid::open_save(app),
id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => match crate::dialogs::perspective_presets::slot_preset(app, id) {
Some(name) => app.run("perspective.grid.preset", json!({ "name": name })),
None => Err("no such perspective grid preset".into()),
},
"ui.blendOptions" => crate::dialogs::blend_options::open(app),
"ui.livePaintGapOptions" => crate::dialogs::live_paint_gaps::open(app),
"ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p),
_ => return None,
};
Some(r)
}
/// `view.goToArtboard`: make artboard `index` (a number, or first / previous / next / last from
/// the current one) active, synchronizing the Artboard tool when in use, and fit it in the window.
fn go_to_artboard(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let n = app.session.active().map(|d| d.doc.artboards.len()).ok_or("no document open")?;
let last = n.checked_sub(1).ok_or("the document has no artboards")?;
let current = app.view().map_or(0, |v| v.artboard).min(last);
let to = match p.get("index") {
Some(Value::String(s)) => match s.as_str() {
"first" => 0,
"previous" => current.saturating_sub(1),
"next" => current + 1,
"last" => last,
other => return Err(format!("index must be a number or first, previous, next or last, not `{other}`")),
},
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("index must be a number or first, previous, next or last")?,
None => return Err("missing index".into()),
}
.min(last);
crate::panels::artboards::select(app, to);
crate::canvas::fit(app, "view.fitArtboard");
Ok(json!({ "index": to }))
}
+169
View File
@@ -0,0 +1,169 @@
use super::*;
/// The active document's perspective grid (View → Perspective Grid toggles).
pub(super) fn perspective_grid(app: &VectorcraftApp) -> Option<vectorcraft_tools::distort::perspective::PerspectiveGrid> {
app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc))
}
/// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them).
pub(super) const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [
"file.openRecent1",
"file.openRecent2",
"file.openRecent3",
"file.openRecent4",
"file.openRecent5",
"file.openRecent6",
"file.openRecent7",
"file.openRecent8",
"file.openRecent9",
"file.openRecent10",
"file.openRecent11",
"file.openRecent12",
"file.openRecent13",
"file.openRecent14",
"file.openRecent15",
"file.openRecent16",
"file.openRecent17",
"file.openRecent18",
"file.openRecent19",
"file.openRecent20",
"file.openRecent21",
"file.openRecent22",
"file.openRecent23",
"file.openRecent24",
"file.openRecent25",
"file.openRecent26",
"file.openRecent27",
"file.openRecent28",
"file.openRecent29",
"file.openRecent30",
];
/// The saved selection a `select.recallN` slot names (none past the document's last).
pub(super) fn saved_selection_name(app: &VectorcraftApp, id: &str) -> Option<String> {
let n = saved_selection_slot(id)?.checked_sub(1)?;
app.session.active()?.doc.saved_selections.get(n).map(|x| x.name.clone())
}
/// The recent file a `file.openRecentN` slot names (none past the preference's count).
pub(super) fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> {
let n: usize = id.strip_prefix("file.openRecent")?.parse().ok()?;
io::recent_files(app).get(n.checked_sub(1)?)
}
/// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type.
pub(super) const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [
[
"ui.perspectiveUserPreset1.1",
"ui.perspectiveUserPreset1.2",
"ui.perspectiveUserPreset1.3",
"ui.perspectiveUserPreset1.4",
"ui.perspectiveUserPreset1.5",
],
[
"ui.perspectiveUserPreset2.1",
"ui.perspectiveUserPreset2.2",
"ui.perspectiveUserPreset2.3",
"ui.perspectiveUserPreset2.4",
"ui.perspectiveUserPreset2.5",
],
[
"ui.perspectiveUserPreset3.1",
"ui.perspectiveUserPreset3.2",
"ui.perspectiveUserPreset3.3",
"ui.perspectiveUserPreset3.4",
"ui.perspectiveUserPreset3.5",
],
];
/// Select → saved selections: the n-th saved selection of the active document.
pub(super) const SAVED_SELECTION_IDS: [&str; vectorcraft_engine::doc::SavedSelection::MAX] = [
"select.recall1",
"select.recall2",
"select.recall3",
"select.recall4",
"select.recall5",
"select.recall6",
"select.recall7",
"select.recall8",
"select.recall9",
"select.recall10",
"select.recall11",
"select.recall12",
"select.recall13",
"select.recall14",
"select.recall15",
"select.recall16",
"select.recall17",
"select.recall18",
"select.recall19",
"select.recall20",
"select.recall21",
"select.recall22",
"select.recall23",
"select.recall24",
"select.recall25",
];
/// View → saved views: the n-th saved view of the active document.
pub(super) const SAVED_VIEW_IDS: [&str; vectorcraft_engine::doc::SavedView::MAX] = [
"view.goto1",
"view.goto2",
"view.goto3",
"view.goto4",
"view.goto5",
"view.goto6",
"view.goto7",
"view.goto8",
"view.goto9",
"view.goto10",
"view.goto11",
"view.goto12",
"view.goto13",
"view.goto14",
"view.goto15",
"view.goto16",
"view.goto17",
"view.goto18",
"view.goto19",
"view.goto20",
"view.goto21",
"view.goto22",
"view.goto23",
"view.goto24",
"view.goto25",
];
/// The 1-based slot a `view.gotoN` id stands for.
pub(super) fn saved_view_slot(id: &str) -> Option<usize> {
id.strip_prefix("view.goto")?.parse().ok()
}
/// The saved view a `view.gotoN` slot names (none past the document's last).
pub(super) fn saved_view<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a vectorcraft_engine::doc::SavedView> {
app.session.active()?.doc.views.get(saved_view_slot(id)?.checked_sub(1)?)
}
/// The 1-based slot a `select.recallN` id stands for.
pub(super) fn saved_selection_slot(id: &str) -> Option<usize> {
id.strip_prefix("select.recall")?.parse().ok()
}
/// Type → Recent Fonts slots (Preferences › Type › Number of Recent Fonts shows up to 15).
pub(super) const RECENT_FONT_IDS: [&str; crate::MAX_RECENT_FONTS] = [
"type.recentFont1",
"type.recentFont2",
"type.recentFont3",
"type.recentFont4",
"type.recentFont5",
"type.recentFont6",
"type.recentFont7",
"type.recentFont8",
"type.recentFont9",
"type.recentFont10",
"type.recentFont11",
"type.recentFont12",
"type.recentFont13",
"type.recentFont14",
"type.recentFont15",
];
+275
View File
@@ -0,0 +1,275 @@
use super::run::normalize_panel;
use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot};
use super::*;
/// Checked state for toggle items.
pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
if id == "app.language" {
let lang = p.get("lang").and_then(Value::as_str).unwrap_or("");
let pref = app.session.prefs.interface_language.as_str();
return Some(if lang.eq_ignore_ascii_case("auto") {
crate::i18n::Lang::from_code(pref).is_none()
} else {
crate::i18n::Lang::from_code(pref).is_some_and(|l| l.code().eq_ignore_ascii_case(lang))
});
}
let v = &app.ui.view;
// An item that can be on or off is `Some` whatever the document and selection (off with none):
// the macOS menu bar can't change an item's kind without rebuilding.
// The first type object (of type on a path, with `onpath`) among the selected objects and the
// objects inside them, in the order the type commands act on them.
let selected_text = |onpath: bool| {
let st = app.session.active()?;
let mut found = None;
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if found.is_none()
&& let vectorcraft_engine::doc::NodeKind::Text(t) = &c.kind
&& (!onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. }))
{
found = Some(t);
}
});
if found.is_some() {
break;
}
}
found
};
Some(match id {
"type.orientation.vertical" | "type.orientation.horizontal" => {
selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical"))
}
// Checked when the first type object `selected_text` finds has it on.
"type.opticalMarginAlignment" => selected_text(false).is_some_and(|t| t.optical_margin),
"view.outline" => v.outline,
"view.pixelPreview" => v.pixel_preview,
"view.trimView" => v.trim_view,
"view.snapToPixel" => v.snap_to_pixel,
"view.snapToGlyph" => v.snap_to_glyph,
// Type on a Path effect of the selected path type.
"type.pathOptions" if p.get("effect").is_some() => {
selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id()))
}
"view.smartGuides" => v.smart_guides,
"view.grid" => v.grid,
"view.snapToGrid" => v.snap_to_grid,
"view.snapToPoint" => v.snap_to_point,
"view.rulers" => v.rulers,
// A View › Rotate View item is checked when the active view is turned to its angle; without
// a document no item is checked.
"view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9),
"window.control" => app.ui.control_bar,
"window.toolbar" => app.ui.toolbar,
"window.toolbarAdvanced" => app.ui.toolbar_advanced,
"window.toolbarColumns" => app.ui.toolbar_double,
"window.editToolbar" => app.ui.tools_drawer,
"window.floatTools" => {
let tool = p.get("tool").and_then(Value::as_str).unwrap_or("");
app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool))
}
"window.taskBar" => app.ui.task_bar,
"window.taskBar.pin" => app.ui.task_bar_place.pinned,
"window.panel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id))
}
"window.collapseDock" => crate::panel_docking::dock_collapsed(&app.ui),
"window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()),
"window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness),
"view.slices.lock" => app.session.slices_locked(),
"object.slice.clipToArtboard" => app.session.active().is_some_and(|d| d.doc.slices_clip_to_artboard),
"view.proofColors" => vectorcraft_render::proof::view().proof_colors,
"view.overprintPreview" => vectorcraft_render::proof::view().overprint,
"view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()),
"perspective.grid.snap" => perspective_grid(app).is_some_and(|g| g.snap),
"perspective.grid.lockStation" => perspective_grid(app).is_some_and(|g| g.lock_station),
"file.documentColorMode" | "object.convertDocumentColorMode" => {
let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk);
p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" })
}
// The ruler context menu lists the document units, the current one checked.
"document.setUnits" => {
p.get("units").and_then(Value::as_str).and_then(vectorcraft_engine::doc::Unit::named) == Some(app.session.general_unit())
}
_ => return None,
})
}
/// Menu slots that are left out while they have nothing to show (unused saved views, recent files,
/// User Defined swatch and graphic style libraries).
pub(super) fn hidden_when_disabled(id: &str) -> bool {
id.starts_with("view.goto")
|| saved_selection_slot(id).is_some()
|| id.starts_with("file.openRecent")
|| id.starts_with(crate::panels::swatches::USER_SLOT)
|| id.starts_with(crate::panels::graphic_styles::USER_SLOT)
|| id.starts_with(crate::panels::symbols::USER_SLOT)
|| id.starts_with(crate::panels::brushes::USER_SLOT)
|| id.starts_with(crate::dialogs::perspective_presets::SLOT)
}
/// Menu items another item stands in for right now: Envelope Distort's Reset with Warp and Reset
/// with Mesh take the place of Make with Warp and Make with Mesh while an envelope is selected.
fn swapped_out(app: &VectorcraftApp, id: &str) -> bool {
let reset = match id {
"object.envelope.makeWithWarp" | "object.envelope.makeWithMesh" => false,
"object.envelope.resetWithWarp" | "object.envelope.resetWithMesh" => true,
_ => return false,
};
enabled(app, "object.envelope.release") != reset
}
/// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on
/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item
/// another stands in for ([`swapped_out`]).
pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option<bool>)> {
let en = enabled(app, id);
if (!en && hidden_when_disabled(id)) || swapped_out(app, id) {
return None;
}
Some((en, checked(app, id, p)))
}
/// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins
/// list them (the command palette's cache follows it).
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
}
/// Effective shortcut of a command: the user's override (Edit → Keyboard Shortcuts) or the default.
pub fn shortcut_of(id: &str) -> Option<&'static str> {
crate::shortcut_editor::command_shortcut(id)
}
/// Is a command currently enabled?
/// Commands that would act on the text being typed, held back while an IME composes in the Type
/// tool (its marked text isn't committed yet): Undo/Redo — the native menu takes ⌘Z ahead of the
/// IME —, the clipboard and the selection.
/// Clear History also waits: it ends the typing session as Undo does.
pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool {
app.session.tool_composing()
&& (matches!(
id,
"edit.undo" | "edit.redo" | "edit.cut" | "edit.copy" | "edit.clear" | "edit.duplicate" | "select.all" | "select.none" | "select.inverse"
) || id == "edit.clearHistory"
|| id.starts_with("edit.paste"))
}
/// Engine commands that take `ids` and otherwise act on the Appearance panel's subject (the
/// targeted layer, group or object, else the selection). Without `ids` and without a subject they
/// are off, as the panel's buttons are: in menus, in the command palette and on their keys.
pub(super) const SUBJECT_COMMANDS: [&str; 2] = ["appearance.addFill", "appearance.addStroke"];
/// Is there an Appearance panel subject: a targeted layer, group or object, or a selected one?
pub(super) fn has_subject(app: &VectorcraftApp) -> bool {
app.session.active().is_some_and(|st| !st.selection.subjects().is_empty())
}
/// Can command `id` run now? An engine command asks the engine, a UI command the app's state;
/// none that would act on text an input method is still composing.
pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
if waits_for_ime(app, id) {
return false;
}
if SUBJECT_COMMANDS.contains(&id) {
return has_subject(app);
}
if let Some(c) = vectorcraft_engine::find_command(id) {
// The system clipboard's contents can be pasted with an empty internal clipboard.
return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste");
}
match id {
// Save is off for a clean document that already has its own file.
"file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()),
"type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(),
id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(),
"type.applyBullets" | "type.applyNumbers" | "type.convertListToText" => crate::panels::character::text_style(app).is_some(),
id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()),
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
"file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()),
"plugin.cancelRuns" => crate::plugin_runs::any_running(app),
"plugin.hideRuns" => crate::plugin_runs::any_shown(app),
"file.place"
| "file.export.svg"
| "file.export.png"
| "file.exportAs"
| "file.exportForScreens"
| "file.documentSetup"
| "view.zoomIn"
| "view.zoomOut"
| "view.fitArtboard"
| "view.fitAll"
| "view.actualSize"
| "view.rotate" => app.session.active().is_some(),
"view.rotateToSelection" => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()),
id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(),
"file.clearRecent" => !app.ui.recent_files.is_empty(),
id if id.starts_with("type.recentFont") => {
id["type.recentFont".len()..].parse::<usize>().is_ok_and(|n| n >= 1 && n <= app.recent_fonts().len()) && app.session.active().is_some()
}
id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).is_some(),
id if saved_view_slot(id).is_some() => saved_view(app, id).is_some(),
"effect.dialog" | "ui.recolorDialog" | "ui.effectGalleryDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.cropImage" => selected_image(app, |_| true),
"effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()),
"file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(),
"ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(),
"ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(),
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
"ui.graphicStyleOptions" => app.session.active().is_some(),
"ui.newArtboard" => app.session.active().is_some(),
"ui.sampleScreenColor" => crate::screen_color::available(app),
"ui.newBrush" => app.session.active().is_some(),
"ui.brushOptions" => crate::dialogs::brush_options::available(app),
"ui.hyphenation" | "ui.justification" => crate::panels::character::text_style(app).is_some(),
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.saveSwatchLibrary" => app.session.active().is_some(),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),
"ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.saveGraphicStyleLibrary" => app.session.active().is_some(),
id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(),
"ui.saveSymbolLibrary" => app.session.active().is_some_and(|d| !d.doc.symbols.is_empty()),
id if id.starts_with(crate::panels::symbols::USER_SLOT) => crate::panels::symbols::user_library(app, id).is_some(),
"ui.saveBrushLibrary" => crate::panels::brushes::has_brushes(app),
id if id.starts_with(crate::panels::brushes::USER_SLOT) => crate::panels::brushes::user_library(app, id).is_some(),
"ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.spotColors" => app.session.active().is_some(),
"ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.simplifyAuto" | "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::showing(app),
"ui.dxfOptionsDialog" => app.session.active().is_some(),
"links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app),
"ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app),
"ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(),
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
"ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()),
"ui.epsOptionsDialog" => app.session.active().is_some(),
"file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(),
"file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"css.copy" | "css.exportFile" => app.session.active().is_some(),
"print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()),
"plugin.dialog" => app.session.active().is_some(),
"ui.perspectiveGridDialog" | "ui.savePerspectivePreset" => app.session.active().is_some(),
id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => {
app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some()
}
"ui.perspectivePlane" => app.session.active().is_some(),
// Only one window per document so far: greyed out, as Window › Arrange's items are (#3724).
"window.newWindow" => false,
_ => true,
}
}
/// Is a placed document selected?
fn selected_placed(app: &VectorcraftApp) -> bool {
app.session.active().is_some_and(|st| {
st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::PlacedDocument(_))))
})
}
/// Is an image `keep` accepts selected?
pub(super) fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObject) -> bool) -> bool {
app.session.active().is_some_and(|st| {
st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(&n.kind, vectorcraft_doc::NodeKind::Image(im) if keep(im))))
})
}
+210
View File
@@ -0,0 +1,210 @@
use super::*;
/// Type → Size presets.
pub(super) const TYPE_SIZES: [(&str, u32); 14] = [
("6 pt", 6),
("8 pt", 8),
("9 pt", 9),
("10 pt", 10),
("11 pt", 11),
("12 pt", 12),
("14 pt", 14),
("18 pt", 18),
("24 pt", 24),
("30 pt", 30),
("36 pt", 36),
("48 pt", 48),
("60 pt", 60),
("72 pt", 72),
];
/// Type → Type on a Path effects (label, `type.pathOptions` effect), also the choices of its
/// Options dialog.
pub(crate) const PATH_EFFECTS: &[(&str, &str)] =
&[("Rainbow", "rainbow"), ("Skew", "skew"), ("3D Ribbon", "3dRibbon"), ("Stair Step", "stairStep"), ("Gravity", "gravity")];
pub(super) const INSERT_SPECIAL: &[(&str, &str)] = &[
("Bullet", "bullet"),
("Copyright Symbol", "copyright"),
("Ellipsis", "ellipsis"),
("Paragraph Symbol", "paragraph"),
("Registered Trademark Symbol", "registered"),
("Section Symbol", "section"),
("Trademark Symbol", "trademark"),
("Em Dash", "emDash"),
("En Dash", "enDash"),
("Discretionary Hyphen", "discretionaryHyphen"),
("Nonbreaking Hyphen", "nonBreakingHyphen"),
("Double Left Quotation Marks", "doubleLeftQuote"),
("Double Right Quotation Marks", "doubleRightQuote"),
("Single Left Quotation Mark", "singleLeftQuote"),
("Single Right Quotation Mark", "singleRightQuote"),
];
pub(super) const INSERT_WHITESPACE: &[(&str, &str)] = &[
("Em Space", "emSpace"),
("En Space", "enSpace"),
("Hair Space", "hairSpace"),
("Sixth Space", "sixthSpace"),
("Thin Space", "thinSpace"),
("Nonbreaking Space", "nonBreakingSpace"),
("Figure Space", "figureSpace"),
("Punctuation Space", "punctuationSpace"),
("Third Space", "thirdSpace"),
("Quarter Space", "quarterSpace"),
];
pub(super) const INSERT_BREAK: &[(&str, &str)] = &[("Forced Line Break", "forcedLineBreak"), ("Tab", "tab")];
pub(super) fn insert_items(list: &[(&'static str, &'static str)]) -> Vec<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 or Show Font Names in English change (the menu tree is built every frame); labels are
/// interned once each, so rebuilding doesn't allocate forever. The command still sets the English
/// family name ([`vectorcraft_text::FontDb::canonical`]).
pub(super) fn font_items(english_names: bool) -> Vec<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, bool, Vec<Item>)> = std::sync::Mutex::new((u64::MAX, true, Vec::new()));
let db = vectorcraft_text::FontDb::global();
// Read first: fonts that load meanwhile make the next frame build the list again.
let generation = db.generation();
let fams = db.menu_family_list();
let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] };
if items.0 != generation || items.1 != english_names {
let list = fams
.iter()
.map(|f| {
let english = f.as_str();
let shown = db.family_display_name(english, english_names);
let label: &'static str = match names.get(shown.as_str()) {
Some(n) => n,
None => {
let n: &'static str = Box::leak(shown.into_boxed_str());
names.insert(n);
n
}
};
let font: &'static str = match names.get(english) {
Some(n) => n,
None => {
let n: &'static str = Box::leak(english.to_string().into_boxed_str());
names.insert(n);
n
}
};
cp(label, "text.setStyle", json!({ "font": font }))
})
.collect();
*items = (generation, english_names, list);
}
items.2.clone()
}
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
/// vector effects): (submenu, the catalogue's menu path of its effects).
/// The raster Stylize submenu shares its path with the vector one and lists only the effects at
/// that path that [`vectorcraft_effects::PIXEL_EFFECTS`] names.
const RASTER_MENUS: [(&str, &[&str]); 10] = [
("Artistic", &["Effect", "Artistic"]),
("Blur", &["Effect", "Blur"]),
("Brush Strokes", &["Effect", "Brush Strokes"]),
("Distort", &["Effect", "Distort"]),
("Pixelate", &["Effect", "Pixelate"]),
("Sharpen", &["Effect", "Sharpen"]),
("Sketch", &["Effect", "Sketch"]),
("Stylize", &["Effect", "Stylize"]),
("Texture", &["Effect", "Texture"]),
("Video", &["Effect", "Video"]),
];
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
pub(super) fn effect_menu() -> Vec<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 and Rasterize…, items of their own) in the reference app's order.
let order = [
"3D and Materials",
"Color Adjustments",
"Convert to Shape",
"Crop Marks",
"Distort & Transform",
"Path",
"Pathfinder",
"Rasterize…",
"Stylize",
"SVG Filters",
"Warp",
];
let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label);
// An effect goes in the vector submenu its path names unless it is a Photoshop-style effect,
// which goes in the raster submenu of the same name when `RASTER_MENUS` has one (Stylize ›
// Glowing Edges).
let in_vector_menu =
|e: &vectorcraft_effects::EffectInfo| order.iter().any(|s| e.menu == ["Effect", *s]) && !vectorcraft_effects::PIXEL_EFFECTS.contains(&e.id);
let item = |e: &vectorcraft_effects::EffectInfo| match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
// No options (Effect → Pathfinder, Crop Marks): apply directly, like Illustrator.
true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })),
false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })),
};
// The effects whose catalogue menu path is `path`.
// `vector` picks the ones for the vector submenu or the ones for the raster submenu.
let items_at =
|path: &[&str], vector: bool| -> Vec<Item> { cat.iter().filter(|e| e.menu == path && in_vector_menu(e) == vector).map(item).collect() };
for sub_name in order {
if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) {
out.push(item(e));
continue;
}
let items = items_at(&["Effect", sub_name], true);
if items.is_empty() {
let placeholder = match sub_name {
"3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")],
"SVG Filters" => vec![todo("Apply SVG Filter…")],
_ => continue,
};
out.push(sub(sub_name, placeholder));
} else {
let mut items = items;
if sub_name == "3D and Materials" {
// In the panel's order of 3D types, then Materials (not built yet).
let rank = |label: &str| ["Extrude & Bevel…", "Revolve…", "Inflate…", "Rotate…"].iter().position(|l| *l == label).unwrap_or(9);
items.sort_by_key(|i| match i {
Item::Cmd(label, ..) => rank(label),
_ => 9,
});
items.push(todo("Materials…"));
}
out.push(sub(sub_name, items));
}
}
// Live-effect plug-ins close the vector effects.
out.extend(crate::dialogs::plugin::effect_menu());
let raster: Vec<Item> = RASTER_MENUS
.iter()
.filter_map(|(name, path)| {
let items = items_at(path, false);
(!items.is_empty()).then(|| sub(name, items))
})
.collect();
if !raster.is_empty() {
out.push(Sep);
out.push(Item::Header("Raster Effects"));
out.push(c("Effect Gallery…", "ui.effectGalleryDialog"));
out.extend(raster);
}
// Anything not placed above (future effects) still shows up.
let placed = |e: &vectorcraft_effects::EffectInfo| top_level(e) || in_vector_menu(e) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path);
for e in cat.iter().filter(|e| !placed(e)) {
out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })));
}
out
}
+711
View File
@@ -0,0 +1,711 @@
use super::slots::{PERSPECTIVE_SLOTS, RECENT_FONT_IDS, RECENT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS};
use super::submenus::{INSERT_BREAK, INSERT_SPECIAL, INSERT_WHITESPACE, PATH_EFFECTS, TYPE_SIZES, effect_menu, font_items, insert_items};
use super::*;
/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see
/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`).
/// `english_names` is Preferences › Type › Show Font Names in English (the Type → Font labels
/// follow it; tests use the default `true`).
pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
menu_tree_named(true)
}
pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id, "toggle": true }));
vec![
(
// The application menu: About and Discord only. The in-window bar never shows it (File
// is first there, as in Illustrator); on macOS `native_menu::mac_layout` composes the
// App menu from this entry and the other menus (About from Help, Settings from Edit,
// Quit from File, Language and Appearance synthesized). It stays first: `from_tree`
// gives the first menu the App role.
"VectorCraft",
vec![c("About VectorCraft", "help.about"), c("Join Our Discord", "help.discord")],
),
(
"File",
vec![
c("New…", "file.newDialog"),
c("New from Template…", "file.newFromTemplate"),
c("Open…", "file.open"),
sub("Open Recent Files", {
let mut v: Vec<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![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]),
Sep,
c("Document Setup…", "file.documentSetup"),
sub(
"Document Color Mode",
vec![
cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})),
cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})),
],
),
c("File Info…", "file.info"),
Sep,
c("Print…", "file.print"),
Sep,
c("Exit", "app.quit"),
],
),
(
"Edit",
vec![
c("Undo", "edit.undo"),
c("Redo", "edit.redo"),
Sep,
c("Cut", "edit.cut"),
c("Copy", "edit.copy"),
c("Paste", "edit.paste"),
c("Paste in Front", "edit.pasteInFront"),
c("Paste in Back", "edit.pasteInBack"),
c("Paste in Place", "edit.pasteInPlace"),
c("Paste on All Artboards", "edit.pasteOnAllArtboards"),
c("Paste without Formatting", "edit.pasteWithoutFormatting"),
c("Clear", "edit.clear"),
Sep,
cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})),
cp("Find Next", "edit.findNext", json!({"find": ""})),
sub("Spelling", vec![todo("Auto Spell Check"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")]),
Sep,
sub(
"Edit Colors",
vec![
c("Recolor Artwork…", "ui.recolorDialog"),
sub(
"Recolor with Preset",
vec![
cp("1 Color Job…", "ui.recolorDialog", json!({"colors": 1})),
cp("2 Color Job…", "ui.recolorDialog", json!({"colors": 2})),
cp("3 Color Job…", "ui.recolorDialog", json!({"colors": 3})),
cp("Color Library…", "ui.recolorDialog", json!({"library": ""})),
],
),
c("Adjust Color Balance…", "ui.colorBalanceDialog"),
c("Blend Front to Back", "edit.colors.blendFrontToBack"),
c("Blend Horizontally", "edit.colors.blendHorizontally"),
c("Blend Vertically", "edit.colors.blendVertically"),
c("Convert to CMYK", "edit.colors.toCMYK"),
c("Convert to Grayscale", "edit.colors.toGrayscale"),
c("Convert to RGB", "edit.colors.toRGB"),
c("Invert Colors", "edit.colors.invert"),
cp(
"Overprint Black…",
"edit.colors.overprintBlack",
json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}),
),
c("Saturate…", "ui.saturateDialog"),
],
),
c("Edit Original", "links.editOriginal"),
Sep,
c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"),
c("Print Presets…", "ui.printPresetsDialog"),
c("PDF Presets…", "ui.pdfPresetsDialog"),
c("Perspective Grid Presets…", "ui.perspectivePresetsDialog"),
Sep,
c("Color Settings…", "edit.colorSettings"),
c("Assign Profile…", "edit.assignProfile"),
Sep,
c("Keyboard Shortcuts…", "edit.keyboardShortcuts"),
c("Preferences…", "edit.preferences"),
],
),
(
"Object",
vec![
sub("Transform", transform_items()),
sub("Arrange", arrange_items()),
sub(
"Align",
vec![
cp("Horizontal Align Left", "object.align", json!({"horizontal": "left"})),
cp("Horizontal Align Center", "object.align", json!({"horizontal": "center"})),
cp("Horizontal Align Right", "object.align", json!({"horizontal": "right"})),
Sep,
cp("Horizontal & Vertical Align Center", "object.align", json!({"horizontal": "center", "vertical": "center"})),
Sep,
cp("Vertical Align Top", "object.align", json!({"vertical": "top"})),
cp("Vertical Align Center", "object.align", json!({"vertical": "center"})),
cp("Vertical Align Bottom", "object.align", json!({"vertical": "bottom"})),
],
),
Sep,
c("Group", "object.group"),
c("Ungroup", "object.ungroup"),
sub(
"Lock",
vec![c("Selection", "object.lock"), c("All Artwork Above", "object.lock.above"), c("Other Layers", "object.lock.otherLayers")],
),
c("Unlock All", "object.unlockAll"),
sub(
"Hide",
vec![c("Selection", "object.hide"), c("All Artwork Above", "object.hide.above"), c("Other Layers", "object.hide.otherLayers")],
),
c("Show All", "object.showAll"),
Sep,
c("Expand…", "ui.expandDialog"),
c("Expand Appearance", "effect.expandAppearance"),
c("Crop Image", "ui.cropImage"),
c("Rasterize…", "object.rasterize"),
cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})),
cp(
"Create Object Mosaic…",
"object.createObjectMosaic",
json!({"columns": 10, "rows": 10, "spacingX": 0, "spacingY": 0, "gray": false, "deleteRaster": false}),
),
c("Vector Halftone…", "object.vectorHalftone"),
c("Create Trim Marks", "object.createTrimMarks"),
c("Flatten Transparency…", "ui.flattenTransparencyDialog"),
Sep,
c("Make Pixel Perfect", "object.makePixelPerfect"),
Sep,
sub(
"Slice",
vec![
c("Make", "object.slice.make"),
c("Release", "object.slice.release"),
c("Create from Guides", "object.slice.fromGuides"),
c("Create from Selection", "object.slice.fromSelection"),
Sep,
c("Duplicate Slice", "object.slice.duplicate"),
c("Combine Slices", "object.slice.combine"),
c("Divide Slices…", "object.slice.divide"),
Sep,
c("Delete All", "object.slice.deleteAll"),
c("Slice Options…", "object.slice.options"),
c("Clip to Artboard", "object.slice.clipToArtboard"),
],
),
Sep,
sub(
"Path",
vec![
c("Join", "path.join"),
c("Average…", "path.average"),
Sep,
c("Outline Stroke", "object.path.outlineStroke"),
c("Offset Path…", "object.path.offsetPath"),
c("Reverse Path Direction", "path.reverse"),
Sep,
c("Simplify…", "object.path.simplify"),
c("Add Anchor Points", "object.path.addAnchorPoints"),
c("Remove Anchor Points", "path.removeAnchors"),
c("Divide Objects Below", "object.path.divideObjectsBelow"),
c("Split Into Grid…", "object.path.splitIntoGrid"),
Sep,
cp("Clean Up…", "object.path.cleanUp", json!({"strayPoints": true, "unpaintedObjects": true, "emptyTextPaths": true})),
],
),
sub("Shape", vec![c("Convert to Shape", "object.shape.convertToShape"), c("Expand Shape", "object.expandShape")]),
sub(
"Pattern",
vec![c("Make", "object.pattern.make"), c("Edit Pattern", "object.pattern.edit"), c("Tile Edge Color…", "ui.tileEdgeColor")],
),
sub(
"Repeat",
vec![
c("Radial", "object.repeat.radial"),
c("Grid", "object.repeat.grid"),
c("Mirror", "object.repeat.mirror"),
Sep,
c("Release", "object.repeat.release"),
c("Options…", "object.repeat.options"),
],
),
sub(
"Intertwine",
vec![c("Make", "object.intertwine.make"), c("Edit", "object.intertwine.edit"), c("Release", "object.intertwine.release")],
),
sub("Objects on Path", vec![c("Attach", "object.objectsOnPath.attach"), c("Expand", "object.objectsOnPath.expand")]),
sub(
"Blend",
vec![
c("Make", "object.blend.make"),
c("Release", "object.blend.release"),
Sep,
c("Blend Options…", "object.blend.options"),
Sep,
c("Expand", "object.blend.expand"),
Sep,
c("Replace Spine", "object.blend.replaceSpine"),
c("Reverse Spine", "object.blend.reverseSpine"),
c("Reverse Front to Back", "object.blend.reverseFrontToBack"),
],
),
sub(
"Envelope Distort",
vec![
// While an envelope is selected, Reset takes Make's place (`swapped_out`).
c("Make with Warp…", "object.envelope.makeWithWarp"),
c("Reset with Warp…", "object.envelope.resetWithWarp"),
c("Make with Mesh…", "object.envelope.makeWithMesh"),
c("Reset with Mesh…", "object.envelope.resetWithMesh"),
c("Make with Top Object", "object.envelope.makeWithTopObject"),
Sep,
c("Release", "object.envelope.release"),
c("Envelope Options…", "object.envelope.options"),
c("Expand", "object.envelope.expand"),
Sep,
c("Edit Contents", "object.envelope.editContents"),
],
),
sub(
"Perspective",
vec![
c("Attach to Active Plane", "perspective.attach"),
c("Release with Perspective", "perspective.release"),
c("Move Plane to Match Object", "perspective.plane.matchObject"),
c("Edit Text", "perspective.editText"),
],
),
sub(
"Live Paint",
vec![
c("Make", "livePaint.make"),
c("Merge", "livePaint.merge"),
c("Release", "livePaint.release"),
Sep,
c("Gap Options…", "ui.livePaintGapOptions"),
Sep,
c("Expand", "livePaint.expand"),
],
),
sub(
"Image Trace",
vec![
// Traced at once with the Default preset, as the Control bar's Image Trace
// button does; other presets come from its arrow or the Image Trace panel
// (#543: the "…" form asked for a preset's name in a text box).
cp("Make", "imageTrace.make", json!({"preset": "Default"})),
cp("Make and Expand", "imageTrace.makeAndExpand", json!({"preset": "Default"})),
c("Release", "imageTrace.release"),
c("Expand", "imageTrace.expand"),
],
),
sub(
"Text Wrap",
vec![
c("Make", "object.textWrap.make"),
c("Release", "object.textWrap.release"),
c("Text Wrap Options…", "object.textWrap.options"),
],
),
Sep,
sub(
"Clipping Mask",
vec![
c("Make", "object.clippingMask.make"),
c("Release", "object.clippingMask.release"),
c("Edit Contents", "object.clippingMask.editContents"),
c("Edit Clipping Path", "object.clippingMask.editMask"),
],
),
sub("Compound Path", vec![c("Make", "object.compoundPath.make"), c("Release", "object.compoundPath.release")]),
sub(
"Artboards",
vec![
c("Convert to Artboards", "artboard.convertToArtboards"),
c("Rearrange All Artboards…", "artboard.rearrange"),
Sep,
c("Fit to Artwork Bounds", "artboard.fitToArt"),
c("Fit to Selected Art", "artboard.fitToSelection"),
],
),
sub(
"Graph",
vec![
c("Type…", "graph.setType"),
c("Data…", "graph.setData"),
c("Design…", "graph.design"),
c("Column…", "graph.column"),
c("Marker…", "graph.marker"),
],
),
sub(
"Collect for Export",
vec![
cp("As Single Asset", "assets.add", json!({"multiple": false})),
cp("As Multiple Assets", "assets.add", json!({"multiple": true})),
],
),
Sep,
sub("Plug-ins", crate::dialogs::plugin::object_menu()),
],
),
(
"Type",
vec![
sub("Font", font_items(english_names)),
sub("Recent Fonts", RECENT_FONT_IDS.iter().map(|id| c("Recent Font", id)).collect()),
sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()),
c("Bold", "type.bold"),
c("Italic", "type.italic"),
Sep,
panel("Glyphs", "glyphs"),
sub("Insert Special Character", insert_items(INSERT_SPECIAL)),
sub("Insert Whitespace Character", insert_items(INSERT_WHITESPACE)),
sub("Insert Break Character", insert_items(INSERT_BREAK)),
Sep,
c("Convert To Area Type", "type.convertToAreaType"),
c("Convert To Point Type", "type.convertToPointType"),
c("Area Type Options…", "text.areaOptions"),
sub(
"Type on a Path",
PATH_EFFECTS
.iter()
.map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect })))
.chain([Sep, c("Type on a Path Options…", "type.pathOptions")])
.collect(),
),
sub(
"Threaded Text",
vec![
c("Create", "text.thread.create"),
c("Release Selection", "text.thread.releaseSelection"),
c("Remove Threading", "text.thread.remove"),
],
),
c("Fit Headline", "text.fitHeadline"),
Sep,
c("Find Font…", "type.findFont"),
sub(
"Change Case",
vec![
cp("UPPERCASE", "type.changeCase", json!({"case": "upper"})),
cp("lowercase", "type.changeCase", json!({"case": "lower"})),
cp("Title Case", "type.changeCase", json!({"case": "title"})),
cp("Sentence case", "type.changeCase", json!({"case": "sentence"})),
],
),
cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})),
sub(
"Bullets and Numbering",
vec![
c("Apply Bullets", "type.applyBullets"),
c("Apply Numbers", "type.applyNumbers"),
c("Convert to Text", "type.convertListToText"),
],
),
Sep,
c("Create Outlines", "type.createOutlines"),
c("Scale Text Attributes", "type.scaleTextAttributes"),
c("Optical Margin Alignment", "type.opticalMarginAlignment"),
Sep,
c("Fill with Placeholder Text", "type.fillPlaceholder"),
c("Insert Inline Symbol", "text.insertInline"),
Sep,
c("Show Hidden Characters", "type.hiddenCharacters"),
sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]),
],
),
("Select", {
let mut v = vec![
c("All", "select.all"),
c("All on Active Artboard", "select.allOnArtboard"),
c("Deselect", "select.none"),
c("Reselect", "select.reselect"),
Sep,
c("Inverse", "select.inverse"),
Sep,
c("Next Object Above", "select.nextAbove"),
c("Next Object Below", "select.nextBelow"),
Sep,
sub(
"Same",
vec![
c("Appearance", "select.same.appearance"),
c("Appearance Attribute", "select.same.appearanceAttribute"),
c("Blending Mode", "select.same.blendingMode"),
c("Fill & Stroke", "select.same.fillAndStroke"),
c("Fill Color", "select.same.fillColor"),
c("Opacity", "select.same.opacity"),
c("Stroke Color", "select.same.strokeColor"),
c("Stroke Weight", "select.same.strokeWeight"),
c("Graphic Style", "select.same.graphicStyle"),
c("Shape", "select.same.shapeType"),
c("Symbol Instance", "select.same.symbolInstance"),
Sep,
c("Font Family", "select.same.fontFamily"),
c("Font Family & Style", "select.same.fontFamilyStyle"),
c("Font Family, Style & Size", "select.same.fontFamilyStyleSize"),
c("Font Size", "select.same.fontSize"),
c("Text Fill Color", "select.same.textFillColor"),
c("Text Stroke Color", "select.same.textStrokeColor"),
],
),
sub(
"Object",
vec![
c("All on Same Layers", "select.object.allOnSameLayers"),
c("Direction Handles", "select.object.directionHandles"),
Sep,
c("Brush Strokes", "select.object.brushStrokes"),
c("Bristle Brush Strokes", "select.object.bristleBrushStrokes"),
c("Clipping Masks", "select.object.clippingMasks"),
c("Slices", "select.object.slices"),
c("Stray Points", "select.object.strayPoints"),
c("Open Paths", "select.object.openPaths"),
Sep,
c("All Text Objects", "select.object.textObjects"),
c("Point Text Objects", "select.object.pointText"),
c("Area Text Objects", "select.object.areaText"),
],
),
c("Start Global Edit", "select.globalEdit"),
Sep,
c("Save Selection…", "select.save"),
c("Update Selection", "select.update"),
c("Edit Selection…", "select.editSaved"),
Sep,
];
v.extend(SAVED_SELECTION_IDS.iter().map(|id| c("Saved Selection", id)));
v
}),
("Effect", effect_menu()),
("View", {
let mut v = vec![
c("Outline", "view.outline"),
c("Overprint Preview", "view.overprintPreview"),
c("Pixel Preview", "view.pixelPreview"),
c("Trim View", "view.trimView"),
c("Presentation Mode", "view.presentation"),
sub(
"Screen Mode",
vec![
cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})),
cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})),
cp("Full Screen Mode", "view.screenMode", json!({"mode": 2})),
],
),
Sep,
sub(
"Proof Setup",
vec![
cp("Working CMYK", "view.proofSetup", json!({"target": "workingCmyk", "proof": true})),
cp("Legacy Macintosh RGB (Gamma 1.8)", "view.proofSetup", json!({"target": "legacyMacRgb", "proof": true})),
cp("Internet Standard RGB (sRGB)", "view.proofSetup", json!({"target": "srgb", "proof": true})),
cp("Monitor RGB", "view.proofSetup", json!({"target": "monitorRgb", "proof": true})),
cp("Color blindness – Protanopia-type", "view.proofSetup", json!({"target": "protanopia", "proof": true})),
cp("Color blindness – Deuteranopia-type", "view.proofSetup", json!({"target": "deuteranopia", "proof": true})),
cp("Color blindness – Tritanopia-type", "view.proofSetup", json!({"target": "tritanopia", "proof": true})),
cp("Customize…", "window.panel", json!({"panel": "separations"})),
],
),
c("Proof Colors", "view.proofColors"),
Sep,
c("Zoom In", "view.zoomIn"),
c("Zoom Out", "view.zoomOut"),
c("Fit Artboard in Window", "view.fitArtboard"),
c("Fit All in Window", "view.fitAll"),
c("Actual Size", "view.actualSize"),
Sep,
sub("Rotate View", crate::state::ROTATE_VIEW_ANGLES.iter().map(|(l, a)| cp(l, "view.rotate", json!({ "angle": a }))).collect()),
c("Rotate View to Selection", "view.rotateToSelection"),
c("Reset Rotate View", "view.rotateReset"),
Sep,
c("Hide Edges", "view.edges"),
c("Hide Artboards", "view.artboards"),
c("Show Print Tiling", "view.printTiling"),
c("Hide Slices", "view.slices.hide"),
c("Lock Slices", "view.slices.lock"),
c("Hide Template", "view.template"),
Sep,
sub(
"Rulers",
vec![
c("Show Rulers", "view.rulers"),
c("Change to Global Rulers", "view.globalRulers"),
c("Show Video Rulers", "view.videoRulers"),
],
),
c("Hide Bounding Box", "view.boundingBox"),
c("Show Transparency Grid", "view.transparencyGrid"),
c("Hide Text Threads", "view.textThreads"),
c("Hide Gradient Annotator", "view.gradientAnnotator"),
c("Hide Corner Widget", "view.cornerWidget"),
c("Show Live Paint Gaps", "view.livePaintGaps"),
Sep,
sub(
"Guides",
vec![
c("Hide Guides", "view.guides"),
c("Lock Guides", "view.guides.lock"),
c("Make Guides", "view.guides.make"),
c("Release Guides", "view.guides.release"),
c("Clear Guides", "view.guides.clear"),
],
),
c("Smart Guides", "view.smartGuides"),
sub(
"Perspective Grid",
vec![
c("Show Grid", "perspective.grid.show"),
c("Show Rulers", "perspective.grid.rulers"),
c("Snap to Grid", "perspective.grid.snap"),
c("Lock Grid", "perspective.grid.lock"),
c("Lock Station Point", "perspective.grid.lockStation"),
Sep,
c("Define Grid…", "ui.perspectiveGridDialog"),
sub("One Point Perspective", crate::dialogs::perspective_presets::menu(1, PERSPECTIVE_SLOTS[0])),
sub("Two Point Perspective", crate::dialogs::perspective_presets::menu(2, PERSPECTIVE_SLOTS[1])),
sub("Three Point Perspective", crate::dialogs::perspective_presets::menu(3, PERSPECTIVE_SLOTS[2])),
Sep,
c("Save Grid as Preset…", "ui.savePerspectivePreset"),
],
),
c("Show Grid", "view.grid"),
c("Snap to Grid", "view.snapToGrid"),
c("Snap to Pixel", "view.snapToPixel"),
c("Snap to Point", "view.snapToPoint"),
c("Snap to Glyph", "view.snapToGlyph"),
Sep,
c("New View…", "view.saved.new"),
c("Edit Views…", "view.saved.edit"),
Sep,
];
v.extend(SAVED_VIEW_IDS.iter().map(|id| c("Saved View", id)));
v
}),
(
"Window",
vec![
c("New Window", "window.newWindow"),
sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]),
sub("Workspace", crate::workspaces::menu_items()),
Sep,
c("Control", "window.control"),
c("Contextual Task Bar", "window.taskBar"),
c("Tools", "window.toolbar"),
sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns")]),
Sep,
panel("Actions", "actions"),
panel("Align", "align"),
panel("Appearance", "appearance"),
panel("Artboards", "artboards"),
panel("Asset Export", crate::panels::asset_export::ID),
panel("Attributes", "attributes"),
panel("Blend", crate::panels::blend::ID),
panel("Brushes", "brushes"),
panel("Color", "color"),
panel("Color Guide", "colorGuide"),
panel("Color Themes", "colorThemes"),
panel("CSS Properties", crate::panels::css_properties::ID),
panel("Document Info", "docInfo"),
panel("Flattener Preview", crate::panels::flattener_preview::ID),
panel("Gradient", "gradient"),
panel("Graphic Styles", "graphicStyles"),
panel("History", "history"),
panel("Image Trace", "imageTrace"),
panel("Info", "info"),
panel("Layers", "layers"),
panel("Libraries", "libraries"),
panel("Links", crate::panels::links::ID),
panel("Magic Wand", "magicWand"),
panel("Navigator", "navigator"),
panel("Pathfinder", "pathfinder"),
panel("Pattern Options", "patternOptions"),
panel("Properties", "properties"),
panel("Separations Preview", "separations"),
panel("Stroke", "stroke"),
panel("SVG Interactivity", crate::panels::svg_interactivity::ID),
panel("Swatches", "swatches"),
panel("Symbols", "symbols"),
panel("Transform", "transform"),
panel("Transparency", "transparency"),
sub(
"Type",
vec![
panel("Character", "character"),
panel("Character Styles", "charStyles"),
panel("Glyphs", "glyphs"),
panel("OpenType", "openType"),
panel("Paragraph", "paragraph"),
panel("Paragraph Styles", "paraStyles"),
panel("Tabs", "tabs"),
],
),
panel("Variables", crate::panels::variables::ID),
Sep,
sub("Brush Libraries", crate::panels::brushes::window_menu()),
sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()),
sub("Swatch Libraries", crate::panels::swatches::window_menu()),
sub("Symbol Libraries", crate::panels::symbols::window_menu()),
],
),
(
"Help",
vec![
c("Join Our Discord", "help.discord"),
c("ArtCraft Website", "help.website"),
c("VectorCraft on getartcraft.com", "help.appPage"),
c("VectorCraft on GitHub", "help.github"),
Sep,
c("Search Commands…", "help.commandPalette"),
todos("VectorCraft Help…", "F1"),
Sep,
c("Check for Updates…", "help.checkForUpdates"),
c("About VectorCraft", "help.about"),
],
),
]
}
/// Object → Transform (also in the canvas context menu).
pub(super) fn transform_items() -> Vec<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).
pub(super) 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"),
]
}
File diff suppressed because it is too large Load Diff