Files
Fahri Meral 202894372a variables: fix binding lifetime, dataset editing and the missing UI to define
Review of #761 turned up three real defects and a feature that was visible
but not usable. All of it is fixed here, each defect with a regression test
that fails without the fix.

- A binding outlived the object it named. Deleting art left its entry in
  `variables.bindings` forever: it counted as a skip in every dataset
  application, `variable.list` counted it, and the saved file grew with every
  delete. `Document::prune_variable_bindings` now lets them go after each
  edit, as `prune_assets` does for Asset Export's.
- `Variables::prune` cleared `active_dataset` by comparing it against the
  *variable* names being deleted. A dataset named like a variable lost its
  active state when the variable went. The two are separate namespaces; the
  block is gone (dataset.delete already did it right).
- `variable.delete` reported the number of names asked for, not the number
  deleted, so deleting a name that was not there still said `deleted: 1`.

- `dataset.set` (new): a dataset's values could only be set when it was
  created, so changing one meant deleting and rebuilding the row — losing its
  place in the list and the active one. This replaces the row's values and
  reports what it dropped.
- Window › Variables, and dialogs for the "…" commands. Every command had an
  empty menu path and the command palette runs those with `{}`, so nothing
  could be defined from the UI at all. The commands now carry their menu
  path, the "…" ones open a Variables dialog (a name and a kind; a name and a
  field per variable, its kind's editor), and `dataset.set` opens with the
  row's own values.
- Locked art is neither bound nor rewritten by a dataset, and says so.
- `set_plain_text` told a node that existed but was not type that it was
  missing.
- The panel had Document Info's icon, so the two were indistinguishable in
  the collapsed column; it gets its own, plus New/Delete buttons, and its
  rows say what their count is.

Format round trips now cover variables (the testkit fixture defines both
kinds, binds them and applies a row), and ROADMAP/README no longer list the
feature as missing.

cargo xtask ci: all 7 steps pass.
2026-10-10 23:34:21 +07:00

256 lines
11 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sessions and documents in known states.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
use vectorcraft_engine::Session;
use vectorcraft_geom::{PathData, Rect, shapes};
/// Execute a command and panic with context on error.
#[track_caller]
pub fn exec(s: &mut Session, id: &str, params: Value) -> Value {
match s.execute(id, &params) {
Ok(v) => v,
Err(e) => panic!("`{id}` {params} failed: {e}"),
}
}
/// The `id` field of a creation command's result.
#[track_caller]
pub fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}")))
}
/// The created date (Unix seconds) of every fixture document: File Info dates come from the clock
/// otherwise, and documents made a second apart would not compare equal.
pub const CREATED: i64 = 1_767_225_600;
/// A session with one new `w`×`h` document, dated [`CREATED`].
pub fn session_with(w: f64, h: f64) -> Session {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"width": w, "height": h, "created": CREATED}));
s
}
/// A session with one new 400×300 document.
pub fn session() -> Session {
session_with(400.0, 300.0)
}
pub fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&exec(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})))
}
pub fn ellipse(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&exec(s, "shape.ellipse", json!({"x": x, "y": y, "width": w, "height": h})))
}
pub fn star(s: &mut Session, cx: f64, cy: f64, r1: f64, r2: f64) -> NodeId {
id_of(&exec(s, "shape.star", json!({"cx": cx, "cy": cy, "radius1": r1, "radius2": r2, "points": 5})))
}
pub fn select(s: &mut Session, ids: &[NodeId]) {
exec(s, "select.set", json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}));
}
/// The three standard fixture states for sweeping commands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fixture {
/// A new document with nothing in it.
Empty,
/// One rectangle, selected.
Single,
/// Several overlapping objects of different kinds (paths, live shapes, text, a group), three selected.
Multi,
}
impl Fixture {
pub const ALL: [Fixture; 3] = [Fixture::Empty, Fixture::Single, Fixture::Multi];
pub fn session(self) -> Session {
let mut s = session();
match self {
Fixture::Empty => {}
Fixture::Single => {
rect(&mut s, 50.0, 50.0, 100.0, 60.0);
}
Fixture::Multi => {
let a = rect(&mut s, 40.0, 40.0, 120.0, 80.0);
let b = ellipse(&mut s, 100.0, 60.0, 90.0, 90.0);
let c = star(&mut s, 200.0, 150.0, 50.0, 25.0);
let t = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 250, "text": "Hi", "size": 24})));
let p = id_of(&exec(
&mut s,
"path.create",
json!({"anchors": [{"x": 250, "y": 30}, {"x": 330, "y": 60, "in": [300, 20], "out": [360, 100]}, {"x": 300, "y": 140}], "closed": false}),
));
let g1 = rect(&mut s, 300.0, 200.0, 40.0, 40.0);
let g2 = ellipse(&mut s, 320.0, 220.0, 40.0, 40.0);
select(&mut s, &[g1, g2]);
exec(&mut s, "object.group", json!({}));
let _ = (t, p);
select(&mut s, &[a, b, c]);
}
}
s
}
}
/// A document touching most node kinds and appearance features, built through commands (so its
/// history is non-trivial). Selection is left empty.
pub fn rich_session() -> Session {
let mut s = session_with(500.0, 400.0);
let a = rect(&mut s, 20.0, 20.0, 150.0, 100.0);
exec(
&mut s,
"paint.setFill",
json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "angle": 30}}),
);
let b = ellipse(&mut s, 100.0, 60.0, 120.0, 120.0);
exec(&mut s, "transparency.set", json!({"opacity": 60, "blend": "Multiply"}));
exec(&mut s, "stroke.set", json!({"weight": 4, "dash": [6, 3], "cap": "round"}));
let c = star(&mut s, 300.0, 100.0, 60.0, 30.0);
exec(&mut s, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 3, "detail": 5, "seed": 7}}));
let l = id_of(&exec(&mut s, "shape.line", json!({"x1": 20, "y1": 300, "x2": 200, "y2": 350})));
exec(&mut s, "stroke.set", json!({"weight": 3, "endArrow": "Arrow"}));
let tx = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "VectorCraft", "size": 30})));
// A non-default paragraph composer, so format round trips cover it.
exec(&mut s, "text.setFormat", json!({"ids": [tx.0], "composer": "singleLine"}));
// Area type with non-default Area Type Options (vertical alignment, inset).
let area = id_of(&exec(
&mut s,
"text.create",
json!({"x": 250, "y": 340, "text": "Centred area type", "size": 12, "area": {"width": 120, "height": 50}}),
));
select(&mut s, &[area]);
exec(&mut s, "text.areaOptions", json!({"verticalAlign": "center", "inset": 2}));
let _ = id_of(&exec(
&mut s,
"text.create",
json!({"x": 300, "y": 330, "size": 14, "text": "Area type shrinks its text to fit the frame.",
"area": {"width": 150, "height": 40, "fit": "shrinkText", "fitMinPercent": 40}}),
));
// Area type whose paragraphs differ (alignment, spacing, tabs).
let para = id_of(&exec(
&mut s,
"text.create",
json!({"x": 20, "y": 360, "text": "Heading\nBody one\tx\nBody two", "size": 9, "area": {"width": 160, "height": 36}}),
));
exec(&mut s, "text.setStyle", json!({"id": para, "justify": "center", "start": 0, "end": 0}));
exec(&mut s, "text.setFormat", json!({"ids": [para], "spaceBefore": 3, "firstLineIndent": 6, "start": 9, "end": 9}));
exec(&mut s, "text.tabs.set", json!({"ids": [para], "stops": [{"position": 80, "align": "right"}], "start": 9, "end": 9}));
exec(&mut s, "layer.new", json!({"name": "Top"}));
let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0);
let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0);
select(&mut s, &[d, e]);
exec(&mut s, "object.compoundPath.make", json!({}));
let clip = ellipse(&mut s, 30.0, 150.0, 80.0, 80.0);
let inner = rect(&mut s, 10.0, 170.0, 140.0, 30.0);
exec(&mut s, "paint.setFill", json!({"color": "#22aa44"}));
select(&mut s, &[inner, clip]);
exec(&mut s, "object.clippingMask.make", json!({}));
// Type with an inline graphic: a symbol (a small circle, drawn here) set in a line of text.
let dot = ellipse(&mut s, 440.0, 40.0, 12.0, 12.0);
select(&mut s, &[dot]);
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
let t = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 370, "text": "Tap to add", "size": 18})));
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 4, "symbol": "Dot"}));
// Variables (data merge): both kinds, bound to art, with two dataset rows and one applied,
// so format round trips cover the definitions, the bindings and the rows.
exec(&mut s, "variable.define", json!({"name": "Title", "kind": "text"}));
exec(&mut s, "variable.define", json!({"name": "Badge", "kind": "visibility"}));
exec(&mut s, "variable.bind", json!({"variable": "Title", "ids": [tx.0]}));
exec(&mut s, "variable.bind", json!({"variable": "Badge", "ids": [a]}));
exec(&mut s, "dataset.new", json!({"name": "Row 1", "values": {"Title": "Merged", "Badge": true}}));
exec(&mut s, "dataset.new", json!({"name": "Row 2", "values": {"Title": "Other"}}));
exec(&mut s, "dataset.select", json!({"name": "Row 1"}));
let _ = (a, b, c, l);
exec(&mut s, "select.none", json!({}));
s
}
/// Build documents directly (without the engine), e.g. for render tests.
pub struct DocBuilder {
pub doc: Document,
pub layer: NodeId,
}
impl DocBuilder {
pub fn new(w: f64, h: f64) -> Self {
let doc = Document::new(w, h);
let layer = doc.layers[0].id;
Self { doc, layer }
}
pub fn alloc(&mut self) -> NodeId {
self.doc.alloc_id()
}
/// A path node (not inserted).
pub fn path_node(&mut self, path: PathData, appearance: Appearance) -> Node {
let id = self.alloc();
Node::path(id, path, appearance)
}
/// A filled, unstroked rectangle node (not inserted).
pub fn rect_node(&mut self, r: Rect, fill: Color) -> Node {
self.path_node(shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0))
}
/// Append `node` at the top of the current layer.
pub fn add(&mut self, node: Node) -> NodeId {
let l = self.layer;
self.doc.insert(Some(l), usize::MAX, node).expect("insert")
}
/// Add a filled rectangle; `f` may tweak the node first.
pub fn rect(&mut self, r: Rect, fill: Color, f: impl FnOnce(&mut Node)) -> NodeId {
let mut n = self.rect_node(r, fill);
f(&mut n);
self.add(n)
}
/// Add any path with an appearance; `f` may tweak the node first.
pub fn path(&mut self, path: PathData, appearance: Appearance, f: impl FnOnce(&mut Node)) -> NodeId {
let mut n = self.path_node(path, appearance);
f(&mut n);
self.add(n)
}
/// Add a group of `children` (bottom first). With `clip`, the first child is the clipping path.
pub fn group(&mut self, mut children: Vec<Node>, clip: bool) -> NodeId {
if clip && let Some(NodeKind::Path { clipping, .. }) = children.first_mut().map(|n| &mut n.kind) {
*clipping = true;
}
let id = self.alloc();
let mut g = Node::group(id, children.into_iter().map(Arc::new).collect());
if let NodeKind::Group { clip: c, .. } = &mut g.kind {
*c = clip;
}
self.add(g)
}
/// Add a new layer and make it current.
pub fn layer(&mut self, name: &str) -> NodeId {
self.layer = self.doc.add_layer(Some(name));
self.layer
}
pub fn build(self) -> Document {
self.doc
}
}
/// All non-layer nodes of a document in paint order.
pub fn art_nodes(doc: &Document) -> Vec<&Node> {
let mut v = Vec::new();
doc.walk(|n| {
if !n.is_layer() {
v.push(n)
}
});
v
}
/// Ids of the top-level art (direct children of layers), bottom first.
pub fn top_level_ids(doc: &Document) -> Vec<NodeId> {
doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id)).collect()
}
/// Every node id (layers included), paint order.
pub fn all_ids(doc: &Document) -> Vec<NodeId> {
let mut v = Vec::new();
doc.walk(|n| v.push(n.id));
v
}