Compare commits

..
Author SHA1 Message Date
bflatastic 0497c9e46d #1007: Reverse Gradient mirrors off-centre midpoints
Reversal read the midpoints after Gradient::reverse had reordered the stops, so each stop
took the wrong segment's midpoint: red-to-blue with its midpoint at 20% reversed to a 50%
midpoint instead of 80%, moving the transition.

Gradient::reverse now mirrors each segment's midpoint with it, so a reversed gradient is the
same ramp the other way round; paint.editGradient's own (wrong) pass is gone, and the SVG
importer's reflect spread, which had a correct copy of the same mirroring, uses it too.

Tests: two- and three-stop gradients with off-centre midpoints sample the same reversed (t vs
1 - t) and come back after two reversals; paint.editGradient reverse turns a 0.2 midpoint into
0.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:31 +07:00
bflatastic cacc9e4f36 #1006: Select > All on Active Artboard selects on the active artboard
The menu item sends no artboard, so the engine fell back to the first one even with another
active in the Artboards panel.

The frontend fills in the active artboard (the view's, as the Artboards panel and navigator
show it) for select.allOnArtboard, as it already did for Paste in Place / in Front / in Back
(#693); the two now share one block, and the index is kept within the artboards. An explicit
artboard still wins; the engine is unchanged.

The test makes artboard 2 active, then 1, and invokes the menu item each time, then passes
artboard 1 explicitly with artboard 1 active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:27 +07:00
bflatastic 33b6a6323d #1005: vectorcraft-cli run ends quietly when its reader closes early
`run` reported "Broken pipe" (on Windows "The pipe is being closed") and exited 1 when the
reader of its stdout went away, unlike `commands` and the other subcommands, which end quietly.

Its step printer now treats a closed pipe as the end of output, not an error: the remaining
steps still run (an export is still written), unprinted, and the run exits 0. Other write
errors and failing steps still fail.

The test runs document.json, a rectangle and an SVG export with stdout closed: status 0, empty
stderr, the SVG written; `run --cmd not-a-command` still fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:23 +07:00
bflatastic 148ef1870a #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
2026-10-11 03:51:19 +07:00
bflatastic 7f17d980d4 #1003: MCP add_text colours type made on or in a path
add_text passed `color` to text.create only, and text.createInPath built its own character
style without a colour, so type on a path and path-based area type took the current fill.

text.createInPath now uses the same new-type style as text.create (size, font, style, color,
else the current fill), and add_text sends `color` on both routes.

The MCP test draws a blue rectangle (the current fill), turns it into on-path and area type
with color #ff0000, and reads red back through inspect_document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:15 +07:00
14 changed files with 176 additions and 49 deletions

No files matched your search

+1 -1
View File
@@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| 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). |
| 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). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). |
| 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-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 |
+15 -1
View File
@@ -292,7 +292,21 @@ fn run(args: &[String]) -> Result<(), String> {
let mut h = Headless::new();
let mut out = std::io::stdout().lock();
let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string());
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
// steps (an export…) still run, unprinted.
let mut closed = false;
let mut emit = |v: Value| {
if closed {
return Ok(());
}
match writeln!(out, "{v}") {
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
closed = true;
Ok(())
}
r => r.map_err(|e| e.to_string()),
}
};
if let Some(path) = &input {
let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?;
emit(json!({"step": "open", "path": path, "result": r}))?;
+18
View File
@@ -297,3 +297,21 @@ fn closed_stdout_ends_quietly() {
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
}
/// #1005: `run` whose reader has gone (`vectorcraft-cli run … | true`) ends quietly with status 0,
/// and its later steps still run; a failing step still fails.
#[test]
fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
let svg = tmp("closed-run.svg");
let _ = std::fs::remove_file(&svg);
let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#;
let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()];
let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status);
assert!(svg.is_file(), "the export after the closed reader still ran");
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
assert!(!out.status.success());
}
+36 -2
View File
@@ -131,10 +131,18 @@ impl Gradient {
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
})
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
pub fn reverse(&mut self) {
self.stops.reverse();
for s in &mut self.stops {
s.offset = 1.0 - s.offset;
for i in 0..self.stops.len() {
// A midpoint belongs to the segment after its stop: reversed, stop i's segment is the
// one the next stop (not yet mirrored) had, measured from the other end.
let mid = self.stops.get(i + 1).map_or(0.5, |n| 1.0 - n.midpoint);
if let Some(s) = self.stops.get_mut(i) {
s.offset = 1.0 - s.offset;
s.midpoint = mid;
}
}
}
pub fn sort(&mut self) {
@@ -569,6 +577,32 @@ mod tests {
assert_eq!(g.stops[0].offset, 0.0);
}
/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same
/// the other way round.
#[test]
fn reverse_mirrors_midpoints() {
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] };
let three =
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] };
for g in [two, three] {
let mut r = g.clone();
r.reverse();
let mids: Vec<f32> = r.stops.iter().map(|s| s.midpoint).collect();
let want: Vec<f32> = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect();
assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}");
for i in 0..=20 {
let t = i as f32 / 20.0;
let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t));
let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1);
assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}");
}
r.reverse();
let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6);
assert!(back, "reversed twice, it is as it was: {:?}", r.stops);
}
}
fn close(a: Point, b: Point) -> bool {
a.distance(b) < 1e-9
}
+1 -6
View File
@@ -270,13 +270,8 @@ pub(crate) fn apply_gradient_edit_in(
gp.swatch = None;
}
if bool_or(p, "reverse", false) {
// Midpoints mirrored with their segments.
gp.gradient.reverse();
// Midpoints belong to the segment to the right; mirror them.
let n = gp.gradient.stops.len();
let mids: Vec<f32> = gp.gradient.stops.iter().map(|s| s.midpoint).collect();
for i in 0..n {
gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 };
}
}
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
let before = gp.geom;
+2 -11
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),
@@ -485,16 +485,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) {
return Err(bad(C, "area type needs a path that encloses an area"));
}
let mut style = CharStyle::default();
if let Some(v) = p.get("size").and_then(Value::as_f64) {
style.size = v.clamp(0.1, 1296.0);
}
if let Some(f) = str_param(p, "font") {
style.font_family = f.to_string();
}
if !s.paint.fill.is_none() && s.paint.fill != Paint::solid(vectorcraft_color::Color::WHITE) {
style.fill = s.paint.fill.clone();
}
let style = super::create::new_type_style(s, p);
let text = str_param(p, "text").unwrap_or("").to_string();
let start = match point_param(p, "at") {
Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0),
+7
View File
@@ -56,6 +56,13 @@ fn edit_gradient_angle_reverse_and_errors() {
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000");
// #1007: an off-centre midpoint is mirrored, not reset.
let stops = json!([{"offset": 0, "color": "#ff0000", "midpoint": 0.2}, {"offset": 1, "color": "#0000ff"}]);
s.execute("paint.editGradient", &json!({"stroke": false, "stops": stops})).unwrap();
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
assert_eq!(g.gradient.stops[0].color.to_hex(), "#0000ff");
assert!((g.gradient.stops[0].midpoint - 0.8).abs() < 1e-6, "{:?}", g.gradient.stops);
assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err());
assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err());
}
+16
View File
@@ -312,6 +312,22 @@ 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}");
}
/// #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() {
for mode in ["onPath", "area"] {
let mut s = server();
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 40, "y": 40, "width": 180, "height": 60, "fill": "#0000ff"}));
let path = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let r = call(&mut s, 3, "add_text", json!({"text": "Hello", "path": path, "mode": mode, "color": "#ff0000"}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let v: Value = serde_json::from_str(&text_of(&call(&mut s, 4, "inspect_document", json!({})))).unwrap();
let t = v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap();
assert_eq!(t["fill"].as_str(), Some("#ff0000"), "{mode}: {t}");
}
}
/// save_file says it saves in the document's own format or the one the path's extension picks.
#[test]
fn save_file_describes_the_formats_it_writes() {
+1 -4
View File
@@ -574,7 +574,7 @@ fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> {
fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?;
let mut style = json!({});
for k in ["size", "font"] {
for k in ["size", "font", "color"] {
if let Some(v) = a.get(k) {
style[k] = v.clone();
}
@@ -592,9 +592,6 @@ fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
for (k, v) in style.as_object().into_iter().flatten() {
p[k.as_str()] = v.clone();
}
if let Some(c) = a.get("color") {
p["color"] = c.clone();
}
if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) {
p["area"] = json!({"width": w, "height": h});
}
+4 -12
View File
@@ -2,7 +2,7 @@
//! repeated (every other period mirrored, for reflect) as many times as the painted area needs,
//! and the gradient stretched over those periods.
use vectorcraft_color::{GradientGeom, GradientKind, GradientStop};
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
use vectorcraft_geom::{Point, Rect};
/// Most periods a gradient is expanded to.
@@ -80,17 +80,9 @@ fn whole_period(stops: &[GradientStop]) -> Vec<GradientStop> {
/// A period mirrored: the stops in reverse, each midpoint measured from the other side.
fn mirror(period: &[GradientStop]) -> Vec<GradientStop> {
let rev: Vec<&GradientStop> = period.iter().rev().collect();
rev.iter()
.enumerate()
.map(|(i, s)| GradientStop {
offset: 1.0 - s.offset,
// The midpoint from this stop to the next one is the mirror of the one the next stop
// had towards this one.
midpoint: rev.get(i + 1).map_or(0.5, |next| 1.0 - next.midpoint),
..(*s).clone()
})
.collect()
let mut g = Gradient { kind: GradientKind::Linear, stops: period.to_vec() };
g.reverse();
g.stops
}
#[cfg(test)]
+44 -3
View File
@@ -16,8 +16,8 @@
//! - `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
//! comes back as `pngBase64` as well)
//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path,
//! or with `data: true`, as `pngBase64`)
//! - `ui.render {path?, scale?, artboard?, data?}`: render an artboard headlessly (`artboard`: its
//! 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`
//! (`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)
@@ -321,7 +321,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
"ui.render" => {
let Some(st) = app.session.active() else { return err("no document") };
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);
if let Err(e) = vectorcraft_render::raster_size(r, scale) {
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"}),
}
}
#[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());
}
}
+10 -8
View File
@@ -579,6 +579,16 @@ impl VectorcraftApp {
return r;
}
let mut params = params;
// Paste in place, in front, in back (#693) and Select › All on Active Artboard (#1006): onto
// or on the active artboard, the view's.
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack" | "select.allOnArtboard")
&& params.get("artboard").is_none()
&& self.view().is_some()
&& let Some(n) = self.session.active().map(|d| d.doc.artboards.len())
&& let Some(p) = params.as_object_mut()
{
p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n)));
}
if id.starts_with("edit.paste") {
if let Err(e) = self.adopt_system_clipboard() {
self.ui.status = e.clone();
@@ -592,14 +602,6 @@ impl VectorcraftApp {
{
p.insert("center".into(), serde_json::json!([c.x, c.y]));
}
// In place, in front, in back: onto the active artboard (#693).
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack")
&& params.get("artboard").is_none()
&& let Some(i) = self.view().map(|v| v.artboard)
&& let Some(p) = params.as_object_mut()
{
p.insert("artboard".into(), serde_json::json!(i));
}
if let Some(r) = dialogs::swatch_conflict::ask(self, id, &params) {
return r;
}
+20
View File
@@ -235,6 +235,26 @@ mod tests {
crate::menus::invoke(&mut app, "view.goToArtboard", json!({"index": "previous"}));
assert_eq!(selected(&app, 2), 0, "and the panel follows the navigator");
}
/// #1006: Select › All on Active Artboard selects the art of the artboard made active here,
/// and an explicit artboard still wins.
#[test]
fn all_on_active_artboard_takes_the_active_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap();
let mut rect = |x| app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let (a, b) = (rect(10), rect(130));
let picked = |app: &mut VectorcraftApp, params| {
crate::menus::invoke(app, "select.allOnArtboard", params);
let ids: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|n| n.0).collect();
json!(ids)
};
select(&mut app, 1);
assert_eq!(picked(&mut app, json!({})), json!([b]));
select(&mut app, 0);
assert_eq!(picked(&mut app, json!({})), json!([a]));
assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b]));
}
/// Double-clicking a row's number goes to its artboard and its name renames it; a row's
/// Options button edits that artboard with the Artboard tool, and the tool's artboard is the
/// active one.
+1 -1
View File
@@ -56,7 +56,7 @@ the two can still win that race.
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
| `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.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` | | |
| `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 |