diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index b1b9efd60..f02d6f6aa 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -168,6 +168,17 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) } } +/// A path is selected, or inside a selected group or compound path: Object › Path's items that +/// work on paths (not on text, images or other objects alone). +pub fn has_selected_path(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + // Stops at the first path: menus ask this every frame, and a selected group can hold many. + fn holds_path(n: &vectorcraft_doc::Node) -> bool { + matches!(n.kind, vectorcraft_doc::NodeKind::Path { guide: false, .. }) || n.children().is_some_and(|c| c.iter().any(|c| holds_path(c))) + } + if st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).any(holds_path) { Ok(()) } else { Err("select a path".into()) } +} /// Objects or ruler guides are selected, or the Artboard tool is chosen (what Copy and Cut take). pub fn has_selection_guides_or_artboard_tool(s: &Session) -> std::result::Result<(), String> { if s.tool_id() == "artboard" { has_doc(s) } else { has_selection_or_guides(s) } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 2def5ce9e..27cba98b5 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -10,8 +10,8 @@ use super::create::anchor_from_json; use super::draw2::usize_opt; use super::pathops::Beside; use super::{ - CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selection, - id_param, ids_param, index, number_or, ok, str_param, switch_or, targets, + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path, + has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets, }; use crate::EngineError; @@ -77,7 +77,7 @@ pub fn specs() -> Vec { ["Object", "Path"], None, "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", - has_doc, + has_selected_path, reverse ), cmd!( @@ -86,7 +86,7 @@ pub fn specs() -> Vec { ["Object", "Path"], Some("Cmd+J"), "{ids?: [path, ...] (default: the selected paths; a compound path ID expands to its open member paths), subpaths?: [usize, usize] (which subpaths to join), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, for two paths/subpaths; default without ids: direct-selected ends if any, else the nearest pair; default with ids: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two or more open paths/subpaths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead", - has_selection, + has_selected_path, join ), cmd!( @@ -95,7 +95,7 @@ pub fn specs() -> Vec { ["Object", "Path"], Some("Cmd+Alt+J"), "{axis?: \"horizontal\"|\"vertical\"|\"both\"}", - has_selection, + has_selected_path, average ), cmd!( @@ -104,7 +104,7 @@ pub fn specs() -> Vec { ["Object", "Path"], Some("Cmd+Alt+Shift+J"), "{to: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours), ids?: [path, ...] (default: the selection; a compound path ID expands to its open member paths)} move two open path ends to their midpoint and join them there, as one undo step: the two direct-selected anchors when they are open path ends, else the nearest ends of the two open paths in ids or the selection (or both ends of one open path, closing it) → the joined path stays selected", - has_selection, + has_selected_path, average_join ), cmd!( diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 1a3305640..5fd0f5443 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -15,8 +15,8 @@ use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selection, number_or, str_param, - switch_or, + CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection, + number_or, str_param, switch_or, }; pub fn specs() -> Vec { @@ -153,7 +153,7 @@ pub fn specs() -> Vec { ["Object", "Path"], None, "{tolerance?: pt (1), level?: 0..100 (the Simplify slider, in place of tolerance: 0 fewest anchor points, 50 is 1 pt, every 10 levels halves or doubles it), auto?: bool (Auto-Simplify: the lowest level whose tolerance is at most 1/400 of the selection's longer side, or 100 when that side is under 12.5 pt), cornerAngle?: deg (30), straightLines?: bool, wholePaths?: bool (false: a path with segments direct-selected, or between direct-selected anchors, simplifies only each run of them between its end anchors, which stay as they are, and keeps the runs selected; true simplifies those paths whole)} → {ids, before, after} anchor counts, and the level used with level or auto", - has_selection, + has_selected_path, simplify ), cmd!( @@ -162,17 +162,17 @@ pub fn specs() -> Vec { ["Object", "Path"], None, "{level?: 0..100 (50; the Smooth slider: 0 minimum smoothing, the path staying within 0.1% of its longer side, 100 maximum: smoothed along its length by up to 5% of it and refitted with curves within up to 2.5% of it, corners kept only where the path turns back, never with more anchor points), auto?: bool (Auto-Smooth: level 50), ids?: [id…] (default: the selection)} smooth each path under the objects, compound path members included, as one undo step; without ids, a path with anchor points or segments direct-selected smooths only the sections between them, which stay selected → {ids, before, after} anchor counts, and the level used", - has_selection, + has_selected_path, smooth ), - cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selection, add_anchor_points), + cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selected_path, add_anchor_points), cmd!( "object.path.divideObjectsBelow", "Divide Objects Below", ["Object", "Path"], None, "{} cut the objects below with the selected path (which is removed) → {ids}", - has_selection, + has_selected_path, divide_objects_below ), cmd!( @@ -181,7 +181,7 @@ pub fn specs() -> Vec { ["Object", "Path"], None, "{rows?: 2, columns?: 2, gutter?: pt (12; rowGutter?, columnGutter?: one of the two), totalHeight?, totalWidth?: pt (the grid's size; the selected paths' when left out), height?, width?: pt (each row's and column's size, which then sets the total), addGuides?: false (ruler guides along the row and column edges)} one grid of rectangles over the selected paths replaces them, painted as the topmost one → {ids, guides: count}", - has_selection, + has_selected_path, split_into_grid ), cmd!( diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9ec3ccaf9..812a6282f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -109,6 +109,7 @@ mod paintproxy; mod panelcmds; mod paragraphs; mod params; +mod path_menu_enabled; mod pathops; mod pathtype; mod pattern; diff --git a/crates/engine/src/tests/path_menu_enabled.rs b/crates/engine/src/tests/path_menu_enabled.rs new file mode 100644 index 000000000..2b9eb44aa --- /dev/null +++ b/crates/engine/src/tests/path_menu_enabled.rs @@ -0,0 +1,35 @@ +//! Object → Path items that work on paths are disabled while only non-path objects are selected. + +use serde_json::json; + +use crate::*; + +const PATH_ITEMS: [&str; 9] = [ + "path.reverse", + "path.join", + "path.average", + "path.averageJoin", + "object.path.simplify", + "object.path.smooth", + "object.path.addAnchorPoints", + "object.path.divideObjectsBelow", + "object.path.splitIntoGrid", +]; + +fn enabled(s: &Session, id: &str) -> bool { + command_specs().iter().find(|c| c.id == id).is_some_and(|c| (c.enabled)(s).is_ok()) +} + +#[test] +fn path_items_need_a_path_selected() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap(); + for id in PATH_ITEMS { + assert!(!enabled(&s, id), "{id} enabled for text"); + } + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + for id in PATH_ITEMS { + assert!(enabled(&s, id), "{id} disabled for a path"); + } +}