diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index e7c963d02..2c527a820 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -59,7 +59,7 @@ pub fn specs() -> Vec { Ok(json!({ "fillActive": s.fill_active })) } ), - cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| { + cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None → {objects}", has_doc, |s, _| { let f = s.fill_active; set_paint(s, &json!({"none": true}), f) }), @@ -101,7 +101,7 @@ pub fn proxy_specs() -> Vec { "Apply Last Color", [], Some(","), - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)", has_doc, |s, p| { let paint = Paint::solid(s.last_solid); @@ -113,7 +113,7 @@ pub fn proxy_specs() -> Vec { "Apply Last Gradient", [], Some("."), - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)", has_doc, |s, p| { let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone()))); @@ -419,7 +419,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result /// Set the fill (or stroke) of the targets of `p` (the `item` and `ids` params, see /// `paint.setFill`) and of the defaults for new art, focus its proxy unless `focus` is false, and -/// remember the paint. +/// remember the paint. Replies `{objects}`: how many objects took the paint. fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result { let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; let focus = switch_or(p, "focus", true, cmd)?; @@ -452,7 +452,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result Result>().len()})) } /// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c346631c8..fccb1588c 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -258,7 +258,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if new_art && let Some(st) = s.new_art_stroke_mut() { set(st); } - ok() + Ok(json!({"objects": ids.iter().collect::>().len()})) } impl Session { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index eeeb15126..85926fb68 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -449,6 +449,63 @@ fn inspect_document_reports_the_paint_of_type() { assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}"); } +/// set_paint says how many objects it painted, and says so when it only set the paint for new art. +#[test] +fn set_paint_reports_how_many_objects_it_painted() { + let mut s = server(); + let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "fill": "#ffffff"})); + let id = serde_json::from_str::(&text_of(&r)).unwrap()["id"].clone(); + let fill = |s: &mut Server, n: u64| { + let v: Value = serde_json::from_str(&text_of(&call(s, n, "inspect_document", json!({})))).unwrap(); + v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap()["fill"].clone() + }; + let paint = |s: &mut Server, n: u64, args: Value| { + let r = call(s, n, "set_paint", args); + assert_eq!(r["isError"], false, "{r}"); + serde_json::from_str::(&text_of(&r)).unwrap() + }; + + // Nothing selected and no ids: only the paint for new art changes. + let r = call(&mut s, 3, "run_command", json!({"command": "select.set", "params": {"ids": []}})); + assert_eq!(r["isError"], false, "{r}"); + let v = paint(&mut s, 4, json!({"fill": "#ff0000"})); + assert_eq!(v["applied"], json!({"fill": "#ff0000"}), "{v}"); + assert_eq!(v["objects"], 0, "{v}"); + assert!(v["note"].as_str().is_some_and(|n| n.contains("new art")), "{v}"); + assert_eq!(fill(&mut s, 5), "#ffffff"); + + // The ids given, counted once each. + let v = paint(&mut s, 6, json!({"fill": "#00ff00", "ids": [id, id]})); + assert_eq!(v["objects"], 1, "{v}"); + assert!(v.get("note").is_none(), "{v}"); + assert_eq!(fill(&mut s, 7), "#00ff00"); + + // The selection. + let r = call(&mut s, 8, "run_command", json!({"command": "select.set", "params": {"ids": [id]}})); + assert_eq!(r["isError"], false, "{r}"); + let v = paint(&mut s, 9, json!({"fill": "#0000ff"})); + assert_eq!(v["objects"], 1, "{v}"); + assert!(v.get("note").is_none(), "{v}"); + assert_eq!(fill(&mut s, 10), "#0000ff"); + + // Empty `ids` paint no object: only the paint for new art changes. + let v = paint(&mut s, 11, json!({"strokeWidth": 3, "ids": []})); + assert_eq!(v["objects"], 0, "{v}"); + assert!(v["note"].as_str().is_some_and(|n| n.contains("new art")), "{v}"); + + // A selected group counts the objects inside it. + let r = call(&mut s, 12, "draw_shape", json!({"shape": "ellipse", "x": 20, "y": 0, "width": 10, "height": 10})); + assert_eq!(r["isError"], false, "{r}"); + for (n, command) in [(13, "select.all"), (14, "object.group")] { + let r = call(&mut s, n, "run_command", json!({"command": command})); + assert_eq!(r["isError"], false, "{r}"); + } + let v = paint(&mut s, 15, json!({"stroke": "#000000"})); + assert_eq!(v["objects"], 2, "{v}"); + let v = paint(&mut s, 16, json!({"strokeWidth": 2})); + assert_eq!(v["objects"], 2, "{v}"); +} + /// #1003: type made on or in a path takes add_text's `color` too, over the current fill. #[test] fn add_text_on_or_in_a_path_takes_its_color() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b2c459706..224d8f529 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -290,7 +290,7 @@ pub fn tool_definitions() -> Vec { tool( "set_paint", "Set paint", - "Set fill, stroke and/or stroke weight on the selection (or `ids`) and as the default for new art.", + "Set fill, stroke and/or stroke weight on the selection (or `ids`) and as the default for new art. Returns {applied, objects}: `objects` counts the objects painted, as paint.setFill, paint.setStroke and stroke.set report them (the largest of their counts); when it is 0, a `note` says that only the default for new art changed.", obj( json!({ "fill": paint_schema("Fill"), @@ -590,15 +590,20 @@ fn paint_steps(a: &Args) -> Result<(Vec>, Map), Str Ok((steps, applied)) } -fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option) -> Result { +/// Sets the paint `a` gives on `ids` (default: the selection) and as the default for new art. +/// Returns the paint applied and the most objects one of its edits painted (the `objects` of the +/// `paint.setFill`, `paint.setStroke` and `stroke.set` replies). +fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option) -> Result<(Value, u64), String> { let (steps, applied) = paint_steps(a)?; + let mut objects = 0; for (cmd, mut p) in steps { if let Some(ids) = &ids { p["ids"] = ids.clone(); } - exec(b, cmd, p)?; + let r = exec(b, cmd, p)?; + objects = objects.max(r.get("objects").and_then(Value::as_u64).unwrap_or(0)); } - Ok(Value::Object(applied)) + Ok((Value::Object(applied), objects)) } /// Checks `draw_path` / `draw_shape`'s `dash`, `cap`, `join`, `opacity`, `name` and `effects` @@ -1063,8 +1068,14 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { let mut p = json!({"key": req_str(a, "key")?}); diff --git a/docs/mcp.md b/docs/mcp.md index 6b325dd82..b17eac1ee 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -413,7 +413,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | -| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. | +| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee). | | `type_text` | `{text}` | Remote only. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |