From 2e134e56bef5d74c05c0693befcf9e77065a644d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 11:48:54 +0700 Subject: [PATCH] Fix #256: inspect_document reports the paint type's characters show A type node's fill, stroke and strokeWidth now come from its first run, as paint.proxies and its strokeOptions already did; fills and strokes of the type object itself are reported apart as objectFill, objectStroke and objectStrokeWidth. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/inspect.rs | 16 +++++++++++++++- crates/engine/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ crates/mcp/src/tests.rs | 19 +++++++++++++++++++ docs/mcp.md | 2 +- 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index f7ebd27f..55713279 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -29,7 +29,21 @@ pub fn node_summary(n: &Node) -> Value { if n.bbox_angle != 0.0 { v["rotation"] = json!(n.bbox_angle); } - if !n.is_container() { + if let NodeKind::Text(t) = &n.kind { + // Type shows its characters' paint: the first run's, as paint.proxies and the Stroke + // options read it. Fills and strokes of the type object itself, above or below its + // characters, come separately so the two stay apart. + let default = vectorcraft_doc::CharStyle::default(); + let style = t.runs.first().map_or(&default, |r| &r.style); + v["fill"] = json!(style.fill.label()); + v["stroke"] = json!(style.stroke.label()); + v["strokeWidth"] = json!(if style.stroke.is_none() { 0.0 } else { style.stroke_width }); + if n.appearance.fill().is_some() || n.appearance.stroke().is_some() { + v["objectFill"] = json!(n.appearance.fill_paint().label()); + v["objectStroke"] = json!(n.appearance.stroke_paint().label()); + v["objectStrokeWidth"] = json!(n.appearance.stroke_width()); + } + } else if !n.is_container() { v["fill"] = json!(n.appearance.fill_paint().label()); v["stroke"] = json!(n.appearance.stroke_paint().label()); v["strokeWidth"] = json!(n.appearance.stroke_width()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 92a69775..7dbe71c1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -285,6 +285,39 @@ fn inspect_lists_everything() { assert!(s.commands().len() > 100); } +/// Type reports the paint its characters show, and its own object-level paint apart from it. +#[test] +fn inspect_reports_the_paint_of_types_characters() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 50, "y": 60, "text": "HI", "size": 24})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id]})).unwrap(); + let node = |s: &mut Session| { + let v = s.execute("document.inspect", &json!({})).unwrap(); + v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap() + }; + let t = node(&mut s); + assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#ff0000"), Some("None"), Some(0.0)), "{t}"); + assert!(t.get("objectFill").is_none(), "no object-level paint: {t}"); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id]})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [id]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 3, "ids": [id]})).unwrap(); + let t = node(&mut s); + assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}"); + // An object-level fill on the type stays distinguishable from its characters' paint. + s.execute("appearance.addFill", &json!({"ids": [id]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ffff00", "item": 0, "ids": [id]})).unwrap(); + let t = node(&mut s); + assert_eq!(t["objectFill"], "#ffff00", "{t}"); + assert_eq!(t["fill"], "#0000ff", "{t}"); + // A path keeps reporting its own appearance. + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r]})).unwrap(); + let v = s.execute("document.inspect", &json!({})).unwrap(); + let rect = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == r).cloned().unwrap(); + assert_eq!(rect["fill"], "#ff0000"); + assert!(rect.get("objectFill").is_none()); +} + #[test] fn every_command_has_unique_id_and_doc() { let mut ids: Vec<&str> = command_specs().iter().map(|c| c.id).collect(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 5037ba9a..0cec1c75 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -284,6 +284,25 @@ fn headless_path_gesture_undo() { assert_eq!(&std::fs::read(tmp("x.png")).unwrap()[..4], b"\x89PNG"); } +/// inspect_document reads back the paint type's characters show, as add_text and set_paint give it. +#[test] +fn inspect_document_reports_the_paint_of_type() { + let mut s = server(); + let r = call(&mut s, 2, "add_text", json!({"text": "HI", "x": 50, "y": 60, "size": 24, "color": "#ff0000"})); + assert_eq!(r["isError"], false, "{r}"); + let id = serde_json::from_str::(&text_of(&r)).unwrap()["id"].clone(); + let node = |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).cloned().unwrap() + }; + let t = node(&mut s, 3); + assert_eq!((t["fill"].as_str(), t["stroke"].as_str()), (Some("#ff0000"), Some("None")), "{t}"); + let r = call(&mut s, 4, "set_paint", json!({"fill": "#0000ff", "stroke": "#00ff00", "strokeWidth": 3, "ids": [id]})); + assert_eq!(r["isError"], false, "{r}"); + let t = node(&mut s, 5); + assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}"); +} + #[test] fn errors_are_tool_results_not_crashes() { let mut s = server(); diff --git a/docs/mcp.md b/docs/mcp.md index 1e6c0b1f..dd64f6ef 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -147,7 +147,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr |---|---|---| | `list_commands` | `{filter?, enabledOnly?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement. | | `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | -| `inspect_document` | `{}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. | +| `inspect_document` | `{}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | | `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. |