#1004: a connected app's screenshot renders the artboard asked for
The desktop app's ui.render (MCP `screenshot` when connected) always rendered the first artboard and ignored `artboard`, which the headless backend honours. It now renders the 0-based `artboard` (the first without one, an error for one that doesn't exist), as headless does. control-protocol.md and the control.rs list show the parameter. The test drives ui.render through the control channel on a 64 x 64 and a 96 x 48 artboard: no index and 0 give 64 x 64, 1 gives 96 x 48 (48 x 24 at scale 0.5), 2 is an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # ROADMAP.md
This commit is contained in:
parent
7f17d980d4
commit
148ef1870a
|
|
@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||||
|
|
||||||
| Date | What landed |
|
| Date | What landed |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). |
|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). |
|
||||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@
|
||||||
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
|
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
|
||||||
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG
|
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG
|
||||||
//! comes back as `pngBase64` as well)
|
//! comes back as `pngBase64` as well)
|
||||||
//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path,
|
//! - `ui.render {path?, scale?, artboard?, data?}`: render an artboard headlessly (`artboard`: its
|
||||||
//! or with `data: true`, as `pngBase64`)
|
//! 0-based index, default the first; PNG; without a path, or with `data: true`, as `pngBase64`)
|
||||||
//! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit`
|
//! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit`
|
||||||
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
|
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
|
||||||
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
|
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
|
||||||
|
|
@ -321,7 +321,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||||
"ui.render" => {
|
"ui.render" => {
|
||||||
let Some(st) = app.session.active() else { return err("no document") };
|
let Some(st) = app.session.active() else { return err("no document") };
|
||||||
let doc = st.doc.clone();
|
let doc = st.doc.clone();
|
||||||
let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no artboard") };
|
// The artboard asked for (0-based, as headless), else the first.
|
||||||
|
let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok());
|
||||||
|
let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") };
|
||||||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
|
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
|
||||||
if let Err(e) = vectorcraft_render::raster_size(r, scale) {
|
if let Err(e) = vectorcraft_render::raster_size(r, scale) {
|
||||||
return err(e);
|
return err(e);
|
||||||
|
|
@ -390,3 +392,42 @@ pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path:
|
||||||
None => json!({"ok": false, "error": "no writer configured"}),
|
None => json!({"ok": false, "error": "no writer configured"}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use vectorcraft_engine::Session;
|
||||||
|
|
||||||
|
use super::ControlRequest;
|
||||||
|
use crate::{Services, VectorcraftApp};
|
||||||
|
|
||||||
|
/// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked
|
||||||
|
/// for, at the scale asked for; the first without one.
|
||||||
|
#[test]
|
||||||
|
fn render_takes_the_artboard_asked_for() {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
|
||||||
|
let mut request = |method: &str, params: Value| {
|
||||||
|
let (req, reply) = ControlRequest::new(method, params);
|
||||||
|
tx.send(req).unwrap();
|
||||||
|
app.drain_control(&egui::Context::default());
|
||||||
|
reply.try_recv().unwrap()
|
||||||
|
};
|
||||||
|
let new = json!({"width": 64, "height": 64, "units": "Points", "artboards": 2});
|
||||||
|
request("engine.execute", json!({"command": "file.new", "params": new}));
|
||||||
|
let board = json!({"index": 1, "x": 100, "y": 0, "width": 96, "height": 48});
|
||||||
|
let r = request("engine.execute", json!({"command": "artboard.setProps", "params": board}));
|
||||||
|
assert_eq!(r["ok"], true, "{r}");
|
||||||
|
for (params, size) in [
|
||||||
|
(json!({}), [64, 64]),
|
||||||
|
(json!({"artboard": 0}), [64, 64]),
|
||||||
|
(json!({"artboard": 1}), [96, 48]),
|
||||||
|
(json!({"artboard": 1, "scale": 0.5}), [48, 24]),
|
||||||
|
] {
|
||||||
|
let r = request("ui.render", params.clone());
|
||||||
|
let got = [&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64);
|
||||||
|
assert_eq!(got, size.map(Some), "{params}: {r}");
|
||||||
|
}
|
||||||
|
assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ the two can still win that race.
|
||||||
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
|
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
|
||||||
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
|
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
|
||||||
| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
||||||
| `ui.render` | `{path?, scale?, data?}` | render the artboard headlessly (PNG): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
|
| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
|
||||||
| `ui.resize` / `ui.focus` | | |
|
| `ui.resize` / `ui.focus` | | |
|
||||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||||
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
|
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue