Files
vectorcraft/crates/ui-egui/src/tests_contextmenu.rs
T
dotCooCoo 219253f163 Objects on Path: attach objects to a path and spread them along it
VectorCraft had no Objects on Path. A click with the Objects on Path
tool attaches the selected objects to the path clicked. The tool is in
Edit Toolbar's Modify section and the Blend tool's flyout, and Object ›
Objects on Path › Attach and the context menu with two or more objects
selected choose it. The objects and the path go into an Objects on
Path group with the path at its bottom.

- object.objectsOnPath.attach orders the objects column by column from
  left to right and top to bottom in each column. The first one sits
  on the path's first anchor, the last one sits on the end of an open
  path, and the rest are spread evenly by length; a closed path takes
  them all the way round. Each object's attachment point (Pivot, one
  of nine, default the center of its bounds without strokes) sits on
  the path, and the object is turned to the path there. An object that
  was rotated by an angle other than a multiple of 90° is first turned
  to the nearest multiple of 90°. Rotate tilts every object by that
  many degrees counter-clockwise. Symbol instances are measured by
  their art. Guides, compound paths, type, paths without length and
  graphs are refused with a reason.
- After every edit the objects go back on the path: editing the path
  moves them, an object moved on its own goes back, and objects moved
  into or out of the group in the Layers panel join the end of the
  path or leave it. Scaling, shearing or reflecting the whole group
  keeps each object's scale, shear or reflection, and the objects are
  spread evenly along the transformed path again. Undo takes the path
  and the objects back in one step, and a copied group stays live on
  its own path.
- object.objectsOnPath.options changes Pivot and Rotate, and
  object.objectsOnPath.expand leaves a plain group. Both act on the
  groups that `ids` names, or on the groups of the selection when
  `ids` is not given.
- The native file keeps the group live; SVG, PDF and the other exports
  write plain art. The tool highlights the path under the pointer, has
  a cursor entry, and Escape during a press cancels the click.

Part of #2545

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 21:51:32 +07:00

