Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdb734aede | ||
|
|
2f743f155f |
No files matched your search
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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])));
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user