mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
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:
+32
-4383
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¶ms).map_err(|e| e.to_string())
|
||||
}
|
||||
// A saved view: its zoom, centre and rotation, in the current window.
|
||||
id if saved_view_slot(id).is_some() => {
|
||||
let saved = saved_view(app, id).and_then(|sv| crate::state::View::saved(sv).map(|to| (sv.name.clone(), to)));
|
||||
match (saved, app.view_mut()) {
|
||||
(Some((name, to)), Some(v)) => {
|
||||
(v.center, v.zoom, v.rotation) = (to.center, to.zoom, to.rotation);
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
_ => Err("no such view".into()),
|
||||
}
|
||||
}
|
||||
id if saved_selection_slot(id).is_some() => match saved_selection_name(app, id) {
|
||||
Some(name) => app.run("select.recall", json!({ "name": name })),
|
||||
None => Err("no such saved selection".into()),
|
||||
},
|
||||
"view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel),
|
||||
"view.snapToGlyph" => flag(&mut app.ui.view.snap_to_glyph),
|
||||
// The preference itself (General › Use Precise Cursors), so the command, the dialog and the saved
|
||||
// preferences share one value.
|
||||
"view.preciseCursors" => {
|
||||
let on = !app.session.prefs.use_precise_cursors;
|
||||
app.run("prefs.set", json!({"key": "usePreciseCursors", "value": on})).map(|_| {
|
||||
crate::prefs_dialog::snapshot(app);
|
||||
json!(on)
|
||||
})
|
||||
}
|
||||
"view.textThreads" => flag(&mut app.ui.view.text_threads),
|
||||
"type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars),
|
||||
"type.bold" => crate::panels::character::toggle_face(app, Face::Bold),
|
||||
"type.italic" => crate::panels::character::toggle_face(app, Face::Italic),
|
||||
id if let Some((_, justify)) = crate::panels::paragraph::ALIGN_COMMANDS.iter().find(|c| c.0 == id) => {
|
||||
crate::panels::paragraph::align(app, justify)
|
||||
}
|
||||
"type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p),
|
||||
"type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p),
|
||||
"type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app),
|
||||
id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b),
|
||||
"view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator),
|
||||
"view.livePaintGaps" => flag(&mut app.ui.view.live_paint_gaps),
|
||||
"effect.last" => match app.last_effect.clone() {
|
||||
Some((e, params)) => {
|
||||
let label = vectorcraft_effects::effect_info(&e).map(|i| i.label.trim_end_matches('…').to_string()).unwrap_or_else(|| e.clone());
|
||||
let mut fields = params.as_object().cloned().unwrap_or_default();
|
||||
fields.insert("__effect".into(), json!(e));
|
||||
fields.insert("__label".into(), json!(label));
|
||||
fields.insert("preview".into(), json!(true));
|
||||
app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Err("no effect applied yet".into()),
|
||||
},
|
||||
id if id.starts_with("type.recentFont") => {
|
||||
let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0);
|
||||
match n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned() {
|
||||
Some(font) => app.run("text.setStyle", json!({ "font": font })),
|
||||
None => Err("no such recent font".into()),
|
||||
}
|
||||
}
|
||||
"view.edges" => flag(&mut app.ui.view.edges),
|
||||
"view.artboards" => flag(&mut app.ui.view.artboards),
|
||||
"view.template" => flag(&mut app.ui.view.templates),
|
||||
"view.rulers" => flag(&mut app.ui.view.rulers),
|
||||
"view.videoRulers" => {
|
||||
let v = &mut app.ui.view;
|
||||
let show = !(v.video_rulers && v.rulers);
|
||||
v.video_rulers = show;
|
||||
v.rulers |= show;
|
||||
Ok(json!(show))
|
||||
}
|
||||
"view.boundingBox" => flag(&mut app.ui.view.bounding_box),
|
||||
"view.guides" => {
|
||||
let r = flag(&mut app.ui.view.guides);
|
||||
// Hidden ruler guides and guide paths cannot stay selected: their selection overlay
|
||||
// would otherwise reveal geometry that Hide Guides just hid.
|
||||
let hidden_selection = !app.ui.view.guides
|
||||
&& app.session.active().is_some_and(|d| {
|
||||
!d.selection.guides.is_empty()
|
||||
|| d.selection
|
||||
.objects
|
||||
.iter()
|
||||
.any(|id| matches!(d.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Path { guide: true, .. })))
|
||||
});
|
||||
if hidden_selection {
|
||||
// Deselecting a document that is open cannot fail.
|
||||
let _ = app.run("select.none", json!({}));
|
||||
}
|
||||
r
|
||||
}
|
||||
"view.smartGuides" => flag(&mut app.ui.view.smart_guides),
|
||||
"view.grid" => flag(&mut app.ui.view.grid),
|
||||
"view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid),
|
||||
"view.snapToPoint" => flag(&mut app.ui.view.snap_to_point),
|
||||
"view.zoomIn" | "view.zoomOut" => {
|
||||
let up = id == "view.zoomIn";
|
||||
let print = app.session.prefs.display_print_size;
|
||||
// Selection & Anchor Display › Zoom to Selection: the selection comes to the middle.
|
||||
let focus = if app.session.prefs.zoom_to_selection { app.selection_box().map(|b| b.center()) } else { None };
|
||||
match app.view_mut() {
|
||||
Some(v) => {
|
||||
v.zoom = next_zoom(v.zoom, up);
|
||||
if let Some(c) = focus {
|
||||
v.center = c;
|
||||
}
|
||||
Ok(json!({"zoom": crate::state::zoom_display_percent(v.zoom, print)}))
|
||||
}
|
||||
None => Err("no document".into()),
|
||||
}
|
||||
}
|
||||
"view.setZoom" => {
|
||||
let print = app.session.prefs.display_print_size;
|
||||
let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print);
|
||||
let center =
|
||||
p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
match app.view_mut() {
|
||||
Some(v) => {
|
||||
v.zoom = z.clamp(0.0313, 640.0);
|
||||
v.fitted = true;
|
||||
if let Some(c) = center {
|
||||
v.center = c;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Err("no document".into()),
|
||||
}
|
||||
}
|
||||
"view.fitArtboard" | "view.fitAll" | "view.actualSize" => {
|
||||
crate::canvas::fit(app, id);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.goToArtboard" => go_to_artboard(app, p),
|
||||
"view.presentation" => {
|
||||
app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 };
|
||||
Ok(json!(app.ui.screen_mode))
|
||||
}
|
||||
"view.screenMode" => {
|
||||
app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) {
|
||||
Some(m) => m.min(3) as u8,
|
||||
// F cycles the three screen modes. Without a mode, the command also goes from
|
||||
// Presentation Mode (not in the cycle) back to Normal.
|
||||
None if app.ui.screen_mode >= 2 => 0,
|
||||
None => app.ui.screen_mode + 1,
|
||||
};
|
||||
Ok(json!(app.ui.screen_mode))
|
||||
}
|
||||
// View › Rotate View and the status bar's angle menu: the view turns about the center of the
|
||||
// canvas to the angle, normalized as a Rotate View drag normalizes it.
|
||||
"view.rotate" => {
|
||||
let key = "angle";
|
||||
let Some(angle) = p.get(key).and_then(Value::as_f64).filter(|a| a.is_finite()) else {
|
||||
return Some(Err(format!("`{key}` must be a number")));
|
||||
};
|
||||
match app.view_mut() {
|
||||
Some(v) => {
|
||||
v.rotation = vectorcraft_geom::normalize_deg(angle);
|
||||
Ok(json!({ "angle": v.rotation }))
|
||||
}
|
||||
None => Err("no document".into()),
|
||||
}
|
||||
}
|
||||
// View › Rotate View to Selection: the view turns about the center of the canvas to the angle
|
||||
// of the selection's bounding box, so the box is square to the window.
|
||||
"view.rotateToSelection" => {
|
||||
let Some(st) = app.session.active() else { return Some(Err("no document".into())) };
|
||||
if st.selection.objects.is_empty() {
|
||||
return Some(Err("nothing selected".into()));
|
||||
}
|
||||
let angle = vectorcraft_geom::normalize_deg(st.doc.bbox_angle(&st.selection.objects));
|
||||
match app.view_mut() {
|
||||
Some(v) => {
|
||||
v.rotation = angle;
|
||||
Ok(json!({ "angle": angle }))
|
||||
}
|
||||
None => Err("no document".into()),
|
||||
}
|
||||
}
|
||||
"view.rotateReset" => {
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.rotation = 0.0;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.control" => flag(&mut app.ui.control_bar),
|
||||
"window.toolbar" => flag(&mut app.ui.toolbar),
|
||||
"window.toolbarColumns" => opt_bool(p, "double").map(|on| {
|
||||
let on = on.unwrap_or(!app.ui.toolbar_double);
|
||||
app.ui.toolbar_double = on;
|
||||
json!(on)
|
||||
}),
|
||||
"window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced),
|
||||
"imageTrace.preview" => opt_bool(p, "on").map(|on| {
|
||||
let on = on.unwrap_or(!app.ui.image_trace_preview);
|
||||
app.ui.image_trace_preview = on;
|
||||
json!(on)
|
||||
}),
|
||||
"window.floatTools" => {
|
||||
let floating = match opt_bool(p, "floating") {
|
||||
Ok(on) => on,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) };
|
||||
crate::toolbar::float_group(app, &tool, floating).map(Value::Bool)
|
||||
}
|
||||
"window.taskBar" => flag(&mut app.ui.task_bar),
|
||||
"window.taskBar.pin" => {
|
||||
let place = &mut app.ui.task_bar_place;
|
||||
opt_bool(p, "pinned").map(|pinned| {
|
||||
let pinned = pinned.unwrap_or(!place.pinned);
|
||||
// Pinning holds the bar where it shows; unpinning lets it follow the selection from
|
||||
// there (the bar turns its pinned spot into an offset when it is next drawn).
|
||||
if pinned && !place.pinned {
|
||||
place.pin_at = place.shown_at;
|
||||
}
|
||||
place.pinned = pinned;
|
||||
json!(pinned)
|
||||
})
|
||||
}
|
||||
"window.taskBar.reset" => {
|
||||
app.ui.task_bar_place = Default::default();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.dock" => {
|
||||
let ui = &mut app.ui;
|
||||
// The bars the last Tab hid show again, unless the toolbar or the Control bar was shown
|
||||
// another way since then (Window › Tools, Window › Control). Panels shown with Shift+Tab
|
||||
// in between stay shown. Switching workspaces clears the record (`workspaces::apply`).
|
||||
let hid = ui.tab_hid.take().filter(|h| (h.dock || h.toolbar || h.control_bar) && !ui.toolbar && !ui.control_bar);
|
||||
let on = if let Some(h) = hid {
|
||||
ui.dock |= h.dock;
|
||||
ui.toolbar = h.toolbar;
|
||||
ui.control_bar = h.control_bar;
|
||||
true
|
||||
} else if ui.dock || ui.toolbar || ui.control_bar {
|
||||
ui.tab_hid = Some(crate::state::TabHid { dock: ui.dock, toolbar: ui.toolbar, control_bar: ui.control_bar });
|
||||
ui.dock = false;
|
||||
ui.toolbar = false;
|
||||
ui.control_bar = false;
|
||||
ui.open_panel = None;
|
||||
false
|
||||
} else {
|
||||
ui.dock = true;
|
||||
ui.toolbar = true;
|
||||
ui.control_bar = true;
|
||||
true
|
||||
};
|
||||
Ok(json!(on))
|
||||
}
|
||||
"window.panels" => {
|
||||
let on = !app.ui.dock;
|
||||
app.ui.dock = on;
|
||||
if !on {
|
||||
app.ui.open_panel = None;
|
||||
}
|
||||
Ok(json!(on))
|
||||
}
|
||||
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
|
||||
Some(b) => {
|
||||
app.ui.brightness = b;
|
||||
app.session.prefs.ui_brightness = b.id().into();
|
||||
app.canvas.key = None;
|
||||
Ok(json!(b.id()))
|
||||
}
|
||||
None => Err("brightness must be dark|mediumDark|mediumLight|light".into()),
|
||||
},
|
||||
"window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()),
|
||||
"tool.select" => match s("tool") {
|
||||
Some(t) if vectorcraft_tools::tool_info(&t).is_some() => {
|
||||
app.select_tool(&t);
|
||||
Ok(json!({"tool": app.session.tool_id()}))
|
||||
}
|
||||
Some(t) => Err(format!("unknown tool `{t}`")),
|
||||
None => Err("missing `tool`".into()),
|
||||
},
|
||||
"tool.setOption" => app.session.set_tool_option_cmd(p),
|
||||
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
|
||||
"ui.recolorDialog" => crate::dialogs::recolor::open(app, p),
|
||||
"ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()),
|
||||
"ui.cropImage" => {
|
||||
app.select_tool(vectorcraft_tools::cropimage::ID);
|
||||
Ok(json!({"tool": app.session.tool_id()}))
|
||||
}
|
||||
"ui.paramDialog" => {
|
||||
let cmd = s("command").unwrap_or_default();
|
||||
let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
fields.insert("__command".into(), json!(cmd));
|
||||
fields.insert("__label".into(), json!(s("label").unwrap_or(cmd.clone())));
|
||||
app.ui.dialog = Some(crate::state::Dialog { kind: "command".into(), fields });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"effect.applyLast" => match app.last_effect.clone() {
|
||||
Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})),
|
||||
None => Err("no effect applied yet".into()),
|
||||
},
|
||||
"file.export.pdf" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::open_save_pdf(app, &json!({})),
|
||||
"file.export.pdf" => io::export_pdf(app, p.clone()),
|
||||
"help.about" => {
|
||||
app.ui.about = true;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"help.checkForUpdates" => crate::updates_ui::check(app),
|
||||
"help.commandPalette" => {
|
||||
app.ui.palette_open = !app.ui.palette_open;
|
||||
app.ui.palette_query.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`).
|
||||
"file.close" => {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index())?;
|
||||
crate::unsaved::close(app, i)
|
||||
}
|
||||
"file.closeAll" => crate::unsaved::close_all(app, "closeAll"),
|
||||
"app.quit" => crate::unsaved::close_all(app, "quit"),
|
||||
"ui.swatchOptions" => match s("name") {
|
||||
Some(name) => crate::dialogs::swatch_options::open(app, &name),
|
||||
None => Err("missing `name`".into()),
|
||||
},
|
||||
"ui.newSwatch" => {
|
||||
let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref())
|
||||
}
|
||||
"ui.newColorGroup" => {
|
||||
let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
crate::dialogs::new_color_group::open(app, names.unwrap_or_default())
|
||||
}
|
||||
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
|
||||
"ui.sampleScreenColor" => crate::screen_color::start(app, p),
|
||||
"ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()),
|
||||
"ui.symbolOptions" => {
|
||||
let name = s("name");
|
||||
let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false);
|
||||
if edit || name.is_some() {
|
||||
crate::dialogs::symbol_options::open_edit(app, name.as_deref())
|
||||
} else {
|
||||
crate::dialogs::symbol_options::open(app)
|
||||
}
|
||||
}
|
||||
"ui.newBrush" => crate::dialogs::new_brush::open(app, p.get("ids").cloned()),
|
||||
"ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()),
|
||||
"tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()),
|
||||
"ui.colorGuideOptions" => {
|
||||
crate::dialogs::color_guide_options::open(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.colorBalanceDialog" => {
|
||||
crate::dialogs::color_balance::open(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.saturateDialog" => {
|
||||
crate::dialogs::saturate::open(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.swatchLibrary" => crate::panels::swatches::open_library(app, p),
|
||||
"window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")),
|
||||
"ui.saveSwatchLibrary" => {
|
||||
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
crate::dialogs::save_swatch_library::open(app, names.unwrap_or_default())
|
||||
}
|
||||
id if id.starts_with(crate::panels::swatches::USER_SLOT) => match crate::panels::swatches::user_library(app, id) {
|
||||
Some(l) => crate::panels::swatches::open_library(app, &json!({ "library": l.id })),
|
||||
None => Err("no such user library".into()),
|
||||
},
|
||||
"ui.mergeGraphicStyles" => {
|
||||
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
crate::dialogs::graphic_style_options::open_merge(app, names.unwrap_or_default())
|
||||
}
|
||||
"ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app),
|
||||
"ui.layerOptions" => crate::dialogs::layer_options::open(app, p),
|
||||
"ui.newLayer" => crate::dialogs::layer_options::open_new(app, p),
|
||||
"ui.newArtboard" => crate::dialogs::artboard_options::open_new(app),
|
||||
"ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app),
|
||||
"ui.layersExpand" => crate::panels::layers::expand(app, p),
|
||||
"ui.layersFilter" => crate::panels::layers::set_filter(app, p),
|
||||
"ui.flattenTransparencyDialog" => {
|
||||
crate::dialogs::flatten::open(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.flattenerPresetsDialog" => {
|
||||
crate::dialogs::flattener_presets::open(app, s("selected").as_deref());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p),
|
||||
"window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p),
|
||||
"ui.libraryPanel" => crate::panels::library_panel::command(app, p),
|
||||
"ui.libraryPanel.locate" => crate::panels::library_panel::locate(app, p),
|
||||
"window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")),
|
||||
"ui.saveGraphicStyleLibrary" => {
|
||||
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
crate::dialogs::save_style_library::open(app, names.unwrap_or_default())
|
||||
}
|
||||
id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) {
|
||||
Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })),
|
||||
None => Err("no such user library".into()),
|
||||
},
|
||||
"window.symbolLibrary" => crate::panels::symbols::open_library(app, p),
|
||||
"window.symbolLibrary.other" => crate::panels::symbols::other_library(app, s("path")),
|
||||
"ui.saveSymbolLibrary" => {
|
||||
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
crate::dialogs::save_symbol_library::open(app, names.unwrap_or_default())
|
||||
}
|
||||
id if id.starts_with(crate::panels::symbols::USER_SLOT) => match crate::panels::symbols::user_library(app, id) {
|
||||
Some(l) => crate::panels::symbols::open_library(app, &json!({ "library": l.id })),
|
||||
None => Err("no such user library".into()),
|
||||
},
|
||||
"window.brushLibrary" => crate::panels::brushes::open_library(app, p),
|
||||
"window.brushLibrary.other" => crate::panels::brushes::other_library(app, s("path")),
|
||||
"ui.saveBrushLibrary" => {
|
||||
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
crate::dialogs::save_brush_library::open(app, names.unwrap_or_default())
|
||||
}
|
||||
id if id.starts_with(crate::panels::brushes::USER_SLOT) => match crate::panels::brushes::user_library(app, id) {
|
||||
Some(l) => crate::panels::brushes::open_library(app, &json!({ "library": l.id })),
|
||||
None => Err("no such user library".into()),
|
||||
},
|
||||
"ui.expandDialog" => crate::dialogs::expand::open(app),
|
||||
"ui.simplifyAuto" => crate::dialogs::simplify_slider::auto(app),
|
||||
"ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::more_options(app),
|
||||
"attributes.openUrl" => crate::panels::attributes::open_url(app, p),
|
||||
"ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p),
|
||||
"ui.spotColors" => crate::dialogs::spot_colors::open(app),
|
||||
"ui.menuDialog" => match s("command").as_deref() {
|
||||
Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
|
||||
c => match c.and_then(|c| menu_dialog(app, c)) {
|
||||
Some((kind, fields)) => {
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()),
|
||||
},
|
||||
},
|
||||
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
|
||||
"ui.corners" => crate::dialogs::corners::open(app, p),
|
||||
"ui.hyphenation" => crate::dialogs::hyphenation::open(app),
|
||||
"ui.justification" => crate::dialogs::justification::open(app),
|
||||
"ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p),
|
||||
"ui.colorPanel" => crate::panels::color::command(app, p),
|
||||
"ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p),
|
||||
"file.exportAs" if s("path").is_none() => {
|
||||
crate::dialogs::open_export_as(app, s("format").as_deref(), p);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.exportAs" => io::export(app, s("format").as_deref(), s("path"), p),
|
||||
"ui.fileInfoDialog" => crate::dialogs::file_info::open(app),
|
||||
"ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app),
|
||||
"ui.effectGalleryDialog" => crate::dialogs::effect_gallery::open(app, p),
|
||||
"ui.pdfPresetsDialog" => {
|
||||
crate::dialogs::pdf_presets::open(app, s("selected").as_deref());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.pdfPresetDialog" => crate::dialogs::open_pdf_preset(app, p),
|
||||
// Save for Office Documents…: its dialog; with options, pick the file (or write `path`).
|
||||
"document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app),
|
||||
"document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })),
|
||||
"ui.dxfOptionsDialog" if app.session.active().is_none() => Err("no document".into()),
|
||||
"ui.dxfOptionsDialog" => {
|
||||
crate::dialogs::dxf_options::open(app, p);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"links.editOriginal" => crate::panels::links::open_file(app, p, false),
|
||||
"links.reveal" => crate::panels::links::open_file(app, p, true),
|
||||
"ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p),
|
||||
"ui.findLinksInFolder" => {
|
||||
crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned())
|
||||
}
|
||||
"ui.packageDialog" => crate::dialogs::package::open(app),
|
||||
"ui.batchDialog" => {
|
||||
let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
crate::dialogs::action_batch::open(app, set, action)
|
||||
}
|
||||
"ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p),
|
||||
"file.showPackage" => crate::dialogs::package::show_package(app, p),
|
||||
"docInfo.save" => crate::panels::doc_info::save_report(app, p),
|
||||
// Slice Options… and Divide Slices…: their dialogs, on the selected slices.
|
||||
"object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_options(app),
|
||||
"object.slice.divide" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_divide(app),
|
||||
"ui.epsOptionsDialog" if app.session.active().is_none() => Err("no document".into()),
|
||||
"ui.epsOptionsDialog" => {
|
||||
crate::dialogs::eps_options::open(app, p);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// Save for Web: its dialog; with settings, write the files.
|
||||
"file.saveForWeb" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::save_for_web::open(app),
|
||||
"file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()),
|
||||
"file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p),
|
||||
// File → Export Selection…: the selection becomes one asset (`multiple: true`: one per
|
||||
// object), shown checked on Export for Screens' Assets tab.
|
||||
"file.exportSelection" => {
|
||||
app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| {
|
||||
let ids: Vec<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 }))
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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))))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user