Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 cdb734aede Fix #219: right-click on the canvas opens a context menu
Right-clicking the canvas now opens a context menu, as the reference app
does. The object under the pointer is selected first unless it already is.
The menu then lists what applies to the selection: Undo and Redo, Cut, Copy,
Paste, Isolate Selected Group or Exit Isolation Mode, Group and Ungroup,
Join and Average, Make or Release Clipping Mask and Compound Path, Make
Guides, the Transform, Arrange and Select submenus, and Export Selection.
On empty canvas it lists Paste, the zoom commands, the ruler, grid and guide
toggles and Select All. Commands that can't run now are left out (Undo and
Redo stay, greyed out), and a press on the canvas only closes the menu. Agents list the same menu with the
`ui.contextMenu.list` control method.

Root cause: the canvas ignored the secondary button except in the toolbar's
tool flyouts; there was no context menu at all. The menu reuses the menu
bar's items, rendering and dispatch; Object > Transform and Arrange are now
shared lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:28:52 +07:00
BFlat 2f743f155f Merge pull request #227 from storytold/fix/issue-221
Fix #221: Effect > Pathfinder works on several selected objects
2026-10-07 02:27:53 +07:00
bflatasticandClaude Opus 5.5 4bc0837867 Fix #221: Effect > Pathfinder works on several selected objects
Choosing an Effect > Pathfinder item (Add, Intersect, Exclude, Subtract...)
with several loose objects selected now groups them and applies the live
effect to the group, in one undo step, so the result shows at once and stays
editable. A selection that already holds a group, or a single object, works
as before, and agents get `grouped: true` back when it happens.

Root cause: Pathfinder effects combine the contents of a group or layer.
Applied to loose objects, each one got an effect with nothing to combine, so
nothing visibly changed, while the Pathfinder panel's commands (found by the
command search) act on the selection directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:21:06 +07:00
BFlat 2ba0e1f6e5 Merge pull request #226 from storytold/fix/issue-177
Fix #177: DXF Fit and Aligned text span their two points
2026-10-07 02:20:43 +07:00
10 changed files with 432 additions and 72 deletions

No files matched your search