249 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The 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);
}
/// A right-click over a locked object adds an Unlock submenu that lists the object by its name.
/// Choosing the name unlocks the object in one undo step; once the menu is closed, the menu lists
/// no Unlock items.
#[test]
fn right_click_on_a_locked_object_offers_unlock_by_its_name() {
let mut app = app();
let id = rect(&mut app, 50.0, 50.0);
app.run("layer.setProps", json!({"ids": [id.0], "name": "Sky", "locked": true})).unwrap();
let steps = app.session.active().unwrap().history.undo.len();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let p = screen(&app, 100.0, 100.0);
let texts = click(&mut app, &ctx, p, PointerButton::Secondary);
assert!(has(&texts, "Unlock"), "{texts:?}");
// Agents see the item under the Unlock path, by the object's name.
let entries = menus::context_entries(&app);
let item = entries.iter().find(|e| e.path == ["Unlock"]).unwrap();
assert_eq!((item.label.as_str(), item.command.as_deref()), ("Sky", Some("layer.setProps")));
assert_eq!(item.params, json!({"ids": [id.0], "locked": false}));
// Hovering Unlock opens the submenu.
frame(&mut app, &ctx, vec![Event::PointerMoved(at(&texts, "Unlock"))]);
let texts = frame(&mut app, &ctx, vec![]);
let texts = click(&mut app, &ctx, at(&texts, "Sky"), PointerButton::Primary);
let st = app.session.active().unwrap();
assert!(!st.doc.node(id).unwrap().locked, "the object is unlocked");
assert_eq!(st.history.undo.len(), steps + 1, "in one undo step");
assert!(!has(&texts, "Unlock"), "the menu closed: {texts:?}");
// Locked again, it is not listed until the next right-click over it.
app.run("layer.setProps", json!({"ids": [id.0], "locked": true})).unwrap();
assert!(menus::context_entries(&app).iter().all(|e| e.path.first().map(String::as_str) != Some("Unlock")));
}
/// #528: right-clicking a ruler offers the document units, the current one checked, and choosing one
/// swaps them — the same effect as Preferences ▸ Units ▸ General (`document.setUnits`).
#[test]
fn right_clicking_a_ruler_swaps_the_document_units() {
use vectorcraft_doc::Unit;
let mut app = app();
app.ui.view.rulers = true;
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
assert_eq!(app.session.general_unit(), Unit::Points, "a new document starts in the prefs unit");
// Right-click on the top ruler (the band along the top, past the origin box).
let texts = click(&mut app, &ctx, pos2(400.0, 8.0), PointerButton::Secondary);
for label in ["Points", "Picas", "Inches", "Millimeters", "Feet & Inches", "Pixels"] {
assert!(has(&texts, label), "{label} in {texts:?}");
}
assert!(texts.iter().any(|(t, _)| t == "✓ Points"), "the current unit is checked: {texts:?}");
// Choosing a unit runs document.setUnits and closes the menu.
let mm = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Millimeters").map(|(_, r)| r.center()).expect("Millimeters");
let texts = click(&mut app, &ctx, mm, PointerButton::Primary);
assert_eq!(app.session.general_unit(), Unit::Millimeters, "the ruler's unit changed");
assert!(!has(&texts, "Inches"), "the menu closed");
// The left ruler offers the same menu, now checking the new unit.
let texts = click(&mut app, &ctx, pos2(8.0, 300.0), PointerButton::Secondary);
assert!(texts.iter().any(|(t, _)| t == "✓ Millimeters"), "the left ruler reflects the current unit: {texts:?}");
}
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", "Objects on Path", "Bring to Front", "Move…"] {
assert!(l.contains(&label), "{label} in {l:?}");
}
assert!(!l.contains(&"Ungroup") && !l.contains(&"Isolate Selected Group"));
// Objects on Path chooses its tool, which attaches the selection to the path clicked next.
menus::invoke(&mut app, "object.objectsOnPath.attach", json!({}));
assert_eq!(app.session.tool_id(), "objectsOnPath");
app.select_tool("selection");
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])));
}
/// Every label the context menu can show is a menu string, so complete catalogs must translate it.
#[test]
fn every_context_label_is_a_menu_string() {
let strings = menus::menu_strings();
let mut app = app();
let mut shown = vec![];
// Nothing selected, one path, two paths, a group, a clipping group, a compound path, isolation.
shown.extend(labels(&menus::context_items(&app)));
let (a, b) = (rect(&mut app, 0.0, 0.0), rect(&mut app, 50.0, 50.0));
shown.extend(labels(&menus::context_items(&app)));
app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap();
shown.extend(labels(&menus::context_items(&app)));
app.run("object.compoundPath.make", json!({})).unwrap();
shown.extend(labels(&menus::context_items(&app)));
app.run("object.compoundPath.release", json!({})).unwrap();
app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap();
app.run("object.clippingMask.make", json!({})).unwrap();
shown.extend(labels(&menus::context_items(&app)));
app.run("object.group", json!({})).unwrap();
shown.extend(labels(&menus::context_items(&app)));
menus::invoke(&mut app, "object.isolate", json!({}));
shown.extend(labels(&menus::context_items(&app)));
// The Unlock submenu that a right-click over a locked object adds.
menus::invoke(&mut app, "object.exitIsolation", json!({}));
app.run("layer.setProps", json!({"ids": [a.0], "locked": true})).unwrap();
app.ui.context_locked = Some((app.session.active().unwrap().uid, vec![a]));
shown.extend(labels(&menus::context_items(&app)));
let missing: Vec<_> = shown.iter().filter(|l| !strings.contains(**l)).collect();
assert!(missing.is_empty(), "context menu labels missing from menu_strings: {missing:?}");
for label in menus::CONTEXT_LABELS {
assert!(shown.contains(label), "`{label}` is listed but the context menu never shows it");
}
}