Merge main into #1636

This commit is contained in:
bflatastic
2026-10-30 15:06:33 +07:00
57 changed files with 2357 additions and 279 deletions
+5 -2
View File
File diff suppressed because one or more lines are too long
+12 -8
View File
@@ -103,10 +103,12 @@ impl Expr {
Expr::Value(VariableValue::Alias(id)) => return vars.resolve(id, modes),
Expr::Value(VariableValue::Number(n)) => return n.is_finite().then_some(VariableValue::Number(*n)),
Expr::Value(v) => return Some(v.clone()),
Expr::Mode { collection } => return vars.collection(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())),
Expr::Mode { collection } => {
return vars.collection_by_id(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone()));
}
Expr::InMode { variable, mode } => {
let v = vars.get(variable)?;
let c = vars.collection(&v.collection)?;
let v = vars.get_by_id(variable)?;
let c = vars.collection_by_id(&v.collection)?;
let mut at = modes.clone();
at.insert(c.id.clone(), c.find_mode(mode)?.id.clone());
return vars.resolve(&v.id, &at);
@@ -143,20 +145,22 @@ impl Expr {
return "…".into();
}
match self {
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
Expr::Value(VariableValue::Alias(id)) => vars.get_by_id(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
Expr::Value(VariableValue::String(s)) => quote(s),
// Exact numbers and `#` colours, so the text reads back as the same expression.
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
Expr::Value(VariableValue::Color(c)) => c.to_css_hex(),
Expr::Value(v) => v.display(),
Expr::Mode { collection } => {
let name = vars.collection(collection).map_or("(missing collection)", |c| c.name.as_str());
let name = vars.collection_by_id(collection).map_or("(missing collection)", |c| c.name.as_str());
format!("mode({})", name_arg(name))
}
Expr::InMode { variable, mode } => {
let var = vars.get(variable);
let mode =
var.and_then(|v| vars.collection(&v.collection)).and_then(|c| c.find_mode(mode)).map_or("(missing mode)", |m| m.name.as_str());
let var = vars.get_by_id(variable);
let mode = var
.and_then(|v| vars.collection_by_id(&v.collection))
.and_then(|c| c.find_mode(mode))
.map_or("(missing mode)", |m| m.name.as_str());
format!("{}[{}]", Expr::var(variable.clone()).display_at(vars, depth + 1), name_arg(mode))
}
Expr::Op { op, args } => {
+34
View File
@@ -138,3 +138,37 @@ fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() {
assert_eq!(mode_of("gone").eval(&vars, &light), None);
assert_eq!(in_mode("n", "dark").variables(), ["n"]);
}
#[test]
fn stored_variable_ids_keep_expression_evaluation_display_and_types() {
let mut vars = more_vars();
let mut shadow = vars.get_by_id("s").unwrap().clone();
shadow.id = "shadow".into();
shadow.name = "n".into();
vars.variables.insert(0, shadow);
let mut shadow = vars.collection("cs").unwrap().clone();
shadow.id = "shadow".into();
shadow.name = "c".into();
vars.collections.insert(0, shadow);
let modes = BTreeMap::new();
for e in [Expr::var("n"), in_mode("n", "dark")] {
assert_eq!(e.type_of(&vars), Ok(VariableType::Number));
assert!(e.display(&vars).starts_with("n-name"));
}
assert_eq!(Expr::var("n").eval(&vars, &modes), Some(VariableValue::Number(3.0)));
assert_eq!(in_mode("n", "dark").eval(&vars, &modes), Some(VariableValue::Number(7.0)));
assert_eq!(Expr::parse("n-name[Dark]", &vars), Ok(in_mode("n", "dark")));
assert_eq!(mode_of("c").eval(&vars, &modes), Some(VariableValue::String("Light".into())));
assert_eq!(mode_of("c").display(&vars), "mode(Theme)");
vars.variables.retain(|v| v.id != "n");
for e in [Expr::var("n"), in_mode("n", "dark")] {
assert_eq!(e.eval(&vars, &modes), None);
assert_eq!(e.type_of(&vars), Err("No variable `n`".into()));
assert!(e.display(&vars).contains("missing variable"));
}
vars.collections.retain(|c| c.id != "c");
assert_eq!(mode_of("c").eval(&vars, &modes), None);
assert_eq!(mode_of("c").type_of(&vars), Err("No variable collection `c`".into()));
assert_eq!(mode_of("c").display(&vars), "mode(\"(missing collection)\")");
}
+5 -5
View File
@@ -88,15 +88,15 @@ impl Expr {
return Err("The expression is nested too deeply".into());
}
let (op, args) = match self {
Expr::Value(VariableValue::Alias(id)) => return vars.get(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
Expr::Value(VariableValue::Alias(id)) => return vars.get_by_id(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
Expr::Value(VariableValue::Number(n)) if !n.is_finite() => return Err("Numbers must be finite".into()),
Expr::Value(v) => return v.type_of().ok_or_else(|| "Not a value".to_string()),
Expr::Mode { collection } => {
return vars.collection(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
return vars.collection_by_id(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
}
Expr::InMode { variable, mode } => {
let v = vars.get(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
let c = vars.collection(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
let v = vars.get_by_id(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
let c = vars.collection_by_id(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
return c.find_mode(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name));
}
Expr::Op { op, args } => (*op, args),
@@ -501,7 +501,7 @@ impl Parser<'_> {
self.i += 1;
let start = self.i;
let name = self.argument(']', "mode")?;
let coll = self.vars.get(&var).and_then(|v| self.vars.collection(&v.collection));
let coll = self.vars.get_by_id(&var).and_then(|v| self.vars.collection_by_id(&v.collection));
match coll.and_then(|c| c.find_mode(&name)) {
Some(m) => Ok(Expr::InMode { variable: var, mode: m.id.clone() }),
None => {
+6 -1
View File
@@ -278,13 +278,18 @@ impl Variables {
self.collections.iter().find(|c| c.id == id || c.name == id)
}
/// Look up a stored collection reference by exact id, without matching collection names.
pub fn collection_by_id(&self, id: &str) -> Option<&Collection> {
self.collections.iter().find(|c| c.id == id)
}
/// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases
/// (bounded, cycles return None).
pub fn resolve(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<VariableValue> {
let mut cur = id.to_string();
for _ in 0..32 {
let var = self.get_by_id(&cur)?;
let coll = self.collection(&var.collection)?;
let coll = self.collection_by_id(&var.collection)?;
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
match var.values.get(mode).or_else(|| var.values.values().next())? {
VariableValue::Alias(next) => cur = next.clone(),
+14
View File
@@ -708,3 +708,17 @@ fn two_segment_closed_loop_rounded_corners() {
let path = net.fill_path();
assert!(!path.elements().is_empty());
}
#[test]
fn stored_variable_ids_resolve_only_their_exact_collection() {
let mut vars = crate::proto_expr_tests::vars();
// The wrong collection's modes force the old resolver's first-value fallback (dark).
let mut shadow = vars.collections[0].clone();
shadow.id = "shadow".into();
shadow.name = "c".into();
shadow.modes = vec![Mode { id: "other".into(), name: "Other".into() }];
vars.collections.insert(0, shadow);
assert_eq!(vars.resolve("n", &Default::default()), Some(VariableValue::Number(3.0)));
vars.collections.retain(|c| c.id != "c");
assert_eq!(vars.resolve("n", &Default::default()), None);
}
+8 -5
View File
@@ -240,7 +240,10 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
.collections
.into_iter()
.filter(|c| {
pick(prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), doc.variables.collection(c).map(|x| x.name.as_str()))
pick(
prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())),
doc.variables.collection_by_id(c).map(|x| x.name.as_str()),
)
})
.collect(),
}
@@ -251,7 +254,7 @@ fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>)
let mut out = BTreeSet::new();
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
while let Some(cur) = stack.pop() {
let Some(v) = vars.get(&cur) else { continue };
let Some(v) = vars.get_by_id(&cur) else { continue };
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
continue;
}
@@ -702,7 +705,7 @@ pub fn import_variables(
) -> VariableMap {
let (lid, m, src) = (lib.manifest.id.as_str(), &lib.manifest, &lib.doc.variables);
let vars = alias_closure(src, keys.iter().cloned());
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get(v)).map(|v| &v.collection)).collect();
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get_by_id(v)).map(|v| &v.collection)).collect();
let mut out = VariableMap::default();
// Collections first: the variables name theirs.
for key in colls {
@@ -758,7 +761,7 @@ pub fn import_variables(
let val = match val {
// An alias to a variable the library doesn't have resolves to nothing: dropped
// (never pointed at a variable of this file).
VariableValue::Alias(a) => match src.get(a).and_then(|t| out.variables.get(&t.id)) {
VariableValue::Alias(a) => match src.get_by_id(a).and_then(|t| out.variables.get(&t.id)) {
Some(l) => VariableValue::Alias(l.clone()),
None => continue,
},
@@ -955,7 +958,7 @@ pub fn pending(doc: &Document, lib: &PublishedLibrary) -> Pending {
.filter(|r| r.library == m.id)
.map(|r| r.key.as_str())
.collect();
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get(&r.local).is_some());
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get_by_id(&r.local).is_some());
variables.extend(published_variables(lib).filter(|v| extended.contains(v.collection.as_str()) && !copied(&v.id)).map(|v| ImportedRecord {
library: m.id.clone(),
key: v.id.clone(),
+4 -4
View File
@@ -149,7 +149,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
out.styles = styles.len();
// Variables: by collection and variable name and type; collections and modes by name.
let coll_name = |d: &Document, id: &str| d.variables.collection(id).map(|c| c.name.clone()).unwrap_or_default();
let coll_name = |d: &Document, id: &str| d.variables.collection_by_id(id).map(|c| c.name.clone()).unwrap_or_default();
// Variables the document's own layers bind or its own variables alias.
let remote: BTreeSet<String> = doc.libraries.variables.iter().map(|r| r.local.clone()).collect();
let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
@@ -161,7 +161,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published));
let mut var_keys: BTreeMap<String, String> = BTreeMap::new();
for r in &var_recs {
let Some(v) = doc.variables.get(&r.local) else { continue };
let Some(v) = doc.variables.get_by_id(&r.local) else { continue };
let cname = coll_name(doc, &v.collection);
let found = lib_coll(&cname).and_then(|c| {
to.doc
@@ -189,10 +189,10 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
// A's collection → (B's, A's mode id → B's mode of the same name).
let mut colls: BTreeMap<String, (String, BTreeMap<String, String>)> = BTreeMap::new();
for (a, k) in &coll_keys {
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection(a)) else { continue };
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection_by_id(a)) else { continue };
let modes = doc
.variables
.collection(b)
.collection_by_id(b)
.map(|bc| {
ac.modes.iter().filter_map(|am| bc.modes.iter().find(|bm| bm.name == am.name).map(|bm| (am.id.clone(), bm.id.clone()))).collect()
})
@@ -114,3 +114,35 @@ fn swap_moves_instances_styles_and_variables_by_name() {
assert!(s.execute("library.swap", &json!({"from": b, "to": b})).is_err());
assert!(s.execute("library.swap", &json!({"from": a, "to": b})).is_err());
}
#[test]
fn stored_variable_ids_keep_library_swap_matches_on_the_real_copies() {
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-ids-{}", std::process::id())));
std::fs::create_dir_all(&dir.0).unwrap();
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
let a = library(&pa, "#0000ff", &[]);
let b = library(&pb, "#00ff00", &[]);
let mut s = Session::new();
let shadow_collection = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#ff0000"}));
ex(&mut s, "library.add", json!({"path": pa}));
ex(&mut s, "library.add", json!({"path": pb}));
let dot = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
ex(&mut s, "library.bindVariable", json!({"library": a, "variable": "accent", "field": "fill", "ids": [dot]}));
ex(&mut s, "library.setVariableMode", json!({"library": a, "collection": "Theme", "mode": "Dark", "ids": [dot]}));
let doc = s.doc().unwrap();
let target = doc.libraries.variables[0].local.clone();
let collection = doc.libraries.collections[0].local.clone();
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": target}));
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_collection["id"], "name": collection}));
let result = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
assert_eq!(result["variables"], 1);
assert_eq!(result["unmatched"], json!([]));
let doc = s.doc().unwrap();
let node = doc.node(dot).unwrap();
assert_eq!(node.fills[0].solid_color().unwrap().to_hex(), "00FF00");
assert!(!node.bound.is_empty());
assert!(!node.var_modes.is_empty());
assert!(node.bound.values().all(|v| doc.libraries.variable_import(v).is_some_and(|r| r.library == b)));
assert!(node.var_modes.keys().all(|c| doc.libraries.collection_import(c).is_some_and(|r| r.library == b)));
}
+334 -139
View File
@@ -5,32 +5,188 @@ use serde_json::{Value, json};
use crate::Session;
/// Top-level node keys accepted by `fields`, including the structure kept in every tree.
pub const NODE_KEYS: &[&str] = &[
"absolute",
"absoluteBounds",
"annotations",
"arc",
"background",
"blend",
"board",
"booleanOperation",
"boundVariables",
"brush",
"characters",
"childCount",
"children",
"clip",
"codeLinks",
"componentProperties",
"componentPropertyDefinitions",
"constraints",
"cornerRadius",
"cornerSmoothing",
"dashes",
"description",
"devChanged",
"devNote",
"devResources",
"devStatus",
"effects",
"exports",
"fills",
"flows",
"fontFamily",
"fontSize",
"fontStyle",
"fontVariations",
"gridCell",
"grids",
"hangingList",
"hangingPunctuation",
"height",
"id",
"innerRadius",
"interactions",
"isMask",
"layout",
"leadingTrim",
"letterSpacing",
"lineHeight",
"lineIndentations",
"lineTypes",
"listSpacing",
"listStartOffsets",
"listStyle",
"locked",
"mainComponent",
"mainComponentName",
"maskType",
"maxHeight",
"maxLines",
"maxWidth",
"measurements",
"minHeight",
"minWidth",
"miterAngle",
"name",
"opacity",
"operandOperation",
"overrides",
"pluginData",
"pointCount",
"rotation",
"sectionContentsHidden",
"segments",
"sizing",
"slot",
"strokeAlign",
"strokeJoin",
"strokeSides",
"strokeWeight",
"strokes",
"styledRuns",
"styles",
"textAlign",
"textAutoResize",
"textDecoration",
"textDecorationColor",
"textDecorationOffset",
"textDecorationSkipInk",
"textDecorationStyle",
"textDecorationThickness",
"textPath",
"textStyleRanges",
"type",
"vertices",
"visible",
"width",
"widthProfile",
"x",
"y",
];
/// Which layer properties to build. Missing options keep the full, sparse inspection output.
#[derive(Clone, Debug, Default)]
pub struct InspectOptions {
fields: Option<std::collections::BTreeSet<&'static str>>,
outline: bool,
}
impl InspectOptions {
/// Parse the same options for engine commands, control methods and MCP tools.
pub fn parse(p: &Value) -> Result<Self, String> {
let outline = match p.get("outline") {
None => false,
Some(Value::Bool(v)) => *v,
Some(_) => return Err("`outline` must be a boolean".into()),
};
if outline && p.get("fields").is_some() {
return Err("`outline: true` cannot be combined with `fields`".into());
}
let fields = match p.get("fields") {
None if outline => Some(["id", "name", "type", "characters", "mainComponentName", "visible"].into_iter().collect()),
None => None,
Some(Value::Array(values)) => {
let mut fields = std::collections::BTreeSet::new();
for value in values {
let key = value.as_str().ok_or("`fields` must be an array of strings")?;
let key = NODE_KEYS
.iter()
.copied()
.find(|k| *k == key)
.ok_or_else(|| format!("unknown field `{key}`; valid keys: {}", NODE_KEYS.join(", ")))?;
fields.insert(key);
}
Some(fields)
}
Some(_) => return Err("`fields` must be an array of strings".into()),
};
Ok(Self { fields, outline })
}
fn includes(&self, key: &str) -> bool {
self.fields.as_ref().is_none_or(|fields| fields.contains(key))
}
fn explicit(&self, key: &str) -> bool {
self.fields.as_ref().is_some_and(|fields| fields.contains(key))
}
}
/// A node and (to `depth`) its children, with the properties a designer would check.
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
node_json_with_options(doc, id, depth, &InspectOptions::default())
}
/// A node's selected properties, applied at every level of its subtree.
pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let mut v = json!({
"id": n.id,
"name": n.name,
"type": n.kind.type_name(),
});
let mut v = json!({"id": n.id});
let Some(o) = v.as_object_mut() else { return v };
insert_prop(o, options, "name", || json!(n.name));
insert_prop(o, options, "type", || json!(n.kind.type_name()));
if options.explicit("visible") {
insert_prop(o, options, "visible", || json!(n.visible));
}
if !n.is_page() {
base_props(doc, n, id, o);
paint_props(n, o);
layout_props(doc, n, o);
dev_props(doc, n, o);
base_props(doc, n, id, o, options);
paint_props(n, o, options);
layout_props(doc, n, o, options);
dev_props(doc, n, o, options);
}
// Layer data written with `node.set {props: {pluginData}}` (pages too).
if !n.plugin_data.is_empty() {
o.insert("pluginData".into(), json!(n.plugin_data));
insert_prop(o, options, "pluginData", || json!(n.plugin_data));
}
match &n.kind {
NodeKind::Text(t) => text_props(t, o),
_ => kind_props(doc, n, o),
NodeKind::Text(t) => text_props(t, o, options),
_ => kind_props(doc, n, o, options),
}
if !n.children.is_empty() {
if depth > 0 {
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_with_options(doc, *c, depth - 1, options)).collect()));
} else {
o.insert("childCount".into(), json!(n.children.len()));
}
@@ -38,272 +194,301 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
v
}
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>) {
let c = doc.frame_relative(id).as_coeffs();
o.insert("x".into(), json!(round(c[4])));
o.insert("y".into(), json!(round(c[5])));
o.insert("width".into(), json!(round(n.width)));
o.insert("height".into(), json!(round(n.height)));
let rot = figcraft_geom::rotation_degrees(n.transform);
if rot.abs() > 1e-6 {
o.insert("rotation".into(), json!(round(rot)));
}
if let Some(b) = doc.world_bounds(id) {
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
}
if !n.visible {
o.insert("visible".into(), json!(false));
}
if n.locked {
o.insert("locked".into(), json!(true));
}
if n.opacity < 1.0 {
o.insert("opacity".into(), json!(n.opacity));
}
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
o.insert("blend".into(), json!(n.blend));
/// Build a property only when it was requested (or no field list was given).
fn insert_prop(o: &mut serde_json::Map<String, Value>, options: &InspectOptions, key: &str, value: impl FnOnce() -> Value) {
if options.includes(key) {
o.insert(key.into(), value());
}
}
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>) {
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
if options.includes("x") || options.includes("y") {
let c = doc.frame_relative(id).as_coeffs();
insert_prop(o, options, "x", || json!(round(c[4])));
insert_prop(o, options, "y", || json!(round(c[5])));
}
insert_prop(o, options, "width", || json!(round(n.width)));
insert_prop(o, options, "height", || json!(round(n.height)));
if options.includes("rotation") {
let rot = figcraft_geom::rotation_degrees(n.transform);
if rot.abs() > 1e-6 {
insert_prop(o, options, "rotation", || json!(round(rot)));
}
}
if let Some(b) = options.includes("absoluteBounds").then(|| doc.world_bounds(id)).flatten() {
insert_prop(
o,
options,
"absoluteBounds",
|| json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}),
);
}
if !n.visible {
insert_prop(o, options, "visible", || json!(false));
}
if n.locked {
insert_prop(o, options, "locked", || json!(true));
}
if n.opacity < 1.0 {
insert_prop(o, options, "opacity", || json!(n.opacity));
}
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
insert_prop(o, options, "blend", || json!(n.blend));
}
}
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
if !n.fills.is_empty() {
o.insert("fills".into(), paints(&n.fills));
insert_prop(o, options, "fills", || paints(&n.fills));
}
if !n.strokes.is_empty() {
o.insert("strokes".into(), paints(&n.strokes));
o.insert("strokeWeight".into(), json!(n.stroke.weight));
o.insert("strokeAlign".into(), json!(n.stroke.align));
insert_prop(o, options, "strokes", || paints(&n.strokes));
insert_prop(o, options, "strokeWeight", || json!(n.stroke.weight));
insert_prop(o, options, "strokeAlign", || json!(n.stroke.align));
if n.stroke.sides.is_some() {
o.insert("strokeSides".into(), json!(n.stroke.sides));
insert_prop(o, options, "strokeSides", || json!(n.stroke.sides));
}
if !n.stroke.dashes.is_empty() {
o.insert("dashes".into(), json!(n.stroke.dashes));
insert_prop(o, options, "dashes", || json!(n.stroke.dashes));
}
if n.stroke.join != StrokeJoin::Miter {
o.insert("strokeJoin".into(), json!(n.stroke.join));
insert_prop(o, options, "strokeJoin", || json!(n.stroke.join));
} else if n.stroke.miter_limit != 4.0 {
o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
insert_prop(o, options, "miterAngle", || json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
}
if let Some(p) = n.stroke.profile() {
let points = crate::cmd::props::width_points_json(Some(p));
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
insert_prop(o, options, "widthProfile", || {
let points = crate::cmd::props::width_points_json(Some(p));
json!({"preset": p.preset().map(|x| x.id()), "points": points})
});
}
if let Some(b) = &n.stroke.brush {
o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
insert_prop(o, options, "brush", || camel(serde_json::to_value(b).unwrap_or(Value::Null)));
}
}
if !n.effects.is_empty() {
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
insert_prop(o, options, "effects", || camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
}
if n.corners.radii.iter().any(|r| *r > 0.0) {
o.insert(
"cornerRadius".into(),
match n.corners.uniform() {
Some(r) => json!(r),
None => json!(n.corners.radii),
},
);
insert_prop(o, options, "cornerRadius", || match n.corners.uniform() {
Some(r) => json!(r),
None => json!(n.corners.radii),
});
if n.corners.smoothing > 0.0 {
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
insert_prop(o, options, "cornerSmoothing", || json!(n.corners.smoothing));
}
}
}
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
if let Some(l) = &n.layout {
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
insert_prop(o, options, "layout", || camel(serde_json::to_value(l).unwrap_or(Value::Null)));
}
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
insert_prop(o, options, "sizing", || json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
}
let cl = &n.child_layout;
if cl.absolute {
o.insert("absolute".into(), json!(true));
insert_prop(o, options, "absolute", || json!(true));
}
for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
if let Some(v) = v {
o.insert(k.into(), json!(v));
insert_prop(o, options, k, || json!(v));
}
}
if let Some(cell) = parent_al
if let Some(cell) = (parent_al && options.includes("gridCell"))
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
.flatten()
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
{
o.insert(
"gridCell".into(),
insert_prop(o, options, "gridCell", || {
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
);
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v})
});
}
if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
insert_prop(o, options, "constraints", || serde_json::to_value(n.constraints).unwrap_or(Value::Null));
}
if n.kind.is_frame_like() {
o.insert("clip".into(), json!(n.clip));
insert_prop(o, options, "clip", || json!(n.clip));
}
}
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
if !n.dev_status.is_none() {
o.insert("devStatus".into(), json!(n.dev_status.id()));
insert_prop(o, options, "devStatus", || json!(n.dev_status.id()));
}
if n.dev_changed {
o.insert("devChanged".into(), json!(true));
insert_prop(o, options, "devChanged", || json!(true));
}
if !n.dev_note.is_empty() {
o.insert("devNote".into(), json!(n.dev_note));
insert_prop(o, options, "devNote", || json!(n.dev_note));
}
if let NodeKind::Section { contents_hidden } = n.kind {
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
insert_prop(o, options, "sectionContentsHidden", || json!(contents_hidden));
}
if n.is_mask {
o.insert("isMask".into(), json!(true));
o.insert("maskType".into(), json!(n.mask_type.id()));
insert_prop(o, options, "isMask", || json!(true));
insert_prop(o, options, "maskType", || json!(n.mask_type.id()));
}
if !n.grids.is_empty() {
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
insert_prop(o, options, "grids", || camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
}
if !n.exports.is_empty() {
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
insert_prop(o, options, "exports", || serde_json::to_value(&n.exports).unwrap_or(Value::Null));
}
if let Some(b) = &n.board {
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
insert_prop(o, options, "board", || serde_json::to_value(b).unwrap_or(Value::Null));
}
if !n.annotations.is_empty() {
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
insert_prop(o, options, "annotations", || serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
}
if !n.code_links.is_empty() {
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
insert_prop(o, options, "codeLinks", || camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
}
if !n.measurements.is_empty() {
o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
insert_prop(o, options, "measurements", || serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
}
let dev = crate::dev_resources::list_json(doc, n.id);
if dev.as_array().is_some_and(|a| !a.is_empty()) {
o.insert("devResources".into(), dev);
if options.includes("devResources") {
let dev = crate::dev_resources::list_json(doc, n.id);
if dev.as_array().is_some_and(|a| !a.is_empty()) {
insert_prop(o, options, "devResources", || dev);
}
}
if !n.interactions.is_empty() {
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
insert_prop(o, options, "interactions", || serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
}
if !n.styles.is_empty() {
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
insert_prop(o, options, "styles", || serde_json::to_value(&n.styles).unwrap_or(Value::Null));
}
if !n.bound.is_empty() {
o.insert("boundVariables".into(), json!(n.bound));
insert_prop(o, options, "boundVariables", || json!(n.bound));
}
if !n.overrides.is_empty() {
o.insert("overrides".into(), json!(n.overrides));
insert_prop(o, options, "overrides", || json!(n.overrides));
}
if let Some(slot) = crate::components::slot_name(doc, n.id) {
o.insert("slot".into(), json!(slot));
if let Some(slot) = options.includes("slot").then(|| crate::components::slot_name(doc, n.id)).flatten() {
insert_prop(o, options, "slot", || json!(slot));
}
// An operand combining by an operation of its own.
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
o.insert("operandOperation".into(), json!(op));
if let Some(op) = n.bool_op.filter(|_| options.includes("operandOperation") && crate::booleans::group_of(doc, n.id).is_some()) {
insert_prop(o, options, "operandOperation", || json!(op));
}
}
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>) {
o.insert("characters".into(), json!(t.characters));
o.insert("fontFamily".into(), json!(t.style.font_family));
o.insert("fontStyle".into(), json!(t.style.font_style));
o.insert("fontSize".into(), json!(t.style.font_size));
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
insert_prop(o, options, "characters", || {
if options.outline {
let mut chars = t.characters.chars();
let mut text: String = chars.by_ref().take(120).collect();
if chars.next().is_some() {
text.push_str("...");
}
json!(text)
} else {
json!(t.characters)
}
});
insert_prop(o, options, "fontFamily", || json!(t.style.font_family));
insert_prop(o, options, "fontStyle", || json!(t.style.font_style));
insert_prop(o, options, "fontSize", || json!(t.style.font_size));
if !t.style.variations.is_empty() {
o.insert("fontVariations".into(), json!(t.style.variations));
insert_prop(o, options, "fontVariations", || json!(t.style.variations));
}
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
o.insert("textAlign".into(), json!(t.align_h));
o.insert("textAutoResize".into(), json!(t.auto_resize));
insert_prop(o, options, "lineHeight", || json!(t.style.line_height.label()));
insert_prop(o, options, "letterSpacing", || json!(t.style.letter_spacing.label()));
insert_prop(o, options, "textAlign", || json!(t.align_h));
insert_prop(o, options, "textAutoResize", || json!(t.auto_resize));
if t.max_lines > 0 {
o.insert("maxLines".into(), json!(t.max_lines));
insert_prop(o, options, "maxLines", || json!(t.max_lines));
}
if let Some(tp) = &t.path {
o.insert("textPath".into(), json!(tp));
insert_prop(o, options, "textPath", || json!(tp));
}
if t.style.decoration != TextDecoration::None {
o.insert("textDecoration".into(), json!(t.style.decoration));
insert_prop(o, options, "textDecoration", || json!(t.style.decoration));
}
if let Some(l) = t.style.decoration_look.as_deref() {
o.insert("textDecorationStyle".into(), json!(l.style));
o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
o.insert("textDecorationOffset".into(), json!(l.offset.label()));
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
insert_prop(o, options, "textDecorationStyle", || json!(l.style));
insert_prop(o, options, "textDecorationThickness", || json!(l.thickness.label()));
insert_prop(o, options, "textDecorationOffset", || json!(l.offset.label()));
insert_prop(o, options, "textDecorationSkipInk", || json!(l.skip_ink));
insert_prop(o, options, "textDecorationColor", || l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
}
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
if t.paragraphs.is_empty() {
if t.list != figcraft_doc::ListStyle::None {
o.insert("listStyle".into(), json!(t.list));
insert_prop(o, options, "listStyle", || json!(t.list));
}
} else {
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
insert_prop(o, options, "lineTypes", || t.paragraphs.iter().map(|p| json!(p.list)).collect());
insert_prop(o, options, "lineIndentations", || t.paragraphs.iter().map(|p| json!(p.indent)).collect());
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
}
}
if t.list_spacing != 0.0 {
o.insert("listSpacing".into(), json!(t.list_spacing));
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
}
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
if on {
o.insert(k.into(), json!(true));
insert_prop(o, options, k, || json!(true));
}
}
if t.leading_trim != figcraft_doc::LeadingTrim::None {
o.insert("leadingTrim".into(), json!(t.leading_trim));
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
}
if !t.runs.is_empty() {
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
}
// Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
if !linked.is_empty() {
o.insert("textStyleRanges".into(), Value::Array(linked));
if options.includes("textStyleRanges") {
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
if !linked.is_empty() {
insert_prop(o, options, "textStyleRanges", || Value::Array(linked));
}
}
}
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
match &n.kind {
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
insert_prop(o, options, "arc", || json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
}
NodeKind::Polygon { count } => {
o.insert("pointCount".into(), json!(count));
insert_prop(o, options, "pointCount", || json!(count));
}
NodeKind::Star { count, inner_ratio } => {
o.insert("pointCount".into(), json!(count));
o.insert("innerRadius".into(), json!(inner_ratio));
insert_prop(o, options, "pointCount", || json!(count));
insert_prop(o, options, "innerRadius", || json!(inner_ratio));
}
NodeKind::BooleanOp { op } => {
o.insert("booleanOperation".into(), json!(op));
insert_prop(o, options, "booleanOperation", || json!(op));
}
NodeKind::Instance { component, props } => {
o.insert("mainComponent".into(), json!(component));
o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone())));
insert_prop(o, options, "mainComponent", || json!(component));
insert_prop(o, options, "mainComponentName", || json!(doc.node(*component).map(|c| c.name.clone())));
if !props.is_empty() {
o.insert("componentProperties".into(), json!(props));
insert_prop(o, options, "componentProperties", || json!(props));
}
}
NodeKind::Component { description, props, .. } => {
if !description.is_empty() {
o.insert("description".into(), json!(description));
insert_prop(o, options, "description", || json!(description));
}
if !props.is_empty() {
o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null));
insert_prop(o, options, "componentPropertyDefinitions", || serde_json::to_value(props).unwrap_or(Value::Null));
}
}
NodeKind::Vector { network } => {
o.insert("vertices".into(), json!(network.vertices.len()));
o.insert("segments".into(), json!(network.segments.len()));
insert_prop(o, options, "vertices", || json!(network.vertices.len()));
insert_prop(o, options, "segments", || json!(network.segments.len()));
}
NodeKind::Page { background, flows, .. } => {
o.insert("background".into(), json!(background.to_css_hex()));
insert_prop(o, options, "background", || json!(background.to_css_hex()));
if !flows.is_empty() {
o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null));
insert_prop(o, options, "flows", || serde_json::to_value(flows).unwrap_or(Value::Null));
}
}
_ => {}
@@ -339,10 +524,20 @@ fn paints(p: &[figcraft_color::Paint]) -> Value {
/// The whole document (current page in depth, other pages summarised).
pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
document_with_options(s, depth, all_pages, &InspectOptions::default())
}
/// The document metadata and a tree built with only the selected layer properties.
pub fn document_with_options(s: &Session, depth: usize, all_pages: bool, options: &InspectOptions) -> Value {
let Ok(ds) = s.ds() else { return json!({"documents": 0}) };
let doc = &ds.doc;
let pages: Vec<Value> =
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
doc.pages
.iter()
.map(|p| {
if all_pages || *p == ds.page { node_json_with_options(doc, *p, depth, options) } else { node_json_with_options(doc, *p, 0, options) }
})
.collect();
json!({
"id": ds.uid,
"name": doc.name,
+3 -2
View File
@@ -305,9 +305,10 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
spec!("node.set", "Set properties", super::props::KEYS, true, set),
spec!("node.get", "Get layer", "{id}", false, |s, p| {
spec!("node.get", "Get layer", "{id, fields?: [node keys], outline?: bool (not with fields)}", false, |s, p| {
let i = req_id("node.get", p, "id")?;
Ok(crate::inspect::node_json(s.doc()?, i, 1))
let options = crate::inspect::InspectOptions::parse(p).map_err(|e| bad("node.get", e))?;
Ok(crate::inspect::node_json_with_options(s.doc()?, i, 1, &options))
}),
spec!(
"node.find",
+4 -3
View File
@@ -245,7 +245,7 @@ fn alias(s: &mut Session, p: &Value) -> Result<Value> {
let kind = doc.variables.get(&own).filter(|v| !is_remote_variable(doc, &v.id)).map(|v| v.kind);
let kind = kind.ok_or_else(|| bad(cmd, format!("no local variable `{own}`")))?;
let (lib, key) = variable_arg(s, cmd, p, "alias")?;
if lib.doc.variables.get(&key).is_some_and(|v| v.kind != kind) {
if lib.doc.variables.get_by_id(&key).is_some_and(|v| v.kind != kind) {
return Err(bad(cmd, "the library variable has another type"));
}
let local = import_variable(s, &lib, &key)?;
@@ -474,7 +474,8 @@ fn swap_cmd(s: &mut Session, p: &Value) -> Result<Value> {
fn assets_json(lib: &PublishedLibrary) -> Value {
let m = &lib.manifest;
let modes = |c: &str| lib.doc.variables.collection(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
let modes =
|c: &str| lib.doc.variables.collection_by_id(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
json!({
"components": m.components.iter().filter(|a| a.published).map(|a| json!({"id": a.id, "name": a.name, "set": a.set, "version": a.version})).collect::<Vec<_>>(),
"styles": m.styles.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "type": x.kind, "version": x.version})).collect::<Vec<_>>(),
@@ -520,7 +521,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
res["publishable"] = json!({
"components": all.components.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": def.components.contains(c)}))).collect::<Vec<_>>(),
"styles": all.styles.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": def.styles.contains(x)}))).collect::<Vec<_>>(),
"collections": all.collections.iter().filter_map(|x| doc.variables.collection(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
"collections": all.collections.iter().filter_map(|x| doc.variables.collection_by_id(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
});
}
Ok(res)
+1 -37
View File
@@ -308,40 +308,6 @@ pub(crate) fn check_action_dests(cmd: &str, doc: &Document, actions: &[Action])
Ok(())
}
/// Variables, collections and modes that actions name by name, stored by id.
fn canonical_ids(doc: &Document, it: &mut Interaction) {
canonical_actions(&doc.variables, &mut it.actions);
}
/// Variables (also those expressions read), collections and modes that `actions` name by name,
/// stored by id.
pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) {
walk_mut(actions, &mut |a| match a {
Action::SetVariable { variable, expr, .. } => {
if let Some(v) = vars.get(variable) {
*variable = v.id.clone();
}
if let Some(e) = expr {
canonical_expr(vars, e, 0);
}
}
Action::SetVariableMode { collection, mode } => {
if let Some(c) = vars.collection(collection) {
if let Some(m) = c.find_mode(mode) {
*mode = m.id.clone();
}
*collection = c.id.clone();
}
}
Action::Conditional { blocks } => {
for e in blocks.iter_mut().filter_map(|b| b.condition.as_mut()) {
canonical_expr(vars, e, 0);
}
}
_ => {}
});
}
/// Variables, collections and modes an expression names by name, stored by id.
pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
match e {
@@ -357,7 +323,7 @@ pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
}
Expr::InMode { variable, mode } => {
if let Some(v) = vars.get(variable) {
if let Some(m) = vars.collection(&v.collection).and_then(|c| c.find_mode(mode)) {
if let Some(m) = vars.collection_by_id(&v.collection).and_then(|c| c.find_mode(mode)) {
*mode = m.id.clone();
}
*variable = v.id.clone();
@@ -425,7 +391,6 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let mut it = it;
it.delay_ms = delay(cmd, p)?.unwrap_or(0);
settle(&mut it);
canonical_ids(s.doc()?, &mut it);
check_dests(cmd, s, &it)?;
let mut index = 0;
for i in req_targets(cmd, s, p)? {
@@ -484,7 +449,6 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
if it.actions.is_empty() {
return Err(bad(cmd, "an interaction needs an action"));
}
canonical_ids(s.doc()?, &mut it);
check_dests(cmd, s, &it)?;
if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) {
*slot = it;
+64 -7
View File
@@ -159,15 +159,73 @@ pub fn default_condition(vars: &Variables) -> Expr {
}
}
/// `p` with every `expression` text in it read: on a set variable action it becomes `expr`
/// (checked against the variable), elsewhere (conditional blocks) the `condition` (checked to give
/// true or false).
/// `p` with incoming names stored as ids and every `expression` text read: on a set variable
/// action it becomes `expr` (checked against the variable), elsewhere (conditional blocks) the
/// `condition` (checked to give true or false). Parsed text already holds ids, so it is never
/// canonicalized again. Existing actions that a command leaves alone never pass through here.
pub fn with_expressions(cmd: &str, vars: &Variables, p: &Value) -> Result<Value> {
let mut v = p.clone();
canonical_inputs(cmd, vars, &mut v, 0)?;
resolve_expressions(cmd, vars, &mut v, 0)?;
Ok(v)
}
/// Visit input actions and conditional blocks, never literal values or parsed expressions.
fn canonical_inputs(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
if depth > 4 * MAX_ACTION_DEPTH {
return Err(bad(cmd, "actions are nested too deeply"));
}
match v {
Value::Array(a) => a.iter_mut().try_for_each(|x| canonical_inputs(cmd, vars, x, depth + 1)),
Value::Object(o) => {
canonical_action_names(vars, o);
canonical_json_exprs(cmd, vars, o)?;
for key in ["action", "actions", "interaction", "blocks"] {
if let Some(child) = o.get_mut(key) {
canonical_inputs(cmd, vars, child, depth + 1)?;
}
}
Ok(())
}
_ => Ok(()),
}
}
/// Only command input accepts names: settle action targets before reading text expressions.
fn canonical_action_names(vars: &Variables, o: &mut serde_json::Map<String, Value>) {
match o.get("type").and_then(Value::as_str) {
Some("setVariable") => {
if let Some(v) = o.get("variable").and_then(Value::as_str).and_then(|n| vars.get(n)) {
o.insert("variable".into(), json!(v.id));
}
}
Some("setVariableMode") => {
if let Some(c) = o.get("collection").and_then(Value::as_str).and_then(|n| vars.collection(n)) {
if let Some(m) = o.get("mode").and_then(Value::as_str).and_then(|n| c.find_mode(n)) {
o.insert("mode".into(), json!(m.id));
}
o.insert("collection".into(), json!(c.id));
}
}
_ => {}
}
}
/// JSON expressions accept names too; text replaces them and is parsed separately.
fn canonical_json_exprs(cmd: &str, vars: &Variables, o: &mut serde_json::Map<String, Value>) -> Result<()> {
if o.get("expression").is_some_and(Value::is_string) {
return Ok(());
}
for key in ["expr", "condition"] {
if let Some(value) = o.get_mut(key).filter(|v| !v.is_null()) {
let mut e: Expr = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, format!("{key}: {e}")))?;
super::proto::canonical_expr(vars, &mut e, 0);
*value = expr_json(cmd, &e)?;
}
}
Ok(())
}
fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
if depth > 4 * MAX_ACTION_DEPTH {
return Err(bad(cmd, "actions are nested too deeply"));
@@ -181,7 +239,7 @@ fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize)
let var = o
.get("variable")
.and_then(Value::as_str)
.and_then(|n| vars.get(n))
.and_then(|n| vars.get_by_id(n))
.ok_or_else(|| bad(cmd, "setVariable: no such `variable`"))?;
e.check_value(vars, var.kind).map_err(|m| bad(cmd, m))?;
o.insert("expr".into(), expr_json(cmd, &e)?);
@@ -208,7 +266,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
for x in walk(std::slice::from_ref(a)) {
match x {
Action::SetVariable { variable, expr, .. } => {
let v = vars.get(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
let v = vars.get_by_id(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
if let Some(e) = expr {
e.check_value(vars, v.kind).map_err(|m| bad(cmd, m))?;
}
@@ -231,8 +289,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
fn action_param(cmd: &str, s: &Session, p: &Value) -> Result<Action> {
let doc = s.doc()?;
let p = with_expressions(cmd, &doc.variables, p)?;
let mut a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
super::proto::canonical_actions(&doc.variables, std::slice::from_mut(&mut a));
let a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
check_action(cmd, &doc.variables, &a)?;
super::proto::check_action_dests(cmd, doc, std::slice::from_ref(&a))?;
Ok(a)
+1 -1
View File
@@ -958,7 +958,7 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
let coll = doc.variables.collection_by_id(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
let mode = match string(p, "mode") {
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
+12 -6
View File
@@ -89,7 +89,7 @@ fn inherited(cmd: &str, what: &str) -> crate::EngineError {
/// Refuse mode edits of extended collections (they have the modes of the collection they extend).
pub(crate) fn check_modes_editable(doc: &Document, cmd: &str, collection: &str) -> Result<()> {
match doc.variables.collection(collection) {
match doc.variables.collection_by_id(collection) {
Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")),
_ => Ok(()),
}
@@ -304,7 +304,7 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
for v in vars.variables.iter().filter(|v| !gone.contains(&v.id)) {
for (m, val) in &v.values {
let VariableValue::Alias(a) = val else { continue };
let Some(t) = vars.get(a).filter(|t| gone.contains(&t.id)) else { continue };
let Some(t) = vars.get_by_id(a).filter(|t| gone.contains(&t.id)) else { continue };
let modes = std::collections::BTreeMap::from([(t.collection.clone(), m.clone())]);
if let Some(r) = vars.resolve(&t.id, &modes) {
detached.push((v.id.clone(), m.clone(), r));
@@ -333,11 +333,11 @@ fn reset_override(s: &mut Session, p: &Value) -> Result<Value> {
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let parent = var.extends.as_deref().and_then(|x| doc.variables.get(x)).cloned();
let parent = var.extends.as_deref().and_then(|x| doc.variables.get_by_id(x)).cloned();
let parent = parent.ok_or_else(|| bad(cmd, "only variables an extended collection inherits have overrides"))?;
let modes: Vec<ModeId> = match string(p, "mode") {
Some(m) => {
let coll = doc.variables.collection(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
let coll = doc.variables.collection_by_id(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
vec![coll.find_mode(&m).ok_or_else(|| bad(cmd, "no such mode"))?.id.clone()]
}
None => var.overrides.iter().cloned().collect(),
@@ -433,6 +433,12 @@ pub fn sync_extensions(doc: &mut Document) {
/// lead back to `var`. Returns the target's id.
pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
let t = vars.get(target).ok_or_else(|| format!("no variable `{target}` to alias"))?;
alias_target_by_id(vars, var, &t.id)
}
/// Check an alias whose target is already a stored or remapped id, without matching names.
pub(crate) fn alias_target_by_id(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
let t = vars.get_by_id(target).ok_or_else(|| format!("no variable `{target}` to alias"))?;
if t.kind != var.kind {
return Err(format!("can't alias a {} variable to a {} one", var.kind.id(), t.kind.id()));
}
@@ -445,9 +451,9 @@ pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::resu
if !seen.insert(cur.clone()) || seen.len() > 10_000 {
continue;
}
for v in vars.get(&cur).map(|x| x.values.values()).into_iter().flatten() {
for v in vars.get_by_id(&cur).map(|x| x.values.values()).into_iter().flatten() {
if let VariableValue::Alias(a) = v
&& let Some(next) = vars.get(a)
&& let Some(next) = vars.get_by_id(a)
{
stack.push(next.id.clone());
}
+2 -2
View File
@@ -95,7 +95,7 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
Some(VariableValue::Boolean(b)) => b.to_string(),
Some(VariableValue::Alias(a)) => match vars.get(a) {
Some(VariableValue::Alias(a)) => match vars.get_by_id(a) {
Some(t) => format!("var({})", name(t)),
None => continue,
},
@@ -330,7 +330,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
VariableValue::Alias(a) => {
let Some(var) = doc.variables.variables.iter().find(|x| x.id == *local) else { continue };
let target = ids.get(a).ok_or_else(|| "its alias target wasn't imported".to_string());
match target.and_then(|t| super::variables::alias_target(&doc.variables, var, t)) {
match target.and_then(|t| super::variables::alias_target_by_id(&doc.variables, var, t)) {
Ok(t) => VariableValue::Alias(t),
Err(e) => {
warnings.push(format!("`{}`: {e}", v.name));
+1 -1
View File
@@ -372,7 +372,7 @@ pub fn bound_variable<'d>(doc: &'d Document, n: &Node, field: &str) -> Option<&'
_ => &[],
};
let found = if forms.is_empty() { n.bound.get(field) } else { forms.iter().find_map(|k| n.bound.get(*k)) };
found.and_then(|v| doc.variables.get(v))
found.and_then(|v| doc.variables.get_by_id(v))
}
/// The variable bound to `field` on `n`, by name.
+19
View File
@@ -58,6 +58,25 @@ fn properties_group_layout_auto_layout_paints_and_tokens() {
assert!(s.execute("inspect.properties", &json!({"id": 999_999})).is_err());
}
#[test]
fn stored_variable_ids_keep_their_dev_inspect_token() {
let mut s = Session::new();
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
let shadow = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})).unwrap();
let surface = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap();
let r = id(s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": surface["id"]}}})).unwrap());
s.execute("variable.update", &json!({"variable": "surface", "codeSyntax": {"WEB": "--real-surface"}})).unwrap();
s.execute("variable.update", &json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}})).unwrap();
let code = s.execute("inspect.code", &json!({"id": r, "language": "css"})).unwrap();
assert!(code["code"].as_str().unwrap().contains("var(--real-surface"));
assert!(!code["code"].as_str().unwrap().contains("--shadow"));
let out = s.execute("inspect.properties", &json!({"id": r})).unwrap();
let fills = out["sections"].as_array().unwrap().iter().find(|s| s["title"] == "Fills").unwrap();
assert_eq!(fills["rows"][0]["color"], "#0D99FF");
assert_eq!(fills["rows"][0]["token"], "surface");
}
#[test]
fn typography_section_for_text() {
let mut s = Session::new();
@@ -206,3 +206,26 @@ fn nested_instances_of_variants_link_to_their_mains_layers() {
let _ = std::fs::remove_file(&path);
assert_eq!(outline(s.doc().unwrap()), before);
}
#[test]
fn stored_variable_ids_keep_exported_binding_identity() {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let shadow = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
let surface = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
let r = id(&run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}})));
run(&mut s, "select.set", json!({"ids": [r]}));
run(&mut s, "style.create", json!({"type": "paint", "name": "Surface"}));
run(&mut s, "node.set", json!({"id": r, "props": {"name": "Styled"}}));
run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}}));
run(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::fig::import(&bytes, "identity").unwrap();
let bound = back.nodes().find(|n| n.name == "Bound").unwrap().bound.values().next().unwrap();
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
let style = back.styles.iter().find(|s| s.name == "Surface").unwrap();
assert!(!style.bound.is_empty());
for bound in style.bound.values() {
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
}
}
+507
View File
@@ -0,0 +1,507 @@
//! Compact inspection keeps the tree useful without building every layer property.
use figcraft_doc::{NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
use crate::inspect::{InspectOptions, document, document_with_options, node_json, node_json_with_options};
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"))
}
fn create(s: &mut Session, params: Value) -> NodeId {
crate::params::id_of(&run(s, "node.create", params)["id"]).unwrap()
}
fn tree() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
let frame = create(&mut s, json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200}));
let text = create(&mut s, json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "x": 10, "y": 20}));
create(&mut s, json!({"type": "rectangle", "parent": frame, "name": "Hidden", "props": {"visible": false, "fill": "#123456"}}));
(s, frame, text)
}
fn node(s: &Session, id: NodeId, depth: usize, params: Value) -> Value {
node_json_with_options(s.doc().unwrap(), id, depth, &InspectOptions::parse(&params).unwrap())
}
fn keys(v: &Value, allowed: &[&str]) {
for (key, value) in v.as_object().unwrap() {
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {v}");
if key == "children" {
for child in value.as_array().unwrap() {
keys(child, allowed);
}
}
}
}
fn golden_tree() -> (Session, NodeId) {
let mut s = Session::new();
let frame = create(
&mut s,
json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200,
"props": {"layout": "horizontal", "padding": 8, "gap": 4, "fill": "#123456", "stroke": "#abcdef", "strokeWeight": 2,
"effects": [{"type": "dropShadow", "offset": [2, 3], "radius": 4}], "cornerRadius": 12}}),
);
create(
&mut s,
json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "width": 80, "height": 24,
"props": {"fontFamily": "Synthetic", "fontSize": 18, "fontStyle": "Bold", "letterSpacing": 1, "lineHeight": 24,
"textAlign": "center", "textDecoration": "underline", "fill": "#fedcba"}}),
);
create(
&mut s,
json!({"type": "rectangle", "parent": frame, "name": "Hidden", "width": 20, "height": 30,
"props": {"visible": false, "locked": true, "fill": "#654321"}}),
);
let main = create(&mut s, json!({"type": "component", "name": "Button", "x": 400, "y": 200, "width": 80, "height": 40}));
run(&mut s, "instance.create", json!({"component": main, "x": 500, "y": 200}));
run(&mut s, "page.add", json!({"name": "Second"}));
(s, frame)
}
#[test]
fn empty_options_keep_the_main_tree_and_document_output() {
let (s, frame) = golden_tree();
// Captured with this builder on origin/main (43af145a12eacd1e87f5f5e6984c49b7da040288), plus the
// document `id` that #1533 added. That id comes from a process-wide counter, so it is the
// session's own.
let expected = json!({
"colorProfile": "srgb",
"currentPage": 7,
"dirty": true,
"history": {
"redo": 0,
"undo": [
"Add page",
"Create instance",
"Create layer",
"Create layer",
"Create layer",
"Create layer"
]
},
"id": s.ds().unwrap().uid,
"images": 0,
"kind": "design",
"name": "Untitled",
"pages": [
{
"background": "#F5F5F5",
"children": [
{
"absoluteBounds": {
"height": 200.0,
"width": 300.0,
"x": 100.0,
"y": 200.0
},
"children": [
{
"absoluteBounds": {
"height": 24.0,
"width": 80.0,
"x": 110.0,
"y": 210.0
},
"characters": "Hello",
"fills": [
{
"color": "#FEDCBA",
"type": "solid"
}
],
"fontFamily": "Synthetic",
"fontSize": 18.0,
"fontStyle": "Bold",
"height": 24.0,
"id": 3,
"letterSpacing": "1",
"lineHeight": "24",
"name": "Title",
"sizing": {
"horizontal": "fixed",
"vertical": "hug"
},
"textAlign": "center",
"textAutoResize": "height",
"textDecoration": "underline",
"type": "TEXT",
"width": 80.0,
"x": 10.0,
"y": 10.0
},
{
"absoluteBounds": {
"height": 30.0,
"width": 20.0,
"x": 100.0,
"y": 200.0
},
"fills": [
{
"color": "#654321",
"type": "solid"
}
],
"height": 30.0,
"id": 4,
"locked": true,
"name": "Hidden",
"sizing": {
"horizontal": "fixed",
"vertical": "fixed"
},
"type": "RECTANGLE",
"visible": false,
"width": 20.0,
"x": 0.0,
"y": 0.0
}
],
"clip": true,
"cornerRadius": 12.0,
"effects": [
{
"blend": "normal",
"color": {
"a": 0.25,
"b": 0.0,
"g": 0.0,
"r": 0.0
},
"offset": [
2.0,
3.0
],
"radius": 4.0,
"showBehind": false,
"spread": 0.0,
"type": "dropShadow",
"visible": true
}
],
"fills": [
{
"color": "#123456",
"type": "solid"
}
],
"height": 200.0,
"id": 2,
"layout": {
"columnTracks": [],
"contentAlign": "auto",
"crossAlign": "min",
"crossGap": 10.0,
"gap": 4.0,
"gridColumns": 2,
"gridRows": 2,
"mainAlign": "min",
"mode": "horizontal",
"padding": [
8.0,
8.0,
8.0,
8.0
],
"reverseZ": false,
"rowTracks": [],
"strokesIncluded": true,
"wrap": false
},
"name": "Card",
"sizing": {
"horizontal": "fixed",
"vertical": "fixed"
},
"strokeAlign": "inside",
"strokeWeight": 2.0,
"strokes": [
{
"color": "#ABCDEF",
"type": "solid"
}
],
"type": "FRAME",
"width": 300.0,
"x": 100.0,
"y": 200.0
},
{
"absoluteBounds": {
"height": 40.0,
"width": 80.0,
"x": 400.0,
"y": 200.0
},
"clip": true,
"fills": [
{
"color": "#FFFFFF",
"type": "solid"
}
],
"height": 40.0,
"id": 5,
"name": "Button",
"type": "COMPONENT",
"width": 80.0,
"x": 400.0,
"y": 200.0
},
{
"absoluteBounds": {
"height": 40.0,
"width": 80.0,
"x": 500.0,
"y": 200.0
},
"clip": true,
"fills": [
{
"color": "#FFFFFF",
"type": "solid"
}
],
"height": 40.0,
"id": 6,
"mainComponent": 5,
"mainComponentName": "Button",
"name": "Button",
"type": "INSTANCE",
"width": 80.0,
"x": 500.0,
"y": 200.0
}
],
"id": 1,
"name": "Page 1",
"type": "PAGE"
},
{
"background": "#F5F5F5",
"id": 7,
"name": "Second",
"type": "PAGE"
}
],
"path": null,
"selection": [],
"styles": [],
"textEdit": null,
"variables": 0
});
let expected_node = &expected["pages"][0]["children"][0];
assert_eq!(node_json(s.doc().unwrap(), frame, 4).to_string(), expected_node.to_string());
assert_eq!(document(&s, 4, true).to_string(), expected.to_string());
for params in [json!({}), json!({"outline": false})] {
let options = InspectOptions::parse(&params).unwrap();
assert_eq!(node_json_with_options(s.doc().unwrap(), frame, 4, &options).to_string(), expected_node.to_string());
assert_eq!(document_with_options(&s, 4, true, &options).to_string(), expected.to_string());
}
}
#[test]
fn fields_filter_recursively_including_pages_without_changing_metadata_or_history() {
let (s, _, _) = tree();
let before = document(&s, 4, true);
let selection = s.selection();
let history = s.undo_depth();
let options = InspectOptions::parse(&json!({"fields": ["name", "characters", "name"], "outline": false})).unwrap();
let compact = document_with_options(&s, 4, true, &options);
let mut metadata = before.clone();
metadata["pages"] = compact["pages"].clone();
assert_eq!(compact, metadata);
keys(&compact["pages"][0], &["id", "name", "characters", "children", "childCount"]);
assert_eq!(compact["pages"][0]["children"][0]["children"][0]["characters"], "Hello");
assert_eq!(s.selection(), selection);
assert_eq!(s.undo_depth(), history);
assert_eq!(document(&s, 4, true), before);
}
#[test]
fn an_empty_field_list_keeps_ids_and_structure_at_depth_boundaries() {
let (s, frame, _) = tree();
let compact = node(&s, frame, 4, json!({"fields": []}));
keys(&compact, &["id", "children", "childCount"]);
assert_eq!(compact["children"].as_array().unwrap().len(), 2);
assert_eq!(node(&s, frame, 0, json!({"fields": []})), json!({"id": frame, "childCount": 2}));
let options = InspectOptions::parse(&json!({"fields": []})).unwrap();
let summary = document_with_options(&s, 0, false, &options);
assert_eq!(summary["pages"][0], json!({"id": s.page().unwrap(), "childCount": 1}));
}
#[test]
fn geometry_keys_are_independently_selectable() {
let (s, frame, text) = tree();
let fields = ["x", "y", "width", "height", "absoluteBounds"];
let compact = node(&s, frame, 2, json!({"fields": fields}));
keys(&compact, &["id", "children", "childCount", "x", "y", "width", "height", "absoluteBounds"]);
assert_eq!((compact["x"].as_f64(), compact["y"].as_f64()), (Some(100.0), Some(200.0)));
assert_eq!(compact["children"][0]["x"], 10.0);
assert_eq!(compact["children"][0]["absoluteBounds"]["x"], 110.0);
for key in fields {
let one = node(&s, text, 0, json!({"fields": [key]}));
assert_eq!(one.as_object().unwrap().len(), 2, "{one}");
assert_eq!(one[key], compact["children"][0][key]);
}
}
#[test]
fn outline_truncates_only_after_120_unicode_scalars() {
let (mut s, _, text) = tree();
for unit in ["a", "界", "\u{1f980}"] {
for len in [0, 119, 120, 121] {
let content = unit.repeat(len);
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().characters = content.clone();
let outline = node(&s, text, 0, json!({"outline": true}));
let want = if len > 120 { format!("{}...", unit.repeat(120)) } else { content.clone() };
assert_eq!(outline["characters"], want);
assert_eq!(node(&s, text, 0, json!({"fields": ["characters"]}))["characters"], content);
keys(&outline, &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount"]);
}
}
}
#[test]
fn selecting_visible_makes_it_explicit_for_every_node_including_pages() {
let (s, frame, _) = tree();
for params in [json!({"fields": ["visible"]}), json!({"outline": true})] {
let options = InspectOptions::parse(&params).unwrap();
let tree = document_with_options(&s, 4, true, &options);
assert_eq!(tree["pages"][0]["visible"], true);
let card = &tree["pages"][0]["children"][0];
assert_eq!(card["visible"], true);
assert_eq!(card["children"][0]["visible"], true);
assert_eq!(card["children"][1]["visible"], false);
}
let no_visible = node(&s, frame, 4, json!({"fields": ["name"]}));
assert!(no_visible["children"][1].get("visible").is_none());
}
#[test]
fn component_names_are_selected_without_other_instance_properties_even_with_a_missing_main() {
let mut s = Session::new();
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
for params in [json!({"fields": ["mainComponentName"]}), json!({"outline": true})] {
let compact = node(&s, instance, 0, params);
assert_eq!(compact["mainComponentName"], "Button");
assert!(compact.get("mainComponent").is_none());
}
if let NodeKind::Instance { component, .. } = &mut s.doc_mut().unwrap().get_mut(instance).unwrap().kind {
*component = NodeId(u64::MAX);
}
let compact = node(&s, instance, 0, json!({"outline": true}));
assert_eq!(compact.get("mainComponentName"), Some(&Value::Null));
}
#[test]
fn page_properties_and_other_page_summaries_respect_fields() {
let (mut s, _, _) = tree();
let first = s.page().unwrap();
run(&mut s, "node.set", json!({"ids": [first], "props": {"pluginData": {"source": "synthetic"}}}));
run(&mut s, "page.add", json!({"name": "Second"}));
let options = InspectOptions::parse(&json!({"fields": ["pluginData", "background"]})).unwrap();
let compact = document_with_options(&s, 4, false, &options);
assert_eq!(compact["pages"].as_array().unwrap().len(), 2);
let page = &compact["pages"][0];
keys(page, &["id", "pluginData", "background", "childCount", "children"]);
assert_eq!(page["pluginData"]["source"], "synthetic");
assert_eq!(page["childCount"], 1);
assert!(page["background"].is_string());
assert!(page.get("name").is_none());
}
#[test]
fn the_catalogue_accepts_emitted_keys_and_structure_keys() {
let (mut s, frame, text) = tree();
run(&mut s, "node.set", json!({"ids": [frame], "props": {"layout": "grid", "fill": "#123456", "stroke": "#abcdef"}}));
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_note = "Ready for review".into();
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_changed = true;
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().style.style_id = Some("synthetic-style".into());
// Exercise each of the six property helpers, including their more expensive values.
for (id, key, sibling) in [
(frame, "absoluteBounds", "width"),
(frame, "fills", "strokes"),
(text, "gridCell", "sizing"),
(frame, "devNote", "devChanged"),
(text, "textStyleRanges", "fontFamily"),
(instance, "mainComponentName", "mainComponent"),
] {
let full = node_json(s.doc().unwrap(), id, 0);
assert!(full.get(key).is_some(), "fixture must emit {key}: {full}");
assert!(full.get(sibling).is_some(), "fixture must emit {sibling}: {full}");
let selected = node(&s, id, 0, json!({"fields": [key]}));
assert_eq!(selected[key], full[key]);
assert!(selected.get(sibling).is_none(), "unrequested {sibling}: {selected}");
keys(&selected, &["id", "children", "childCount", key]);
}
let full = node_json(s.doc().unwrap(), s.page().unwrap(), 3);
fn check(s: &Session, full: &Value) {
let id = crate::params::id_of(&full["id"]).unwrap();
for (key, value) in full.as_object().unwrap() {
let selected = node(s, id, 2, json!({"fields": [key]}));
if key == "children" {
assert_eq!(selected[key].as_array().map(Vec::len), value.as_array().map(Vec::len));
} else {
assert_eq!(&selected[key], value, "{key}");
}
}
for child in full["children"].as_array().into_iter().flatten() {
check(s, child);
}
}
check(&s, &full);
let structural = node(&s, frame, 0, json!({"fields": ["id", "children", "childCount"]}));
assert_eq!(structural, json!({"id": frame, "childCount": 2}));
}
#[test]
fn invalid_options_are_errors_and_unknown_names_list_the_catalogue() {
for params in [
json!({"fields": null}),
json!({"fields": "name"}),
json!({"fields": {"name": true}}),
json!({"fields": [false]}),
json!({"fields": [null]}),
json!({"fields": ["name", 1]}),
json!({"outline": null}),
json!({"outline": "true"}),
json!({"outline": 1}),
json!({"outline": []}),
json!({"outline": true, "fields": []}),
json!({"outline": true, "fields": ["name"]}),
] {
assert!(InspectOptions::parse(&params).is_err(), "{params}");
}
for key in ["Name", "absolute_bounds", "noSuchField", "truncated"] {
let error = InspectOptions::parse(&json!({"fields": [key]})).unwrap_err();
assert!(error.contains(key), "{error}");
for valid in crate::inspect::NODE_KEYS {
assert!(error.contains(valid), "missing {valid}: {error}");
}
}
}
#[test]
fn node_get_uses_options_but_keeps_its_existing_depth_and_selection() {
let (mut s, frame, _) = tree();
let selection = s.selection();
let history = s.undo_depth();
let default = node_json(s.doc().unwrap(), frame, 1);
assert_eq!(run(&mut s, "node.get", json!({"id": frame})), default);
let compact = run(&mut s, "node.get", json!({"id": frame, "fields": []}));
assert!(compact["children"].is_array(), "the engine command still inspects to depth 1");
let error = s.execute("node.get", &json!({"id": frame, "fields": [], "depth": 0})).unwrap_err();
assert!(error.to_string().contains("unknown parameter `depth`"), "{error}");
keys(&compact, &["id", "children", "childCount"]);
assert!(s.execute("node.get", &json!({"id": frame, "outline": true, "fields": []})).is_err());
assert_eq!(s.selection(), selection);
assert_eq!(s.undo_depth(), history);
}
+2
View File
@@ -349,6 +349,8 @@ mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
#[cfg(test)]
mod inspect_fields_tests;
#[cfg(test)]
mod instance_modes_tests;
#[cfg(test)]
mod instance_resizing_tests;
@@ -428,3 +428,36 @@ fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_ne
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn stored_variable_ids_keep_library_alias_dependencies() {
let mut s = Session::new();
for name in ["Shadow", "Base", "Theme"] {
ex(&mut s, "variable.createCollection", json!({"name": name}));
}
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#FF0000"}));
let surface = ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "color", "value": "#0D99FF"}));
let alias = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
let f = idv(&ex(&mut s, "frame.create", json!({"name": "Chip", "x": 0, "y": 0, "width": 100, "height": 40})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": alias["id"]}));
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
let doc = s.doc().unwrap();
let selection = Selection { components: vec![chip], ..Default::default() };
let lib = publish(doc, None, &selection, None, 0).unwrap();
let target = surface["id"].as_str().unwrap();
assert!(lib.doc.variables.get_by_id(target).is_some());
assert!(lib.doc.variables.get_by_id(shadow["id"].as_str().unwrap()).is_none());
assert!(lib.doc.variables.collections.iter().any(|c| c.name == "Base"));
// Import from a snapshot that retains both the dependency and its namesake.
let lib = publish(doc, None, &publishable(doc), None, 1).unwrap();
let mut dest = Document::new("consumer");
let key = alias["id"].as_str().unwrap().to_string();
let map = import_variables(&mut dest, &lib, &std::collections::BTreeSet::from([key.clone()]), &Default::default(), false);
assert!(map.variables.contains_key(target));
let copy = dest.variables.get_by_id(&map.variables[&key]).unwrap();
assert!(!copy.values.is_empty());
assert!(copy.values.values().all(|v| *v == VariableValue::Alias(map.variables[target].clone())));
assert!(dest.variables.collections.iter().any(|c| c.name == "Base"));
}
+12 -4
View File
@@ -42,10 +42,11 @@ fn fit(kind: VariableType, v: VariableValue) -> Option<VariableValue> {
}
}
/// Set `variable` to `value`, or to `expr` evaluated in the modes of `node` (the layer whose
/// interaction runs), in every mode, and re-apply bindings. False when nothing could be set.
/// Set stored variable id `variable` to `value`, or to `expr` evaluated in the modes of `node`
/// (the layer whose interaction runs), in every mode, and re-apply bindings. False when nothing
/// could be set.
pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Value, expr: Option<&Expr>) -> bool {
let Some(var) = doc.variables.get(variable) else { return false };
let Some(var) = doc.variables.get_by_id(variable) else { return false };
let (id, kind) = (var.id.clone(), var.kind);
let v = match expr {
Some(e) => e.eval(&doc.variables, &doc.modes_for(node)).and_then(|v| fit(kind, v)),
@@ -65,7 +66,14 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va
/// on the page's top-level frames that chose another mode of it (modes chosen deeper inside still
/// win), then re-apply bindings. False for an unknown collection or mode.
pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
let Some(c) = doc.variables.collection(collection) else { return false };
let Some(id) = doc.variables.collection(collection).map(|c| c.id.clone()) else { return false };
set_mode_by_id(doc, frame, &id, mode)
}
/// Switch a stored collection id to `mode`, without matching another collection's name.
/// Otherwise behaves like [`set_mode`].
pub fn set_mode_by_id(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
let Some(c) = doc.variables.collection_by_id(collection) else { return false };
let Some(mode) = c.find_mode(mode).map(|m| m.id.clone()) else {
return false;
};
+137
View File
@@ -207,3 +207,140 @@ fn junk_conditional_actions_never_panic() {
}
}
}
#[test]
fn stored_variable_ids_set_only_the_prototype_action_target() {
let (mut s, frame, _, _, _) = setup();
let shadow = var_id(s.doc().unwrap(), "count");
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap();
let target = target["id"].as_str().unwrap().to_string();
s.execute("variable.update", &json!({"variable": shadow, "name": target})).unwrap();
let mut doc = s.doc().unwrap().clone();
assert!(set_variable(&mut doc, frame, &target, &json!(9), None));
assert_eq!(doc.variables.resolve(&target, &Default::default()), Some(VariableValue::Number(9.0)));
assert_eq!(doc.variables.resolve(&shadow, &Default::default()), Some(VariableValue::Number(0.0)));
doc.variables.variables.retain(|v| v.id != target);
let before = doc.clone();
assert!(!set_variable(&mut doc, frame, &target, &json!(11), None));
assert_eq!(doc, before);
}
#[test]
fn stored_variable_ids_survive_unrelated_interaction_edits() {
let (mut s, frame, _, _, _) = setup();
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
s.execute(
"proto.interaction.add",
&json!({"id": frame, "actions": [
{"type": "back"},
{"type": "setVariable", "variable": "target", "expression": "target + 1"},
{"type": "setVariableMode", "collection": "Theme", "mode": "Dark"}
]}),
)
.unwrap();
let theme = s.doc().unwrap().variables.collections[0].id.clone();
s.execute("variable.createCollection", &json!({"name": "shadow", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": theme})).unwrap();
// Put the namesake before the referenced collection, as variable lookup already does.
s.doc_mut().unwrap().variables.collections.swap(0, 1);
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
let actions = s.doc().unwrap().node(frame).unwrap().interactions[0].actions.clone();
for patch in [json!({"durationMs": 450}), json!({"action": {"type": "closeOverlay"}})] {
let mut p = json!({"id": frame, "index": 0});
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
s.execute("proto.interaction.set", &p).unwrap();
let it = &s.doc().unwrap().node(frame).unwrap().interactions[0];
assert_eq!(it.duration_ms, 450);
assert_eq!(it.actions[1..], actions[1..], "only the supplied fields change");
}
}
#[test]
fn stored_variable_ids_from_text_are_not_resolved_twice() {
let (mut s, frame, _, _, _) = setup();
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
let action = json!({"type": "setVariable", "variable": "target", "expression": "target + 1"});
s.execute("proto.interaction.add", &json!({"id": frame, "action": action})).unwrap();
for (cmd, patch) in [
("proto.interaction.add", json!({"action": action})),
("proto.interaction.set", json!({"action": action})),
("proto.interaction.set", json!({"interaction": {"actions": [action]}})),
("proto.action.add", json!({"path": [0], "action": action})),
("proto.action.set", json!({"path": [0], "action": action})),
] {
let mut p = json!({"id": frame});
if cmd != "proto.interaction.add" {
p["index"] = json!(0);
}
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
let r = s.execute(cmd, &p).unwrap();
let index = if cmd == "proto.interaction.add" { r["index"].as_u64().unwrap() as usize } else { 0 };
let Action::SetVariable { variable, expr: Some(expr), .. } = &s.doc().unwrap().node(frame).unwrap().interactions[index].actions[0] else {
panic!("{cmd}: no set variable expression")
};
assert_eq!(variable, &target, "{cmd}");
assert_eq!(expr.eval(&s.doc().unwrap().variables, &Default::default()), Some(VariableValue::Number(6.0)), "{cmd}");
}
}
#[test]
fn stored_variable_ids_resolve_only_incoming_action_and_expression_names() {
let (mut s, frame, _, _, _) = setup();
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
let value = json!({"type": "setVariable", "variable": "target"});
s.execute(
"proto.interaction.add",
&json!({"id": frame, "actions": [
{"type": "setVariable", "variable": "target", "value": null, "expr": Expr::var(&target)},
{"type": "conditional", "blocks": [{"expression": "target == 5", "actions": [
{"type": "setVariable", "variable": "label", "value": value}
]}]}
]}),
)
.unwrap();
let doc = s.doc().unwrap();
let actions = &doc.node(frame).unwrap().interactions[0].actions;
let Action::SetVariable { variable, expr: Some(expr), .. } = &actions[0] else { panic!("no set variable") };
assert_eq!(variable, &target);
// A JSON expression is command input: it still accepts names, even one that resembles an id.
assert_eq!(expr.eval(&doc.variables, &Default::default()), Some(VariableValue::Number(100.0)));
let Action::Conditional { blocks } = &actions[1] else { panic!("no conditional") };
assert_eq!(blocks[0].condition.as_ref().unwrap().eval(&doc.variables, &Default::default()), Some(VariableValue::Boolean(true)));
let Action::SetVariable { value: stored, .. } = &blocks[0].actions[0] else { panic!("no nested set variable") };
assert_eq!(stored, &value, "a literal value is not an action to canonicalize");
}
#[test]
fn stored_variable_ids_switch_only_the_prototype_collection() {
let (s, frame, _, _, r) = setup();
let mut doc = s.doc().unwrap().clone();
let theme = doc.variables.collections[0].clone();
let mut shadow = theme.clone();
shadow.id = "shadow".into();
shadow.name = theme.id.clone();
doc.variables.collections.insert(0, shadow);
assert!(crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark"));
assert_eq!(fill_hex(&doc, r).as_deref(), Some("000000"));
let page = doc.page_of(frame).unwrap();
assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow"));
// The public name-accepting wrapper keeps its existing lookup behavior.
assert!(set_mode(&mut doc, frame, &theme.id, "Light"));
assert!(doc.node(page).unwrap().var_modes.contains_key("shadow"));
doc.variables.collections.retain(|c| c.id != theme.id);
let before = doc.clone();
assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark"));
assert_eq!(doc, before);
}
+6
View File
@@ -459,6 +459,12 @@ fn every_command_survives_junk_params() {
// would write to it.)
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
// Compact inspection: fields and outline with malformed types and unknown keys.
json!({"id": 2, "fields": "name", "outline": true}),
json!({"id": 2, "fields": ["name", 5]}),
json!({"id": 2, "fields": ["Name", "noSuchField"]}),
json!({"id": 2, "fields": null, "outline": []}),
json!({"id": 2, "outline": "yes"}),
// Layer search roots, filters and limits as junk.
json!({"root": {"id": 2}, "name": [1], "type": "nope", "text": true, "component": [], "limit": -1}),
json!({"root": 999999, "limit": 1e308}),
@@ -498,3 +498,24 @@ fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() {
assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040");
assert_eq!(node(&s, r).fills[0].opacity, 1.0);
}
#[test]
fn stored_variable_ids_cannot_hide_an_alias_cycle() {
for link in ["a", "b"] {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let mut ids = std::collections::BTreeMap::new();
for name in ["shadow", "a", "b"] {
let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": name, "type": "number", "value": 1}));
ids.insert(name, v["id"].clone());
}
run(&mut s, "variable.set", json!({"variable": "b", "value": {"alias": "a"}}));
run(&mut s, "variable.update", json!({"variable": ids["shadow"], "name": ids[link]}));
let before = s.doc().unwrap().clone();
let undo = s.ds().unwrap().history.undo.len();
let error = s.execute("variable.set", &json!({"variable": "a", "value": {"alias": "b"}})).unwrap_err();
assert!(error.to_string().contains("refer back to itself"), "{link}: {error}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
}
}
@@ -125,3 +125,66 @@ fn create_collection_refuses_a_taken_name() {
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
}
#[test]
fn stored_variable_ids_detach_deleted_aliases_per_mode() {
for collection in [false, true] {
let mut s = Session::new();
for name in ["Base", "Theme"] {
ex(&mut s, "variable.createCollection", json!({"name": name, "modes": ["Light", "Dark"]}));
}
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
let surface = ex(
&mut s,
"variable.create",
json!({"collection": "Base", "name": "surface", "type": "color", "values": {"Light": "#0D99FF", "Dark": "#000000"}}),
);
// Use a different collection only when deleting the whole target collection.
let home = if collection { "Theme" } else { "Base" };
let alias = ex(&mut s, "variable.create", json!({"collection": home, "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
let aid = alias["id"].as_str().unwrap();
let before = s.doc().unwrap().variables.get_by_id(aid).unwrap().clone();
let expected: std::collections::BTreeMap<_, _> = before
.values
.keys()
.map(|m| {
let modes = std::collections::BTreeMap::from([(before.collection.clone(), m.clone())]);
(m.clone(), s.doc().unwrap().variables.resolve(aid, &modes).unwrap())
})
.collect();
if collection {
ex(&mut s, "variable.deleteCollection", json!({"collection": "Base"}));
} else {
ex(&mut s, "variable.delete", json!({"variable": "surface"}));
}
assert_eq!(s.doc().unwrap().variables.get_by_id(aid).unwrap().values, expected);
}
}
#[test]
fn stored_variable_ids_keep_collection_edits_on_the_resolved_collection() {
let mut s = Session::new();
let shadow = ex(&mut s, "variable.createCollection", json!({"name": "Shadow", "modes": ["Only"]}));
let theme = ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "number", "value": 1}));
ex(&mut s, "variable.renameCollection", json!({"collection": shadow["id"], "name": theme["id"]}));
ex(&mut s, "variable.set", json!({"variable": "surface", "mode": "Dark", "value": 7}));
let doc = s.doc().unwrap();
let coll = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap();
let mode = coll.find_mode("Dark").unwrap().id.clone();
let modes = std::collections::BTreeMap::from([(coll.id.clone(), mode)]);
assert_eq!(doc.variables.resolve(surface["id"].as_str().unwrap(), &modes), Some(figcraft_doc::VariableValue::Number(7.0)));
// Mode commands resolve the user's collection argument before checking its stored id.
let doc = s.doc_mut().unwrap();
let mut extended = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap().clone();
extended.id = "extended".into();
extended.name = "Extended".into();
extended.extends = Some(theme["id"].as_str().unwrap().into());
doc.variables.collections.push(extended);
doc.variables.collections[0].name = "extended".into();
let before = s.doc().unwrap().clone();
assert!(s.execute("variable.removeMode", &json!({"collection": "Extended", "mode": "Dark"})).is_err());
assert_eq!(s.doc().unwrap(), &before);
}
+83
View File
@@ -231,6 +231,39 @@ fn variables_export_and_import_round_trip() {
let _ = std::fs::remove_file(&path);
}
#[test]
fn stored_variable_ids_keep_remapped_import_aliases() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "number", "value": 100}))["id"]
.as_str()
.unwrap()
.to_string();
let target = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "target", "type": "number", "value": 5}))["id"]
.as_str()
.unwrap()
.to_string();
ex(&mut s, "variable.update", json!({"variable": shadow, "name": target}));
let imported = json!({"meta": {
"variableCollections": {"c": {"id": "c", "name": "Theme", "modes": [{"modeId": "m", "name": "Mode 1"}]}},
"variables": {
"t": {"id": "t", "name": "target", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": 5}},
"a": {"id": "a", "name": "alias", "variableCollectionId": "c", "resolvedType": "FLOAT",
"valuesByMode": {"m": {"type": "VARIABLE_ALIAS", "id": "t"}}}
}
}});
let result = ex(&mut s, "variable.import", json!({"variables": imported}));
assert_eq!(result["warnings"], json!([]));
let vars = &s.doc().unwrap().variables;
let alias = vars.get("alias").unwrap();
assert_eq!(alias.values.values().next(), Some(&VariableValue::Alias(target.clone())));
assert_eq!(vars.resolve(&alias.id, &Default::default()), Some(VariableValue::Number(5.0)));
// The command's alias argument remains name-accepting, unlike the import's remapped id.
ex(&mut s, "variable.set", json!({"variable": "alias", "value": {"alias": target}}));
let vars = &s.doc().unwrap().variables;
assert_eq!(vars.resolve(&vars.get("alias").unwrap().id, &Default::default()), Some(VariableValue::Number(100.0)));
}
#[test]
fn variables_import_checks_what_it_reads() {
let mut s = Session::new();
@@ -555,3 +588,53 @@ fn css_export_neutralises_every_css_newline_in_strings() {
assert!(css.contains("\"a b c\""), "{css:?}");
}
}
#[test]
fn stored_variable_ids_keep_exported_css_alias_names() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}));
assert!(css["text"].as_str().unwrap().contains("--alias: var(--surface);"));
}
#[test]
fn stored_variable_ids_reset_only_the_inherited_parent_and_collection() {
for collection_shadow in [false, true] {
let mut s = Session::new();
let shadow_coll = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
ex(&mut s, "variable.createCollection", json!({"name": "Base", "modes": ["Light", "Dark"]}));
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "number", "value": 0}));
let surface =
ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "number", "values": {"Light": 4, "Dark": 8}}));
if collection_shadow {
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_coll["id"], "name": "extended"}));
} else {
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
}
let doc = s.doc_mut().unwrap();
let parent = doc.variables.get_by_id(surface["id"].as_str().unwrap()).unwrap().clone();
let mut coll = doc.variables.collection_by_id(&parent.collection).unwrap().clone();
let (light, dark) = (coll.find_mode("Light").unwrap().id.clone(), coll.find_mode("Dark").unwrap().id.clone());
coll.extends = Some(coll.id.clone());
coll.id = "extended".into();
coll.name = "Extended".into();
let mut inherited = parent.clone();
inherited.id = "inherited".into();
inherited.name = "inherited".into();
inherited.collection = coll.id.clone();
inherited.extends = Some(parent.id);
inherited.overrides = inherited.values.keys().cloned().collect();
inherited.values.values_mut().for_each(|v| *v = VariableValue::Number(2.0));
doc.variables.collections.push(coll);
doc.variables.variables.push(inherited);
ex(&mut s, "variable.resetOverride", json!({"variable": "inherited", "mode": "Dark"}));
let result = s.doc().unwrap().variables.get_by_id("inherited").unwrap();
assert_eq!(result.values[&light], VariableValue::Number(2.0));
assert_eq!(result.values[&dark], VariableValue::Number(8.0));
assert_eq!(result.overrides, std::collections::BTreeSet::from([light]));
}
}
+63 -3
View File
@@ -53,6 +53,7 @@ mod text_wrap_tests;
#[cfg(test)]
mod value_budget_tests;
use std::cell::Cell;
use std::collections::BTreeMap;
use std::io::Read;
use std::sync::Arc;
@@ -69,6 +70,13 @@ const MAX_CANVAS: u64 = 256 * 1024 * 1024;
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
/// Largest image member read.
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
/// 64-bit targets, lower on 32-bit targets to leave address space for the canvas, layers and
/// decoded pixels.
#[cfg(not(target_pointer_width = "32"))]
const MAX_IMAGE_BYTES: u64 = crate::IMAGE_BUDGET.bytes;
#[cfg(target_pointer_width = "32")]
const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024;
/// Most chunks read from `canvas.fig`.
const MAX_CHUNKS: usize = 16;
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
@@ -107,7 +115,12 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option<Result<(Document, Vec<Stri
/// Build a document from a `.fig` file. Returns it with the warnings about what was skipped or
/// approximated. `name`: the document name when the file doesn't carry one.
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), FormatError> {
import_with_image_limit(bytes, name, MAX_IMAGE_BYTES)
}
fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result<(Document, Vec<String>), FormatError> {
let mut archive = Archive::open(bytes)?;
archive.image_budget.remaining.set(image_limit);
// The budgets follow the compressed canvas, which the file itself bounds (its zip member may
// inflate too).
let packed = archive.canvas.len().min(bytes.len());
@@ -127,6 +140,10 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
drop(msg);
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
let skipped = archive.image_budget.skipped.get();
if skipped > 0 {
warnings.push(format!("{skipped} ZIP image(s) skipped by the total ZIP image byte budget ({image_limit} bytes)"));
}
if board {
doc.kind = FileKind::Board;
}
@@ -220,18 +237,27 @@ fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> {
blobs.get(index)?.get("bytes").and_then(Kv::bytes)
}
/// Bytes left for ZIP image reads, including damaged images. The REST resolver caches references,
/// including failures, so each referenced member is read or counted as skipped only once.
struct ZipImageBudget {
remaining: Cell<u64>,
skipped: Cell<usize>,
}
/// The parts of a `.fig` archive the import reads.
struct Archive<'b> {
canvas: Vec<u8>,
file_name: Option<String>,
/// `images/<hash>` members (or nothing for a bare canvas).
zip: Option<zip::ZipArchive<std::io::Cursor<&'b [u8]>>>,
image_budget: ZipImageBudget,
}
impl<'b> Archive<'b> {
fn open(bytes: &'b [u8]) -> Result<Self, FormatError> {
let image_budget = ZipImageBudget { remaining: Cell::new(MAX_IMAGE_BYTES), skipped: Cell::new(0) };
if bytes.starts_with(b"fig-") {
return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None });
return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None, image_budget });
}
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?;
let canvas = crate::read_member(&mut z, "canvas.fig", MAX_CANVAS)?;
@@ -239,17 +265,51 @@ impl<'b> Archive<'b> {
.ok()
.and_then(|m| serde_json::from_slice::<serde_json::Value>(&m).ok())
.and_then(|m| m.get("file_name").and_then(serde_json::Value::as_str).map(str::to_string));
Ok(Archive { canvas, file_name, zip: Some(z) })
Ok(Archive { canvas, file_name, zip: Some(z), image_budget })
}
/// Bytes of image `hash` (lowercase hex) from `images/`.
fn image(&self, hash: &str) -> Option<Vec<u8>> {
let budget = &self.image_budget;
if hash.is_empty() || hash.len() > 128 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
let mut z = self.zip.clone()?;
crate::read_member(&mut z, &format!("images/{hash}"), MAX_IMAGE).ok()
let f = z.by_name(&format!("images/{hash}")).ok()?;
let size = f.size();
if size > MAX_IMAGE {
return None;
}
let remaining = budget.remaining.get();
if size > remaining {
budget.skipped.set(budget.skipped.get() + 1);
return None;
}
let checksum = f.crc32();
let mut data = Vec::new();
// Probe past the per-image cap only when the total budget has room. Even a dishonest
// size cannot make the read exceed the remaining total; failed reads keep their charge.
let limit = remaining.min(MAX_IMAGE + 1);
let read = f.take(limit).read_to_end(&mut data);
let used = data.len() as u64;
budget.remaining.set(remaining - used);
if used > size && used == remaining {
budget.skipped.set(budget.skipped.get() + 1);
}
if read.is_err() || used != size {
return None;
}
// `take` can stop exactly at the budget without asking ZIP for EOF (where it checks CRC).
// Check it here too, without spending an extra byte on an overflow probe.
if used == limit {
let mut crc = flate2::Crc::new();
crc.update(&data);
if crc.sum() != checksum {
return None;
}
}
Some(data)
}
}
+2 -2
View File
@@ -251,9 +251,9 @@ impl<'d> Exporter<'d> {
}
}
/// A reference to variable `var` (its id or name) as variable data.
/// A reference to variable `var` (its stored id) as variable data.
fn alias(&mut self, var: &str) -> Option<Kv<'static>> {
let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
let found = self.doc.variables.get_by_id(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
let Some((kind, local)) = found else {
self.warn("bindings to variables that aren't in the document were written as their values");
return None;
+227
View File
@@ -795,6 +795,233 @@ fn hashes_naming_one_blob_share_one_copy_of_it() {
assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
}
mod image_budget {
use super::*;
fn image(hash: u8) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([hash, 0, 0, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
fn file(references: &[u8], members: &[(u8, Vec<u8>)]) -> Vec<u8> {
let mut nodes = vec![node(0, None, "DOCUMENT", "Document", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![])];
for (i, hash) in references.iter().enumerate() {
nodes.push(node(
i as u64 + 2,
Some((1, &format!("{i:04}"))),
"RECTANGLE",
&format!("Image {i}"),
vec![
("size", xy(10.0, 10.0)),
("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![*hash; 20]))]))])])),
],
));
}
let canvas = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))]));
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("canvas.fig", deflated).unwrap();
z.write_all(&canvas).unwrap();
for (hash, bytes) in members {
z.start_file(format!("images/{}", format!("{hash:02x}").repeat(20)), deflated).unwrap();
z.write_all(bytes).unwrap();
}
z.finish().unwrap().into_inner()
}
fn bytes(doc: &Document, index: usize) -> &[u8] {
let node = named(doc, &format!("Image {index}"));
let figcraft_doc::color::PaintKind::Image(fill) = &node.fills[0].kind else { panic!("not an image fill") };
&doc.images[&fill.image].bytes
}
fn budget_warnings(warnings: &[String]) -> Vec<&str> {
warnings.iter().filter(|w| w.contains("total ZIP image byte budget")).map(String::as_str).collect()
}
fn change_member_field(zip: &mut [u8], hash: u8, offset: usize, value: u32) {
let start = {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&*zip)).unwrap();
z.by_name(&format!("images/{}", format!("{hash:02x}").repeat(20))).unwrap().central_header_start() as usize
};
zip[start + offset..start + offset + 4].copy_from_slice(&value.to_le_bytes());
}
#[test]
fn exact_total_limit_keeps_all_images_and_each_import_gets_its_own_budget() {
let members = [(1, image(1)), (2, image(2))];
let limit = members.iter().map(|(_, b)| b.len() as u64).sum();
let zip = file(&[1, 2], &members);
for _ in 0..2 {
let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap();
assert_eq!(bytes(&doc, 0), members[0].1);
assert_eq!(bytes(&doc, 1), members[1].1);
assert!(warnings.is_empty(), "{warnings:?}");
}
}
#[test]
fn individually_acceptable_images_overflow_the_total_and_leave_placeholders() {
let members = [(1, image(1)), (2, image(2))];
let limit = members.iter().map(|(_, b)| b.len() as u64).sum::<u64>() - 1;
assert!(members.iter().all(|(_, b)| b.len() as u64 <= limit));
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap();
assert_eq!(bytes(&doc, 0), members[0].1);
assert_ne!(bytes(&doc, 1), members[1].1);
assert!(!doc.images.contains_key(&crate::image_hash(&members[1].1)));
let notes = budget_warnings(&warnings);
assert_eq!(notes.len(), 1, "{warnings:?}");
assert!(notes[0].contains("1 ZIP image") && notes[0].contains(&limit.to_string()), "{warnings:?}");
}
#[test]
fn repeated_references_do_not_charge_or_count_a_rejection_twice() {
let members = [(1, image(1)), (2, image(2))];
let (doc, warnings) = import_with_image_limit(&file(&[1, 1, 2, 2, 1], &members), "Images", members[0].1.len() as u64).unwrap();
for i in [0, 1, 4] {
assert_eq!(bytes(&doc, i), members[0].1);
}
assert_eq!(bytes(&doc, 2), bytes(&doc, 3));
assert_ne!(bytes(&doc, 2), members[1].1);
let notes = budget_warnings(&warnings);
assert_eq!(notes.len(), 1, "{warnings:?}");
assert!(notes[0].contains("1 ZIP image"), "{warnings:?}");
}
#[test]
fn distinct_members_with_identical_contents_still_use_the_read_budget() {
let png = image(1);
let members = [(1, png.clone()), (2, png.clone())];
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap();
assert_eq!(bytes(&doc, 0), png);
assert_ne!(bytes(&doc, 1), png);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn rejecting_a_member_before_reading_leaves_room_for_a_smaller_one() {
let png = image(1);
let members = [(1, vec![0; png.len() + 1]), (2, png.clone())];
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap();
assert_ne!(bytes(&doc, 0), members[0].1);
assert_eq!(bytes(&doc, 1), png);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn failed_crc_reads_still_consume_the_bytes_read() {
let members = [(1, image(1)), (2, image(2))];
let mut zip = file(&[1, 2], &members);
change_member_field(&mut zip, 1, 16, 0);
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64 + 1).unwrap();
assert_ne!(bytes(&doc, 0), members[0].1);
assert_ne!(bytes(&doc, 1), members[1].1);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn a_truncated_deflate_read_keeps_its_partial_charge() {
let members = [(1, image(1)), (2, image(2))];
let mut zip = file(&[1, 2], &members);
// Shorten the first member's compressed size, cutting its deflate stream mid-image.
change_member_field(&mut zip, 1, 20, 15);
let limit = members[0].1.len() as u64;
let archive = Archive::open(&zip).unwrap();
archive.image_budget.remaining.set(limit);
assert!(archive.image(&"01".repeat(20)).is_none());
let remaining = archive.image_budget.remaining.get();
assert!(remaining > 0 && remaining < limit, "partial read left {remaining} of {limit} bytes");
assert!(archive.image(&"02".repeat(20)).is_none());
assert_eq!(archive.image_budget.remaining.get(), remaining);
let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap();
assert_ne!(bytes(&doc, 1), members[1].1);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn the_individual_image_cap_is_preserved_without_reading_a_large_member() {
let members = [(1, image(1)), (2, image(2))];
let mut zip = file(&[1, 2], &members);
change_member_field(&mut zip, 1, 24, (MAX_IMAGE + 1) as u32);
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[1].1.len() as u64).unwrap();
assert_ne!(bytes(&doc, 0), members[0].1);
assert_eq!(bytes(&doc, 1), members[1].1);
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
}
#[test]
fn crc_is_checked_even_when_the_read_ends_exactly_at_the_budget() {
let members = [(1, image(1))];
let mut zip = file(&[1], &members);
change_member_field(&mut zip, 1, 16, 0);
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64).unwrap();
assert_ne!(bytes(&doc, 0), members[0].1);
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
}
#[test]
fn an_understated_size_cannot_read_past_the_remaining_budget() {
let members = [(1, image(1)), (2, image(2))];
let mut zip = file(&[1, 2], &members);
change_member_field(&mut zip, 1, 24, 1);
let archive = Archive::open(&zip).unwrap();
archive.image_budget.remaining.set(members[0].1.len() as u64 - 1);
assert!(archive.image(&"01".repeat(20)).is_none());
assert_eq!(archive.image_budget.remaining.get(), 0);
assert_eq!(archive.image_budget.skipped.get(), 1);
assert!(archive.image(&"02".repeat(20)).is_none());
assert_eq!(archive.image_budget.remaining.get(), 0);
assert_eq!(archive.image_budget.skipped.get(), 2);
}
#[test]
fn invalid_image_contents_are_charged_before_image_validation() {
let members = [(1, vec![0; 8]), (2, image(2))];
let limit = members[1].1.len() as u64 + 7;
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap();
assert_ne!(bytes(&doc, 1), members[1].1);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn missing_and_unreferenced_members_do_not_consume_the_budget() {
let members = [(1, image(1)), (2, vec![0; 128])];
let (doc, warnings) = import_with_image_limit(&file(&[9, 1], &members), "Images", members[0].1.len() as u64).unwrap();
assert_eq!(bytes(&doc, 1), members[0].1);
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
}
#[test]
fn embedded_image_fallback_is_independent_of_the_zip_budget() {
let canvas = canvas(&schema(), &message_with_image_in_blobs(0));
for bytes in [&canvas, &zip_file(&canvas)] {
let (doc, warnings) = import_with_image_limit(bytes, "Images", 0).unwrap();
let photo = named(&doc, "Photo");
let figcraft_doc::color::PaintKind::Image(fill) = &photo.fills[0].kind else { panic!("not an image fill") };
assert_eq!(*doc.images[&fill.image].bytes, png());
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
assert_eq!(budget_warnings(&warnings).len(), usize::from(bytes.starts_with(b"PK")), "{warnings:?}");
}
}
#[test]
fn default_import_preserves_image_bytes_dimensions_and_existing_warnings() {
let members = [(1, image(1)), (2, image(2))];
let (doc, warnings) = import(&file(&[1, 2], &members), "Images").unwrap();
assert_eq!(bytes(&doc, 0), members[0].1);
assert_eq!(bytes(&doc, 1), members[1].1);
assert!(doc.images.values().all(|img| (img.width, img.height) == (2, 1)));
assert!(warnings.is_empty(), "{warnings:?}");
let (doc, warnings) = import(&fixture(), "Fixture").unwrap();
assert!(doc.images.values().any(|img| *img.bytes == png() && (img.width, img.height) == (2, 1)));
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
}
}
#[test]
fn other_files_are_not_fig() {
assert!(try_import(b"{\"document\": {}}", "x").is_none());
+1 -1
View File
@@ -1657,7 +1657,7 @@ impl<'a> Exporter<'a> {
if let Some(name) = self.token_names.get(variable) {
return Some(name.clone());
}
let var = self.doc.variables.get(variable)?;
let var = self.doc.variables.get_by_id(variable)?;
let name = if variable.starts_with("penpot:tokens:") {
var.name.split_once('/').map_or(var.name.clone(), |(_, n)| n.to_owned())
} else {
+24
View File
@@ -909,3 +909,27 @@ fn page_guides_reloaded_from_a_figcraft_file_are_exported() {
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
assert_eq!(page_json["guides"].as_object().unwrap().values().next().unwrap()["position"], 410);
}
#[test]
fn stored_variable_ids_keep_penpot_token_identity() {
for missing in [false, true] {
let mut doc = Document::new("Token identity");
doc.variables.collections.push(Collection {
id: "c".into(),
name: "Theme".into(),
modes: vec![Mode { id: "m".into(), name: "Default".into() }],
extends: None,
});
doc.variables.variables.push(variable("shadow", "target", "c", VariableType::Number, &[("m", VariableValue::Number(20.0))]));
if !missing {
doc.variables.variables.push(variable("target", "surface", "c", VariableType::Number, &[("m", VariableValue::Number(40.0))]));
}
let p = page(&doc);
let r = add(&mut doc, p, NodeKind::Rectangle, "Bound", 0.0, 0.0, 40.0, 10.0);
doc.node_mut(r).unwrap().bound.insert("width".into(), "target".into());
let exported = entries(&export(&doc).unwrap().0);
let width = &shape(&exported, "Bound")["appliedTokens"]["width"];
let expected = if missing { Value::Null } else { json!("surface") };
assert_eq!(width, &expected);
}
}
@@ -248,3 +248,34 @@ fn junk_in_conditional_actions_never_panics() {
}
}
}
#[test]
fn stored_variable_ids_keep_imported_mode_lookup_identity() {
for collection in [false, true] {
let mut vars = variables_json();
if collection {
let mut shadow = vars["meta"]["variableCollections"][THEME].clone();
shadow["id"] = json!("VariableCollectionId:0:0");
shadow["name"] = json!(THEME);
shadow["modes"] = json!([{"modeId": "other", "name": "Other"}]);
vars["meta"]["variableCollections"]["VariableCollectionId:0:0"] = shadow;
} else {
let mut shadow = vars["meta"]["variables"][SPACE].clone();
shadow["id"] = json!("VariableID:0:0");
shadow["name"] = json!(LABEL);
vars["meta"]["variables"]["VariableID:0:0"] = shadow;
}
let lookup = expr("VAR_MODE_LOOKUP", vec![var(LABEL), data("STRING", json!("1:0"))]);
let (doc, warnings) = import_with(&file(vec![on("ON_CLICK", vec![set(LABEL, lookup)])]), Some(&vars), &no_images).unwrap();
let actions = &button(&doc).interactions[0].actions;
assert_eq!(
actions[0],
Action::SetVariable {
variable: LABEL.into(),
value: Value::Null,
expr: Some(Expr::InMode { variable: LABEL.into(), mode: "1:0".into() }),
},
"{warnings:?}"
);
}
}
+2 -2
View File
@@ -442,8 +442,8 @@ impl Ctx<'_> {
};
let vars = &self.doc.variables;
let found = parts.filter(|(id, _)| self.var_kinds.contains_key(*id)).and_then(|(id, mode)| {
let v = vars.get(id)?;
let m = vars.collection(&v.collection)?.modes.iter().find(|m| m.id == mode)?;
let v = vars.get_by_id(id)?;
let m = vars.collection_by_id(&v.collection)?.modes.iter().find(|m| m.id == mode)?;
Some(Expr::InMode { variable: v.id.clone(), mode: m.id.clone() })
});
if found.is_none() {
+4 -2
View File
@@ -252,7 +252,8 @@ impl Headless {
)),
"document.inspect" => {
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize;
Ok(figcraft_engine::inspect::document(&self.session, depth, b(&p, "allPages")))
let options = figcraft_engine::inspect::InspectOptions::parse(&p)?;
Ok(figcraft_engine::inspect::document_with_options(&self.session, depth, b(&p, "allPages"), &options))
}
"node.get" => {
let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required (a node id)")?;
@@ -261,7 +262,8 @@ impl Headless {
if !doc.contains(id) {
return Err(format!("no such node {id}"));
}
Ok(figcraft_engine::inspect::node_json(doc, id, depth))
let options = figcraft_engine::inspect::InspectOptions::parse(&p)?;
Ok(figcraft_engine::inspect::node_json_with_options(doc, id, depth, &options))
}
"ui.inspect" => Ok(self.inspect_ui()),
"ui.pointer" => self.pointer(&p),
+35 -8
View File
@@ -64,6 +64,15 @@ fn id_schema(desc: &str) -> Value {
fn ids_schema(desc: &str) -> Value {
json!({"type": "array", "description": desc, "items": {"anyOf": [{"type": "integer"}, {"type": "string"}]}})
}
fn fields_schema() -> Value {
json!({"type": "array", "items": {"type": "string", "enum": figcraft_engine::inspect::NODE_KEYS},
"description": "Exact, case-sensitive top-level node keys to include at every level, pages too (e.g. name, type, x, fills, characters, pluginData). Omit for all properties; [] keeps only ids and structure. id, children, childCount and the transport's truncated marker are always kept. Selecting visible emits an explicit boolean for every node. Unknown keys are an error."})
}
fn outline_schema() -> Value {
boolean(
"Compact tree: id, name, type, characters (first 120 characters, then ... only if longer), mainComponentName and explicit visible booleans, plus structure. Cannot combine true with fields; false permits fields.",
)
}
fn obj(props: Value, required: &[&str]) -> Value {
let mut o = json!({"type": "object", "properties": props});
if !required.is_empty() {
@@ -213,6 +222,8 @@ fn tools() -> Vec<Value> {
obj(
json!({
"depth": int("Tree depth for the current page (default 4)"),
"fields": fields_schema(),
"outline": outline_schema(),
"allPages": boolean("Expand every page, not only the current one"),
"maxBytes": int(
"Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id"
@@ -230,6 +241,8 @@ fn tools() -> Vec<Value> {
json!({
"id": id_schema("Layer id"),
"depth": int("Child depth (default 1)"),
"fields": fields_schema(),
"outline": outline_schema(),
"offset": int("First child to report (default 0), for reading a node with more children than fit in one message"),
"maxBytes": int(
"Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id"
@@ -751,19 +764,31 @@ fn wire_size(value: &Value) -> usize {
/// The document at `depth`, and what its text block would take on the wire. The tree is sent as a
/// text block, so it is measured pretty-printed *and* escaped -- escaping adds about a tenth.
fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool) -> Result<(Value, usize), String> {
let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?;
fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool, options: &Value) -> Result<(Value, usize), String> {
let mut params = options.clone();
params["depth"] = json!(depth);
params["allPages"] = json!(all);
let value = b.call("document.inspect", params)?;
let wire = wire_size(&value);
Ok((value, wire))
}
/// One layer's subtree at `depth`, and what its text block would take on the wire.
fn node_at(b: &mut dyn Backend, id: u64, depth: u64) -> Result<(Value, usize), String> {
let value = b.call("node.get", json!({"id": id, "depth": depth}))?;
fn node_at(b: &mut dyn Backend, id: u64, depth: u64, options: &Value) -> Result<(Value, usize), String> {
let mut params = options.clone();
params["id"] = json!(id);
params["depth"] = json!(depth);
let value = b.call("node.get", params)?;
let wire = wire_size(&value);
Ok((value, wire))
}
fn inspection_options(a: &Map<String, Value>) -> Result<Value, String> {
let options = Value::Object(a.iter().filter(|(k, _)| matches!(k.as_str(), "fields" | "outline")).map(|(k, v)| (k.clone(), v.clone())).collect());
figcraft_engine::inspect::InspectOptions::parse(&options)?;
Ok(options)
}
fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
let depth = n_arg(a, "depth")?.unwrap_or(4.0).clamp(0.0, 64.0) as u64;
let all = match a.get("allPages") {
@@ -771,9 +796,10 @@ fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolR
Some(Value::Bool(x)) => *x,
Some(_) => return Err("`allPages` must be a boolean".into()),
};
let options = inspection_options(a)?;
let (max_bytes, budget) = budget(a)?;
let (value, wire) = inspect_at(b, depth, all)?;
let (value, wire) = inspect_at(b, depth, all, &options)?;
if wire <= budget {
return Ok(ToolResult::json(&value));
}
@@ -791,7 +817,7 @@ fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolR
let mut best: Option<(u64, Value, usize)> = None;
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let (v, w) = inspect_at(b, mid, all)?;
let (v, w) = inspect_at(b, mid, all, &options)?;
if w + MARKER_RESERVE <= budget {
best = Some((mid, v, w));
if mid == hi {
@@ -840,9 +866,10 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
let id = id_arg(a.get("id").ok_or("`id` is required")?)?;
let depth = n_arg(a, "depth")?.unwrap_or(1.0).clamp(0.0, 64.0) as u64;
let offset = n_arg(a, "offset")?.unwrap_or(0.0).max(0.0).min(u32::MAX as f64) as usize;
let options = inspection_options(a)?;
let (max_bytes, budget) = budget(a)?;
let (value, wire) = node_at(b, id, depth)?;
let (value, wire) = node_at(b, id, depth, &options)?;
if value.is_null() {
return Err(format!("no such layer {id}"));
}
@@ -858,7 +885,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
let (mut lo, mut hi) = (1u64, depth - 1);
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let (v, w) = node_at(b, id, mid)?;
let (v, w) = node_at(b, id, mid, &options)?;
if w + MARKER_RESERVE <= budget {
(used, value, used_wire) = (mid, v, w);
if mid == hi {
+18
View File
@@ -64,6 +64,24 @@ fn attached_backend_forwards_everything() {
assert_eq!(err["isError"], true);
}
#[test]
fn attached_inspection_forwards_compact_options_to_the_app() {
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
let created = call(&mut s, "create_node", json!({"type": "text", "text": "界".repeat(121), "name": "Title"}));
let id = serde_json::from_str::<Value>(created["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone();
let r = call(&mut s, "get_node", json!({"id": id, "outline": true}));
assert_eq!(r["isError"], false, "{r}");
let outline: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(outline["characters"], format!("{}...", "界".repeat(120)));
assert_eq!(outline["visible"], true);
assert!(outline.get("fontSize").is_none());
let r = call(&mut s, "inspect_document", json!({"fields": [], "outline": false}));
assert_eq!(r["isError"], false, "{r}");
let compact: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(compact["pages"][0]["children"][0], json!({"id": id}));
assert!(compact["pages"][0].get("name").is_none());
}
#[test]
fn an_attached_agent_comments_under_its_own_name() {
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
+207
View File
@@ -0,0 +1,207 @@
//! Compact trees are built by the backend, including every size-budget retry.
use crate::common::Client;
use figcraft_mcp::{Backend, Headless, call_tool};
use serde_json::{Value, json};
fn tree(mut create: impl FnMut(Value) -> Value) -> Value {
let root = create(json!({"type": "frame", "name": "Product list", "width": 1200, "height": 2400}));
for i in 0..24 {
let card = create(json!({"type": "frame", "parent": root, "name": format!("Product card {i}"), "width": 280, "height": 160,
"props": {"fill": "#F0F0F0", "cornerRadius": 12, "pluginData": {"source": "synthetic".repeat(40)}}}));
for j in 0..6 {
create(json!({"type": "text", "parent": card, "name": format!("Description {j}"), "text": "Synthetic product description. ".repeat(8),
"x": 12, "y": j * 20, "width": 240}));
}
}
root
}
fn compact_keys(node: &Value, outline: bool) {
let allowed: &[&str] = if outline {
&["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount", "truncated"]
} else {
&["id", "name", "children", "childCount", "truncated"]
};
for key in node.as_object().unwrap().keys() {
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {node}");
}
for child in node["children"].as_array().into_iter().flatten() {
compact_keys(child, outline);
}
}
#[test]
fn tool_schemas_describe_compact_inspection_options() {
let mut c = Client::new();
let tools = c.rpc("tools/list", json!({}));
for name in ["inspect_document", "get_node"] {
let tool = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap();
let props = &tool["inputSchema"]["properties"];
assert_eq!(props["fields"]["type"], "array");
assert_eq!(props["fields"]["items"]["type"], "string");
assert!(props["fields"]["items"]["enum"].as_array().unwrap().contains(&json!("absoluteBounds")));
assert_eq!(props["outline"]["type"], "boolean");
assert!(props["fields"]["description"].as_str().unwrap().contains("case-sensitive"));
assert!(props["outline"]["description"].as_str().unwrap().contains("120"));
}
}
#[test]
fn a_realistic_nested_tree_is_much_smaller_with_fields_or_outline() {
let mut c = Client::new();
let root = tree(|params| c.create(params));
for (tool, mut args) in [("inspect_document", json!({"depth": 6})), ("get_node", json!({"id": root, "depth": 6}))] {
let full = c.ok(tool, args.clone());
let full_bytes = full.to_string().len();
args["outline"] = json!(true);
let outline = c.ok(tool, args.clone());
assert!(outline.to_string().len() * 2 < full_bytes, "{tool}: outline {} vs full {full_bytes}", outline.to_string().len());
args.as_object_mut().unwrap().remove("outline");
args["fields"] = json!([]);
let ids = c.ok(tool, args);
assert!(ids.to_string().len() * 5 < full_bytes, "{tool}: ids {} vs full {full_bytes}", ids.to_string().len());
if tool == "inspect_document" {
assert_eq!(outline["history"], full["history"]);
assert_eq!(ids["selection"], full["selection"]);
compact_keys(&outline["pages"][0], true);
} else {
compact_keys(&outline, true);
}
}
}
struct Recording {
inner: Headless,
calls: Vec<(String, Value)>,
}
impl Backend for Recording {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.calls.push((method.into(), params.clone()));
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"recording headless".into()
}
}
#[test]
fn fields_and_outline_survive_every_depth_retry() {
let mut backend = Recording { inner: Headless::new(), calls: Vec::new() };
let root = tree(|params| {
let result = call_tool(&mut backend, "create_node", &params);
assert!(!result.is_error, "{}", result.first_text());
serde_json::from_str::<Value>(result.first_text()).unwrap()["id"].clone()
});
for options in [json!({"fields": ["name"], "outline": false}), json!({"outline": true})] {
for (tool, method) in [("inspect_document", "document.inspect"), ("get_node", "node.get")] {
backend.calls.clear();
let mut args = options.clone();
if tool == "get_node" {
args["id"] = root.clone();
}
args["depth"] = json!(6);
args["maxBytes"] = json!(6000);
let result = call_tool(&mut backend, tool, &args);
assert!(!result.is_error, "{}", result.first_text());
let value: Value = serde_json::from_str(result.first_text()).unwrap();
assert!(value["truncated"].is_object(), "{tool}: {value}");
assert!(backend.calls.len() > 1, "must exercise retries: {tool}");
for (called, params) in &backend.calls {
assert_eq!(called, method);
for key in ["fields", "outline"] {
assert_eq!(params.get(key), options.get(key), "{tool}: {params}");
}
}
let outline = options["outline"] == true;
if tool == "inspect_document" {
compact_keys(&value["pages"][0], outline);
} else {
compact_keys(&value, outline);
}
}
}
}
#[test]
fn compact_children_keep_window_markers_and_page_in_order() {
let mut c = Client::new();
let root = tree(|params| c.create(params));
let full = c.ok("get_node", json!({"id": root, "depth": 1, "fields": []}));
let expected = full["children"].as_array().unwrap();
let wire = serde_json::to_string(&serde_json::to_string_pretty(&full).unwrap()).unwrap().len();
let limit = wire + 1024 - 1;
assert!(limit > 2048, "room for both the envelope and the marker");
let mut seen = Vec::new();
let mut offset = Some(0);
let mut pages = 0;
while let Some(at) = offset {
let page = c.ok("get_node", json!({"id": root, "depth": 1, "fields": [], "offset": at, "maxBytes": limit}));
assert!(page["truncated"]["children"].is_object(), "{page}");
let wire = serde_json::to_string(&serde_json::to_string_pretty(&page).unwrap()).unwrap().len();
assert!(wire + 1024 <= limit, "page at {at} exceeds maxBytes: {wire} + envelope > {limit}");
assert_eq!(page["truncated"]["children"]["from"], at);
seen.extend(page["children"].as_array().unwrap().iter().cloned());
offset = page["truncated"]["children"]["nextOffset"].as_u64();
pages += 1;
assert!(pages <= expected.len());
}
assert!(pages > 1, "the fixture must require a child window");
assert_eq!(&seen, expected);
}
fn json_example(section: &str) -> &str {
section.split("```json\n").nth(1).unwrap().split("```").next().unwrap()
}
#[test]
fn compact_examples_run_verbatim_from_the_docs() {
let mut c = Client::new();
for _ in 0..2 {
c.create(json!({"type": "rectangle"}));
}
let docs = include_str!("../../../../docs/mcp.md");
let section = docs.split("### Compact layer trees\n").nth(1).unwrap();
for line in json_example(section).lines() {
let reply: Value = serde_json::from_str(&c.server.handle_line(line).unwrap()).unwrap();
assert_eq!(reply["result"]["isError"], false, "{reply}");
}
let mut h = Headless::new();
for _ in 0..2 {
h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap();
}
let docs = include_str!("../../../../docs/control-protocol.md");
let section = docs.split("### `node.get ").nth(1).unwrap();
for line in json_example(section).lines() {
let request: Value = serde_json::from_str(line).unwrap();
h.call(request["method"].as_str().unwrap(), request["params"].clone()).unwrap();
}
}
#[test]
fn invalid_compact_options_fail_in_tools_and_headless_control() {
let mut c = Client::new();
let id = c.create(json!({"type": "rectangle"}));
let mut h = Headless::new();
let created = h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap();
assert_eq!(created["id"], id);
for mut args in [
json!({"fields": [1]}),
json!({"fields": null}),
json!({"fields": ["Name"]}),
json!({"outline": "yes"}),
json!({"outline": true, "fields": []}),
] {
args["id"] = id.clone();
for tool in ["inspect_document", "get_node"] {
assert!(!c.err(tool, args.clone()).is_empty());
}
for method in ["document.inspect", "node.get"] {
assert!(h.call(method, args.clone()).is_err(), "{method}: {args}");
}
}
}
+2
View File
@@ -17,6 +17,8 @@ mod export_xml;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod garbage;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod inspect_fields;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod inspect_response_limit;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod overlay_anchor;
+22 -5
View File
@@ -413,6 +413,19 @@ fn patch(last: Frame, doc: &Arc<Document>, page: NodeId, view: &View, px: (i64,
} else if dy < 0 {
damage.push(Px { x0: 0, y0: 0, x1: w, y1: reach - dy });
}
// And at the edges that moved in: a backdrop there read what is now past the edge.
if reach > 0 {
if dx > 0 {
damage.push(Px { x0: 0, y0: 0, x1: reach, y1: h });
} else if dx < 0 {
damage.push(Px { x0: w - reach, y0: 0, x1: w, y1: h });
}
if dy > 0 {
damage.push(Px { x0: 0, y0: 0, x1: w, y1: reach });
} else if dy < 0 {
damage.push(Px { x0: 0, y0: h - reach, x1: w, y1: h });
}
}
// Layers that changed, where they were and where they are now.
if !Arc::ptr_eq(&last.doc, doc) {
let mut areas = Vec::new();
@@ -528,18 +541,22 @@ struct FrameRender<'a> {
opts: &'a Options,
}
/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`.
/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`. The margin stops
/// at the frame's edge: background blurs and glass read what is drawn under them, and a full
/// render's backdrops end where the frame does.
fn render_into(frame: FrameRender<'_>, d: &Px, margin: i64, pix: &mut Pixmap) -> Option<()> {
let FrameRender { doc, page, view, px, opts } = frame;
let s = view.scale;
let (ox, oy) = (d.x0 - margin, d.y0 - margin);
let (rw, rh) = (u32::try_from(d.x1 - d.x0 + 2 * margin).ok()?, u32::try_from(d.y1 - d.y0 + 2 * margin).ok()?);
let (w, h) = (i64::from(pix.width()), i64::from(pix.height()));
let (ox, oy) = ((d.x0 - margin).max(0), (d.y0 - margin).max(0));
let (ex, ey) = ((d.x1 + margin).min(w), (d.y1 + margin).min(h));
let (rw, rh) = (u32::try_from(ex - ox).ok()?, u32::try_from(ey - oy).ok()?);
// The same pixel grid as the frame: origin (px + offset) / scale.
let sub = View { origin: ((px.0 + ox) as f64 / s, (px.1 + oy) as f64 / s), scale: s, width: rw, height: rh };
let part = render_view(doc, page, &sub, opts)?;
let inner = IntRect::from_xywh(
i32::try_from(margin).ok()?,
i32::try_from(margin).ok()?,
i32::try_from(d.x0 - ox).ok()?,
i32::try_from(d.y0 - oy).ok()?,
u32::try_from(d.x1 - d.x0).ok()?,
u32::try_from(d.y1 - d.y0).ok()?,
)?;
+83
View File
@@ -456,3 +456,86 @@ fn assert_within(a: &Pixmap, b: &Pixmap, rows_down: u32, tol: u8, what: &str) {
let worst = a.data().iter().zip(below).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
assert!(worst <= tol, "{what}: differs by {worst} levels");
}
#[test]
fn backdrops_at_the_views_edge_patch_as_they_render() {
fonts();
let blur = |r: f64| {
let mut e = Effect::background_blur();
if let EffectKind::BackgroundBlur(b) = &mut e.kind {
b.radius = r;
}
e
};
let mut glass = Effect::glass();
if let EffectKind::Glass(g) = &mut glass.kind {
(g.depth, g.refraction) = (30.0, 1.0);
}
// A layer reading its backdrop across the view's left edge, over stripes, and a square moved
// beside it in the view and off it: what the patch reads past the edge a full render doesn't.
for (why, fx) in [("a background blur of 60", blur(60.0)), ("glass", glass)] {
for (from, by) in [((180.0, 100.0), (15.0, 10.0)), ((-150.0, 100.0), (20.0, 0.0))] {
let mut d = Document::new("edge");
let p = d.first_page().unwrap();
for k in 0..20 {
let s = d.create(p, NodeKind::Rectangle, "S", -200.0 + f64::from(k) * 20.0, 0.0, 10.0, 400.0).unwrap();
solid(d.node_mut(s).unwrap(), if k % 2 == 0 { "FF0000" } else { "0000FF" });
}
let e = d.create(p, NodeKind::Rectangle, "E", -60.0, 50.0, 160.0, 200.0).unwrap();
let n = d.node_mut(e).unwrap();
n.fills = vec![Paint { opacity: 0.2, ..Paint::solid(Color::WHITE) }];
n.effects = vec![fx.clone()];
let m = d.create(p, NodeKind::Rectangle, "M", from.0, from.1, 40.0, 40.0).unwrap();
solid(d.node_mut(m).unwrap(), "00AA00");
let before = Arc::new(d);
let after = edit(&before, |d| d.node_mut(m).unwrap().transform = Affine::translate((from.0 + by.0, from.1 + by.1)));
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 300 };
let mut r = Retained::default();
r.render(&before, p, &v, &Options::default()).unwrap();
let (shown, pix, work) = r.render(&after, p, &v, &Options::default()).unwrap();
let pix = pix.clone();
let full = render_view(&after, p, &shown, &Options::default()).unwrap();
let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert!(worst <= 2, "{why}, a square moved from {from:?}: {work:?} is {worst} levels off a full render");
}
}
}
#[test]
fn pans_patch_backdrops_at_the_edges_that_moved_in() {
fonts();
let mut blur = Effect::background_blur();
if let EffectKind::BackgroundBlur(b) = &mut blur.kind {
b.radius = 10.0;
}
let mut glass = Effect::glass();
if let EffectKind::Glass(g) = &mut glass.kind {
(g.depth, g.refraction) = (30.0, 1.0);
}
// A layer reading its backdrop across all four edges of the view, over stripes: after a pan,
// what it read past an edge that moved in is past the edge no more.
for (why, fx) in [("a background blur of 10", blur), ("glass", glass)] {
let mut d = Document::new("pan");
let p = d.first_page().unwrap();
for k in 0..40 {
let s = d.create(p, NodeKind::Rectangle, "S", -200.0 + f64::from(k) * 20.0, -100.0, 10.0, 600.0).unwrap();
solid(d.node_mut(s).unwrap(), if k % 2 == 0 { "FF0000" } else { "0000FF" });
}
let e = d.create(p, NodeKind::Rectangle, "E", -60.0, -60.0, 500.0, 420.0).unwrap();
let n = d.node_mut(e).unwrap();
n.fills = vec![Paint { opacity: 0.2, ..Paint::solid(Color::WHITE) }];
n.effects = vec![fx];
let doc = Arc::new(d);
for (dx, dy) in [(30.0, 0.0), (-30.0, 0.0), (0.0, 30.0), (0.0, -30.0)] {
let mut r = Retained::default();
r.render(&doc, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 300 }, &Options::default()).unwrap();
let v = View { origin: (dx, dy), scale: 1.0, width: 400, height: 300 };
let (shown, pix, work) = r.render(&doc, p, &v, &Options::default()).unwrap();
let pix = pix.clone();
let full = render_view(&doc, p, &shown, &Options::default()).unwrap();
let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert!(partial(work), "{why}, a pan by ({dx}, {dy}): {work:?}");
assert!(worst <= 2, "{why}, a pan by ({dx}, {dy}): {work:?} is {worst} levels off a full render");
}
}
}
+9 -9
View File
@@ -1075,7 +1075,7 @@ status = "draft — needs native review"
"Remove row" = "Zeile entfernen"
"Remove slot" = "Slot entfernen"
"Remove starting point" = "Startpunkt entfernen"
"Remove stop" = "Farbunterbrechung entfernen"
"Remove stop" = "Verlaufsstopp entfernen"
"Remove text from path" = "Text vom Pfad lösen"
"Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen"
"Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen"
@@ -1288,7 +1288,7 @@ status = "draft — needs native review"
"Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis"
"Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen"
"Start on path" = "Anfang auf dem Pfad"
"Start on the Home screen" = "Mit Start beginnen"
"Start on the Home screen" = "Mit dem Startbildschirm starten"
"Start point" = "Startpunkt"
"Start sharing" = "Teilen starten"
"Start vote" = "Abstimmung starten"
@@ -1305,7 +1305,7 @@ status = "draft — needs native review"
"Stop sharing" = "Teilen beenden"
"Stop spotlighting" = "Spotlight beenden"
"Stop timer" = "Timer stoppen"
"Stop {n} {hex}" = "Farbunterbrechung {n} {hex}"
"Stop {n} {hex}" = "Verlaufsstopp {n} {hex}"
"Straight" = "Gerade"
"Stretch" = "Strecken"
"Strikethrough" = "Durchgestrichen"
@@ -1316,11 +1316,11 @@ status = "draft — needs native review"
"Strokes" = "Konturen"
"Style" = "Stil"
"Styles" = "Stile"
"Stylistic set 1" = "Stilistischer Satz 1"
"Stylistic set 2" = "Stilistischer Satz 2"
"Stylistic set 3" = "Stilistischer Satz 3"
"Stylistic set 4" = "Stilistischer Satz 4"
"Stylistic set 5" = "Stilistischer Satz 5"
"Stylistic set 1" = "Stilsatz 1"
"Stylistic set 2" = "Stilsatz 2"
"Stylistic set 3" = "Stilsatz 3"
"Stylistic set 4" = "Stilsatz 4"
"Stylistic set 5" = "Stilsatz 5"
"Subtract" = "Subtrahieren"
"Subtracts" = "Subtraktionen"
"Suffix" = "Suffix"
@@ -2051,7 +2051,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Bewegung reduzieren"
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
"pref.startOnHome" = "Mit Start beginnen"
"pref.startOnHome" = "Mit dem Startbildschirm starten"
"pref.userName" = "Ihr Name"
"proto.action.add" = "Prototyp-Aktion hinzufügen"
"proto.action.move" = "Prototyp-Aktion neu anordnen"
+6 -2
View File
@@ -1398,12 +1398,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
)),
"document.inspect" => {
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize;
in_document(app, p, |s| Ok(figcraft_engine::inspect::document(s, depth, b(p, "allPages"))))
let options = figcraft_engine::inspect::InspectOptions::parse(p)?;
in_document(app, p, |s| Ok(figcraft_engine::inspect::document_with_options(s, depth, b(p, "allPages"), &options)))
}
"node.get" => {
let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?;
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize;
in_document(app, p, |s| s.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string()))
let options = figcraft_engine::inspect::InspectOptions::parse(p)?;
in_document(app, p, |s| {
s.doc().map(|d| figcraft_engine::inspect::node_json_with_options(d, id, depth, &options)).map_err(|e| e.to_string())
})
}
"window.new" => {
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
@@ -0,0 +1,48 @@
//! Attached control replies build compact layer trees before they leave the app.
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::layers_drag_tests::Ui;
fn call(u: &mut Ui, method: &str, params: Value) -> Result<Value, String> {
crate::control::handle(&mut u.app, &u.ctx.clone(), method, &params)
}
#[test]
fn control_inspection_filters_nodes_and_pages_before_replying() {
let mut u = Ui::new(Session::new());
let frame = call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "frame", "name": "Card"}})).unwrap()["id"].clone();
let text =
call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "text", "parent": frame, "text": "界".repeat(121)}}))
.unwrap()["id"]
.clone();
let default = call(&mut u, "document.inspect", json!({"depth": 3})).unwrap();
let selection = u.app.session.selection();
let history = u.app.session.undo_depth();
let compact = call(&mut u, "document.inspect", json!({"depth": 3, "fields": []})).unwrap();
let page = &compact["pages"][0];
assert!(page.get("name").is_none());
assert_eq!(page["children"][0]["children"][0], json!({"id": text}));
let shallow = call(&mut u, "node.get", json!({"id": frame, "depth": 0, "fields": []})).unwrap();
assert_eq!(shallow, json!({"id": frame, "childCount": 1}));
let outline = call(&mut u, "node.get", json!({"id": frame, "depth": 1, "outline": true})).unwrap();
assert_eq!(outline["children"][0]["characters"], format!("{}...", "界".repeat(120)));
assert_eq!(outline["visible"], true);
assert!(outline.get("fills").is_none());
assert_eq!(u.app.session.selection(), selection);
assert_eq!(u.app.session.undo_depth(), history);
assert_eq!(call(&mut u, "document.inspect", json!({"depth": 3})).unwrap(), default);
}
#[test]
fn control_inspection_rejects_invalid_options_consistently() {
let mut u = Ui::new(Session::new());
for method in ["document.inspect", "node.get"] {
for mut params in [json!({"fields": ["Name"]}), json!({"fields": [2]}), json!({"outline": "true"}), json!({"outline": true, "fields": []})] {
params["id"] = json!(u.app.session.page().unwrap());
let error = call(&mut u, method, params).unwrap_err();
assert!(error.contains("fields") || error.contains("field") || error.contains("outline"), "{error}");
}
}
}
+2
View File
@@ -122,6 +122,8 @@ mod ime_tests;
#[cfg(test)]
mod import_report_tests;
#[cfg(test)]
mod inspect_fields_tests;
#[cfg(test)]
mod layers_behaviour_tests;
#[cfg(test)]
mod layers_drag_tests;
+2 -2
View File
@@ -131,7 +131,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc<PublishedLibrary>]) -> Ve
let (id, name) = (lib.manifest.id.as_str(), library::library_name(doc, &lib.manifest.id));
for c in library::published_collections(lib) {
let local = copy(id, &c.id).map(|r| r.local.as_str());
let coll = local.and_then(|l| doc.variables.collection(l)).unwrap_or(c);
let coll = local.and_then(|l| doc.variables.collection_by_id(l)).unwrap_or(c);
if several(coll) {
out.push(ModeRow { coll, library: Some((id, name)), key: Some(&c.id), local });
}
@@ -141,7 +141,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc<PublishedLibrary>]) -> Ve
if out.iter().any(|x| x.local == Some(r.local.as_str())) {
continue;
}
if let Some(c) = doc.variables.collection(&r.local).filter(|c| several(c)) {
if let Some(c) = doc.variables.collection_by_id(&r.local).filter(|c| several(c)) {
out.push(ModeRow { coll: c, library: Some((&r.library, library::library_name(doc, &r.library))), key: None, local: Some(&r.local) });
}
}
+1 -1
View File
@@ -232,7 +232,7 @@ impl Presenter {
Action::SetVariableMode { collection, mode } => {
let Some(d) = self.design.as_mut() else { return false };
let before = d.clone();
let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode);
let set = figcraft_engine::proto_run::set_mode_by_id(d, self.current, collection, mode);
if set {
self.settle(&before);
}
+29 -2
View File
@@ -76,7 +76,8 @@ fn counter_then_conditional_navigation() {
#[test]
fn set_variable_mode_recolours_bound_layers() {
let (mut doc, a, _, button) = setup();
on_click(&mut doc, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Dark".into() }]);
let theme = doc.variables.collection("Theme").unwrap().id.clone();
on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.clone(), mode: "Dark".into() }]);
let hex = |p: &Presenter| p.doc.as_ref().and_then(|d| d.node(a)?.fills.first()?.solid_color()).map(|c| c.to_hex());
let mut p = Presenter::new(a);
p.sync(&Arc::new(doc), 0.0);
@@ -85,7 +86,7 @@ fn set_variable_mode_recolours_bound_layers() {
assert_eq!(hex(&p).as_deref(), Some("000000"));
// An unknown mode does nothing.
let mut d2 = p.doc.clone().unwrap();
on_click(&mut d2, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Sepia".into() }]);
on_click(&mut d2, button, vec![Action::SetVariableMode { collection: theme, mode: "Sepia".into() }]);
let mut p2 = Presenter::new(a);
p2.sync(&Arc::new(d2), 0.0);
assert!(!p2.fire(button, 1, None, 1.0));
@@ -104,3 +105,29 @@ fn deeply_nested_conditionals_stop() {
assert!(!p.fire(button, 0, None, 1.0));
assert_eq!(p.current, a);
}
#[test]
fn stored_variable_ids_keep_player_collection_identity() {
for missing in [false, true] {
let (mut doc, a, _, button) = setup();
let theme = doc.variables.collections[0].clone();
let mut shadow = theme.clone();
shadow.id = "shadow".into();
shadow.name = theme.id.clone();
doc.variables.collections.insert(0, shadow);
if missing {
doc.variables.collections.retain(|c| c.id != theme.id);
}
on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.id.clone(), mode: "Dark".into() }]);
let mut p = Presenter::new(a);
p.sync(&Arc::new(doc), 0.0);
assert_eq!(p.fire(button, 0, None, 1.0), !missing);
let played = p.doc.as_ref().unwrap();
let modes = &played.node(played.page_of(a).unwrap()).unwrap().var_modes;
assert!(!modes.contains_key("shadow"));
if !missing {
let fill = played.node(a).unwrap().fills[0].solid_color().unwrap();
assert_eq!(fill.to_hex(), "000000");
}
}
}
@@ -133,6 +133,34 @@ fn brand_library(tag: &str) -> (String, String) {
(path, lid)
}
#[test]
fn stored_variable_ids_keep_library_mode_picker_rows() {
let (path, lid) = brand_library("stored-ids");
let mut s = Session::new();
let f = id(&s.execute("frame.create", &json!({"width": 100, "height": 100})).unwrap());
s.execute("variable.createCollection", &json!({"name": "shadow"})).unwrap();
s.execute("library.add", &json!({"path": path})).unwrap();
s.execute("library.setVariableMode", &json!({"library": lid, "collection": "Brand", "mode": "Dark", "ids": [f]})).unwrap();
let local = s.doc().unwrap().libraries.collections[0].local.clone();
s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": local})).unwrap();
let mut u = Ui::new(s);
let doc = u.app.session.doc().unwrap();
let libs = crate::variable_ui::libraries(&u.app, doc);
// Both a read library and an unavailable one keep the actual copy's modes, not its namesake's.
for available in [libs.as_slice(), &[]] {
let rows = crate::variable_ui::mode_rows(doc, available);
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!(rows[0].coll.id, local);
assert_eq!(rows[0].coll.name, "Brand");
assert_eq!(rows[0].coll.modes.len(), 2);
assert_eq!(rows[0].local, Some(local.as_str()));
assert_eq!(rows[0].library.unwrap().0, lid);
}
u.run("select.set", json!({"ids": [f]}));
u.frames(2, Vec::new);
let _ = std::fs::remove_file(&path);
}
#[test]
fn library_collections_show_in_the_mode_picker_under_their_library() {
let (path, lid) = brand_library("picker");
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -68,7 +68,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values |
| Code syntax per platform (Web, iOS, Android, Flutter) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose/ArkUI/Flutter code doesn't name variables yet |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Explicit modes per frame, library collections' modes | done | |
| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there |
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
| Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); CSS custom properties and Dart constants (Flutter) export; a variables file named by path must be a regular file of at most 256 MiB; in the browser the export arrives as a download (import there still needs a file picker) |
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged |
| 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. |
| 2026-10-30 | minor | M8, #1097: library updates from a team library a file names are offered, not fetched from its server by themselves: only team libraries you added or checked are checked in the background (**Check for updates** in Assets ▸ Libraries). Numbers unchanged. |
| 2026-10-30 | minor | M8, #1114: variables import from an explicit file path (`variable.import`, the `variables` of `file.open` and `file.import`) takes regular files of at most 256 MiB, the limit the sibling `variables.json` already had; anything else is refused before the document changes. Grade unchanged. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads` |
+23 -3
View File
@@ -163,14 +163,15 @@ against the shared log, not by the UI thread, so it does not freeze the app, use
need a drawn frame: it works while the window is minimised. Other requests on the same connection
wait until the poll finishes (at most 25 s); use a separate connection to keep editing meanwhile.
### `document.inspect {depth?, allPages?}`
### `document.inspect {depth?, allPages?, fields?, outline?}`
The active document as JSON: name, path, dirty, currentPage, selection, pages (the current page's
layer tree to `depth`, default 8; others summarised unless `allPages`), styles, variable count,
images, the colour profile (`colorProfile`: `srgb` or `displayP3`), the text being edited and the
undo history.
undo history. `fields` and `outline` select layer properties at every level, pages included, without
changing document metadata (see below).
### `node.get {id, depth?}`
### `node.get {id, depth?, fields?, outline?}`
One layer (children to `depth`, default 1). Fields: id, name, type, x, y, width, height, rotation,
absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, effects, cornerRadius,
@@ -187,6 +188,25 @@ starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written wi
only when there is some). The auto layout props
that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know).
Both inspection methods accept `fields: [string]`: exact, case-sensitive top-level node keys such
as `name`, `type`, `x`, `fills`, `characters` or `pluginData`, applied recursively to pages too.
`[]` keeps only ids and structure. `id`, `children` and depth-boundary `childCount` are always kept.
An unknown key is an error that lists the valid keys; a `fields` that is not an array of strings,
or an `outline` that is not a boolean, is an error too.
`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus
structure. `characters` is cut after 120 characters (Unicode scalars), with ASCII `...` appended
only when longer. When `visible` is selected (by either option), every node has an explicit
boolean; without options the output stays sparse. `outline: true` with `fields` is an error;
`outline: false` with `fields` is allowed. Document metadata is unchanged. Through
`engine.execute`, the engine command `node.get` takes the same options but no `depth`; it always
returns one level of children.
```json
{"id":5,"method":"document.inspect","params":{"depth":6,"outline":true}}
{"id":6,"method":"node.get","params":{"id":3,"depth":2,"fields":["name","width","height"]}}
```
### `ui.inspect {}`
UI state: panels and tabs (`panels`: the `left` and `right` panels' widths as asked for and whether the left one is closed, `leftCollapsed`), the cursor the canvas shows (`cursor`: a CSS cursor name such as `default`, `crosshair`, `ew-resize`, `grab`, `copy`, `not-allowed`, `zoom-in`, or one FigCraft draws: `rotate <degrees>`, `pen`, `pen-close`, `pen-add`, `pen-bend`, `eyedropper`, `comment`; `null` while the pointer is elsewhere or over something with a cursor of its own, a ruler guide or a gradient handle; the engine's own hint is `tool.overlay.cursor`), current tool (`tool`: its id, overlay hints and, for a selected
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -123,6 +123,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed |
| 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged |
| 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. |
+24 -2
View File
@@ -353,8 +353,8 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`), which keeps one copy of the document however many steps there are (an `edit.undo` step undoes the batch's edits so far together); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
| `inspect_document {depth, allPages}` | The document as JSON (its `id`, layer tree, styles, selection, history). The tools that act on a document also take `document` (see Naming the document below). |
| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
| `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (its `id`, layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). The tools that act on a document also take `document` (see Naming the document below). |
| `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
@@ -366,6 +366,28 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
| `inspect_ui {}` | Tool, view and status (and panels, timings and the app's `windows` when attached). |
| `wait_for_changes {after?, timeoutMs?, topics?, limit?}` | Wait for new comment events in the attached app; returns pending events at once, else waits up to `timeoutMs` (default 20 s, max 25 s). Pass the returned `cursor` back to keep watching. Headless returns immediately. |
### Compact layer trees
`inspect_document` and `get_node` accept `fields`, an array of exact, case-sensitive top-level node
keys, applied to every layer and page. For example, `fields: ["name", "type", "width", "height"]`
leaves out paints and text properties; `fields: []` keeps only ids and structure. `id`, `children`,
depth-boundary `childCount` and the transport's `truncated` marker are always kept (`truncated` is
not a selectable field). An unknown key is an error that lists the valid keys; a `fields` that is
not an array of strings, or an `outline` that is not a boolean, is an error too. Document metadata
stays unchanged.
`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus
structure. `characters` is cut after 120 characters (Unicode scalars), with ASCII `...` appended
only when longer. Selecting `visible`, explicitly or through outline, reports a boolean even for
visible layers and pages;
without these options the output stays sparse. `outline: true` cannot be combined with `fields`;
`outline: false` can. Both options keep the existing depth and response-size limits.
```json
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"inspect_document","arguments":{"depth":6,"outline":true}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_node","arguments":{"id":3,"depth":2,"fields":["name","width","height"]}}}
```
### Starter templates
An agent can start from a template instead of an empty page. `run_command {command: "template.list"}`
+7 -4
View File
File diff suppressed because one or more lines are too long