+2 -1
View File
@@ -95,6 +95,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
@@ -191,7 +192,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done) | 15–25 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
+51 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
has_doc,
apply
),
@@ -130,6 +130,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
let gid = s.edit(&label, |d, sel| {
let gid = super::object::group_nodes(d, &loose)?;
d.node_mut(gid).ok_or(EngineError::NoNode(gid))?.appearance.effects.push(effect.clone());
sel.set([gid]);
Ok(gid)
})?;
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
}
let item = item_target(s, p, C)?;
let mut index = 0;
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
@@ -143,6 +152,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
}
/// The selected objects a Pathfinder effect should group before it applies: it combines the
/// contents of a group or layer, so on several loose objects (none a group) it would do nothing.
/// `None` when the effect isn't one, objects are named (`ids`) or another target is asked for.
fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<NodeId>>> {
if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) {
return Ok(None);
}
let roots = super::appearance::subject_roots(s)?;
let d = &s.doc()?.doc;
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
Ok((roots.len() >= 2 && !grouped).then_some(roots))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let catalog: Vec<Value> = effects::effect_catalog()
.into_iter()
@@ -507,6 +529,34 @@ mod tests {
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn pathfinder_effect_on_loose_objects_groups_them_first() {
let (mut s, a) = session_with_rect();
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
assert_eq!(r["grouped"], true);
let g = NodeId(r["ids"][0].as_u64().unwrap());
let gn = node(&s, g);
assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "stacking order kept");
assert!(vectorcraft_render::effects::has_pathfinder(&gn));
assert_eq!(s.doc().unwrap().selection.objects, [g]);
// Live: one united shape, so expanding leaves a single path.
s.execute("effect.expandAppearance", &json!({})).unwrap();
assert_eq!(node(&s, g).children().unwrap().len(), 1);
// Grouping and applying are one undo step.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(g).is_none());
assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b));
// A selection holding a group applies to each object as before; so does a single object.
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
assert!(r.get("grouped").is_none());
assert_eq!(node(&s, a).appearance.effects.len(), 1);
}
#[test]
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
let (mut s, a) = session_with_rect();
+13 -7
View File
@@ -375,20 +375,26 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
fn group(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let gid = s.edit("Group", |d, sel| {
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in &ids {
d.move_node(*id, Some(gid), usize::MAX)?;
}
let gid = group_nodes(d, &ids)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in ids {
d.move_node(*id, Some(gid), usize::MAX)?;
}
Ok(gid)
}
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Ungroup", |d, sel| {
+27 -1
View File
@@ -158,10 +158,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
fit(app, "view.fitArtboard");
}
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
handle_input(app, ui, &resp, rect);
// A press on the canvas while the context menu is open only closes it (a drag too, which egui
// alone would leave open); the tool doesn't get it.
if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
egui::Popup::close_all(ui.ctx());
} else {
handle_input(app, ui, &resp, rect);
}
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
panel_drop(app, ui, &resp, &xf);
context_menu(app, &resp, &xf);
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
@@ -821,6 +828,25 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
}
/// Right-click: the object under the pointer is selected first unless it already is, then the
/// context menu lists what applies to the selection ([`crate::menus::context_items`]).
fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) {
if resp.secondary_clicked()
&& let Some(p) = resp.interact_pointer_pos()
&& let Some(st) = app.session.active()
&& let Some(top) = hit_at(app, xf.to_doc(p), xf.zoom).map(|h| h.top_object(st.isolation))
&& !st.selection.contains(top)
{
// A locked or hidden object can't be selected; the menu is then for the selection as is.
let _ = app.run("select.set", json!({ "ids": [top.0] }));
}
let mut clicked = None;
resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked));
if let Some((id, p)) = clicked {
crate::menus::invoke(app, &id, p);
}
}
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
+2
View File
@@ -5,6 +5,7 @@
//! - `engine.commands`: engine + UI commands with enablement
//! - `document.inspect`: document summary; `ui.inspect`: UI state
//! - `ui.menu.list`: flattened menu tree
//! - `ui.contextMenu.list`: the canvas context menu for the current selection, flattened
//! - `ui.tool.select {tool}`, `ui.tool.list`
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
//! drive the active tool through the same path as the mouse
@@ -117,6 +118,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
"document.inspect" => wrap(app.run("document.inspect", json!({}))),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
"ui.tool.list" => ok(serde_json::to_value(vectorcraft_tools::TOOL_GROUPS).unwrap_or_default()),
"ui.pointer" => {
+2
View File
@@ -48,6 +48,8 @@ mod tests_background;
#[cfg(test)]
mod tests_clipboard;
#[cfg(test)]
mod tests_contextmenu;
#[cfg(test)]
mod tests_cut;
#[cfg(test)]
mod tests_distortkeys;
+181 -61
View File
@@ -1577,33 +1577,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
(
"Object",
vec![
sub(
"Transform",
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"),
],
),
sub(
"Arrange",
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"),
],
),
sub("Transform", transform_items()),
sub("Arrange", arrange_items()),
sub(
"Align",
vec![
@@ -2103,6 +2078,141 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
]
}
/// Object → Transform (also in the canvas context menu).
fn transform_items() -> Vec<Item> {
vec![
c("Transform Again", "object.transformAgain"),
Sep,
c("Move…", "object.move"),
c("Rotate…", "object.rotate"),
c("Reflect…", "object.reflect"),
c("Scale…", "object.scale"),
c("Shear…", "object.shear"),
Sep,
c("Transform Each…", "object.transformEach"),
Sep,
c("Reset Bounding Box", "object.resetBoundingBox"),
]
}
/// Object → Arrange (also in the canvas context menu).
fn arrange_items() -> Vec<Item> {
vec![
c("Bring to Front", "object.arrange.bringToFront"),
c("Bring Forward", "object.arrange.bringForward"),
c("Send Backward", "object.arrange.sendBackward"),
c("Send to Back", "object.arrange.sendToBack"),
Sep,
c("Send to Current Layer", "object.arrange.sendToCurrentLayer"),
]
}
/// The canvas context menu (right-click): what applies to the selection, or to the view when
/// nothing is selected. Commands that can't run now are left out rather than greyed out.
pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
use vectorcraft_doc::NodeKind;
let Some(st) = app.session.active() else { return vec![] };
let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect();
let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind));
let several = roots.len() >= 2;
let mut v = vec![c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep];
if st.isolation.is_some() {
v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]);
}
if roots.is_empty() {
v.extend([
c("Paste", "edit.paste"),
Sep,
c("Zoom In", "view.zoomIn"),
c("Zoom Out", "view.zoomOut"),
c("Fit Artboard in Window", "view.fitArtboard"),
Sep,
c("Show Rulers", "view.rulers"),
c("Show Grid", "view.grid"),
c("Hide Guides", "view.guides"),
c("Lock Guides", "view.guides.lock"),
Sep,
c("Select All", "select.all"),
]);
} else {
v.extend([c("Cut", "edit.cut"), c("Copy", "edit.copy"), c("Paste", "edit.paste"), Sep]);
if let [one] = roots.as_slice()
&& one.is_container()
{
v.push(c("Isolate Selected Group", "object.isolate"));
}
if several {
v.push(c("Group", "object.group"));
}
if any(|k| matches!(k, NodeKind::Group { clip: false, .. })) {
v.push(c("Ungroup", "object.ungroup"));
}
let paths = any(|k| matches!(k, NodeKind::Path { .. }));
if paths {
v.extend([c("Join", "path.join"), c("Average…", "path.average")]);
}
if several {
v.push(c("Make Clipping Mask", "object.clippingMask.make"));
}
if any(|k| matches!(k, NodeKind::Group { clip: true, .. })) {
v.push(c("Release Clipping Mask", "object.clippingMask.release"));
}
if several && paths {
v.push(c("Make Compound Path", "object.compoundPath.make"));
}
if any(|k| matches!(k, NodeKind::Compound { .. })) {
v.push(c("Release Compound Path", "object.compoundPath.release"));
}
v.extend([
c("Make Guides", "view.guides.make"),
Sep,
sub("Transform", transform_items()),
sub("Arrange", arrange_items()),
sub(
"Select",
vec![c("Next Object Above", "select.nextAbove"), c("Next Object Below", "select.nextBelow"), Sep, c("Deselect", "select.none")],
),
Sep,
c("Export Selection…", "file.exportSelection"),
]);
}
prune(app, v)
}
/// `items` without the commands that can't run now (Undo and Redo stay, greyed out), submenus
/// left empty, and separators that no longer separate anything.
fn prune(app: &VectorcraftApp, items: Vec<Item>) -> Vec<Item> {
let mut out: Vec<Item> = Vec::with_capacity(items.len());
for it in items {
let it = match it {
Item::Sub(l, children) => Item::Sub(l, prune(app, children)),
it => it,
};
let keep = match &it {
Item::Cmd(_, id, _) => matches!(*id, "edit.undo" | "edit.redo") || enabled(app, id),
Item::Sub(_, children) => !children.is_empty(),
Item::Sep => out.last().is_some_and(|l| !matches!(l, Item::Sep)),
_ => true,
};
if keep {
out.push(it);
}
}
if matches!(out.last(), Some(Item::Sep)) {
out.pop();
}
out
}
/// The canvas context menu's popup; the item clicked goes in `clicked`, for [`invoke`].
pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(200.0);
// Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter.
render_items(app, ui, &context_items(app), false, clicked);
});
}
/// Render the menu bar.
/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width.
pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
@@ -2133,11 +2243,13 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
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, clicked);
render_items(app, ui, items, true, clicked);
});
}
fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
/// `items` as menu buttons; with `checks`, items that can be on or off show a check mark (or the
/// room for one).
fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: bool, clicked: &mut Option<(String, Value)>) {
let t = Tokens::get(ui.ctx());
for it in items {
match it {
@@ -2151,7 +2263,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
ui.menu_button(app.ui.language.tr(label), |ui| {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(200.0);
render_items(app, ui, children, clicked);
render_items(app, ui, children, checks, clicked);
});
});
}
@@ -2171,7 +2283,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
let label = dynamic_label(app, id, label);
let label = app.ui.language.tr(&label).to_string();
let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default();
let chk = checked(app, id, p);
let chk = checks.then(|| checked(app, id, p)).flatten();
let text = match chk {
Some(true) => format!("✓ {label}"),
Some(false) => format!(" {label}"),
@@ -2387,42 +2499,50 @@ pub struct MenuEntry {
/// Flattened menu for `ui.menu.list`.
pub fn menu_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
fn walk(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
for it in items {
match it {
Item::Cmd(_, id, _) if (hidden_when_disabled(id) && !enabled(app, id)) || swapped_out(app, id) => {}
Item::Cmd(l, id, p) => out.push(MenuEntry {
path: path.clone(),
label: dynamic_label(app, id, l),
command: Some(id.to_string()),
params: p.clone(),
enabled: enabled(app, id),
shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(),
}),
Item::Todo(l, sc) => out.push(MenuEntry {
path: path.clone(),
label: l.to_string(),
command: None,
params: Value::Null,
enabled: false,
shortcut: sc.to_string(),
}),
Item::Sub(l, ch) => {
let mut p = path.clone();
p.push(l.to_string());
walk(app, p, ch, out);
}
_ => {}
}
}
}
let mut out = vec![];
for (title, items) in menu_tree() {
walk(app, vec![title.to_string()], &items, &mut out);
flatten(app, vec![title.to_string()], &items, &mut out);
}
out
}
/// Flattened canvas context menu for `ui.contextMenu.list` (paths are its submenus).
pub fn context_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
let mut out = vec![];
flatten(app, vec![], &context_items(app), &mut out);
out
}
fn flatten(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
for it in items {
match it {
Item::Cmd(_, id, _) if (hidden_when_disabled(id) && !enabled(app, id)) || swapped_out(app, id) => {}
Item::Cmd(l, id, p) => out.push(MenuEntry {
path: path.clone(),
label: dynamic_label(app, id, l),
command: Some(id.to_string()),
params: p.clone(),
enabled: enabled(app, id),
shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(),
}),
Item::Todo(l, sc) => out.push(MenuEntry {
path: path.clone(),
label: l.to_string(),
command: None,
params: Value::Null,
enabled: false,
shortcut: sc.to_string(),
}),
Item::Sub(l, ch) => {
let mut p = path.clone();
p.push(l.to_string());
flatten(app, p, ch, out);
}
_ => {}
}
}
}
/// Type → Size presets.
const TYPE_SIZES: [(&str, u32); 14] = [
("6 pt", 6),
+152
View File
@@ -0,0 +1,152 @@
//! The canvas context menu: right-click selects the object under the pointer and lists what
//! applies to the selection.
use egui::{Event, PointerButton, Pos2, Rect, pos2, vec2};
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_engine::Session;
use vectorcraft_geom::Point;
use crate::canvas::Xf;
use crate::menus::{self, Item};
use crate::{VectorcraftApp, canvas};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app
}
fn rect(app: &mut VectorcraftApp, x: f64, y: f64) -> NodeId {
NodeId(app.run("shape.rectangle", json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap())
}
/// One headless frame of the canvas on an 800 × 600 window → the text painted, with its rect.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui));
out.textures_delta.clear();
fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
_ => {}
}
}
let mut v = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
v
}
fn press(pos: Pos2, button: PointerButton, pressed: bool) -> Event {
Event::PointerButton { pos, button, pressed, modifiers: Default::default() }
}
/// Click `button` at `pos` (move, press, release, then a frame to show the result).
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, pos: Pos2, button: PointerButton) -> Vec<(String, Rect)> {
frame(app, ctx, vec![Event::PointerMoved(pos)]);
frame(app, ctx, vec![press(pos, button, true)]);
frame(app, ctx, vec![press(pos, button, false)]);
frame(app, ctx, vec![])
}
/// Where document point `p` is on screen (after a first frame laid the canvas out).
fn screen(app: &VectorcraftApp, x: f64, y: f64) -> Pos2 {
Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(x, y))
}
fn has(texts: &[(String, Rect)], label: &str) -> bool {
texts.iter().any(|(t, _)| t.trim_start_matches(['✓', ' ']) == label)
}
fn at(texts: &[(String, Rect)], label: &str) -> Pos2 {
texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}"))
}
#[test]
fn right_click_selects_the_object_and_lists_its_commands() {
let mut app = app();
let id = rect(&mut app, 50.0, 50.0);
app.run("select.none", json!({})).unwrap();
let ctx = egui::Context::default();
assert!(!has(&frame(&mut app, &ctx, vec![]), "Arrange"));
let p = screen(&app, 100.0, 100.0);
let texts = click(&mut app, &ctx, p, PointerButton::Secondary);
assert_eq!(app.session.active().unwrap().selection.objects, [id], "the object right-clicked is selected");
for label in ["Undo Rectangle", "Cut", "Copy", "Make Guides", "Transform", "Arrange", "Select", "Export Selection…"] {
assert!(has(&texts, label), "{label} in {texts:?}");
}
// Nothing that needs several objects, a group or nothing selected.
for label in ["Group", "Ungroup", "Make Clipping Mask", "Release Compound Path", "Select All", "Zoom In"] {
assert!(!has(&texts, label), "{label} in {texts:?}");
}
// Choosing an item runs its command and closes the menu.
let texts = click(&mut app, &ctx, at(&texts, "Make Guides"), PointerButton::Primary);
let n = app.session.active().unwrap().doc.node(id).cloned().unwrap();
assert!(matches!(n.kind, NodeKind::Path { guide: true, .. }));
assert!(!has(&texts, "Arrange"));
}
#[test]
fn right_click_on_empty_canvas_offers_the_view_and_a_dismissing_click_does_nothing() {
let mut app = app();
app.select_tool("rectangle");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let p = screen(&app, 300.0, 250.0);
let texts = click(&mut app, &ctx, p, PointerButton::Secondary);
for label in ["Zoom In", "Zoom Out", "Fit Artboard in Window", "Show Rulers", "Select All"] {
assert!(has(&texts, label), "{label} in {texts:?}");
}
assert!(!has(&texts, "Cut") && !has(&texts, "Arrange"));
// A click on the canvas away from the menu only closes it: the Rectangle tool draws nothing.
let away = pos2(100.0, 500.0);
frame(&mut app, &ctx, vec![Event::PointerMoved(away)]);
frame(&mut app, &ctx, vec![press(away, PointerButton::Primary, true)]);
frame(&mut app, &ctx, vec![Event::PointerMoved(away + vec2(40.0, 30.0))]);
frame(&mut app, &ctx, vec![press(away + vec2(40.0, 30.0), PointerButton::Primary, false)]);
let texts = frame(&mut app, &ctx, vec![]);
assert!(!has(&texts, "Zoom In"));
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
}
fn labels(items: &[Item]) -> Vec<&'static str> {
items
.iter()
.flat_map(|it| match it {
Item::Cmd(l, ..) => vec![*l],
Item::Sub(l, ch) => std::iter::once(*l).chain(labels(ch)).collect(),
_ => vec![],
})
.collect()
}
#[test]
fn the_menu_follows_the_selection() {
let mut app = app();
let (a, b) = (rect(&mut app, 0.0, 0.0), rect(&mut app, 50.0, 50.0));
app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap();
let l = labels(&menus::context_items(&app));
for label in ["Group", "Join", "Make Clipping Mask", "Make Compound Path", "Bring to Front", "Move…"] {
assert!(l.contains(&label), "{label} in {l:?}");
}
assert!(!l.contains(&"Ungroup") && !l.contains(&"Isolate Selected Group"));
app.run("object.group", json!({})).unwrap();
let l = labels(&menus::context_items(&app));
assert!(l.contains(&"Ungroup") && l.contains(&"Isolate Selected Group"));
assert!(!l.contains(&"Group") && !l.contains(&"Join"));
// Isolated: a way out.
menus::invoke(&mut app, "object.isolate", json!({}));
assert!(labels(&menus::context_items(&app)).contains(&"Exit Isolation Mode"));
// Agents see the same menu, with submenus as paths.
let entries = menus::context_entries(&app);
assert!(entries.iter().any(|e| e.command.as_deref() == Some("object.exitIsolation") && e.enabled));
app.run("select.set", json!({"ids": [a.0]})).unwrap();
let entries = menus::context_entries(&app);
let front = entries.iter().find(|e| e.command.as_deref() == Some("object.arrange.bringToFront")).unwrap();
assert_eq!(front.path, ["Arrange"]);
// No separator leads, trails or doubles up.
let items = menus::context_items(&app);
assert!(!matches!(items.first(), Some(Item::Sep)) && !matches!(items.last(), Some(Item::Sep)));
assert!(items.windows(2).all(|w| !matches!(w, [Item::Sep, Item::Sep])));
}
+1
View File
@@ -10,6 +10,7 @@
| `document.inspect` | | layer tree, selection, history, paint defaults |
| `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf, background saves and exports still running |
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
+1 -1
View File
@@ -67,7 +67,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |