- Main components, instances with overrides that survive edits, component sets with variants, properties, swap instance, detach, reset overrides and go to main component, and an Assets panel with live thumbnails, search and drag-to-canvas into frames and auto layout. Local styles and variables (colour, number, string, boolean) with modes, aliases and scopes, edited in a Variables table.
+ Main components, instances with overrides that survive edits, component sets with variants, properties, swap instance, detach, reset overrides and go to main component, and an Assets panel with live thumbnails, search and drag-to-canvas into frames and auto layout. Local styles and variables (colour, number, string, boolean) with modes, aliases and scopes, edited in a Variables table and bound to fills, strokes, effects, layout grids and instance properties. Fills, strokes, effects and layout grids reorder by dragging their rows.
diff --git a/ROADMAP.md b/ROADMAP.md
index 5353783..680d135 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -118,7 +118,10 @@ dimension measured side by side.
"Library updates available" covers changed variables and bound layers follow). Explicit variable
modes in the Design panel: a frame's mode per collection, Auto (inherited) or its own, with
enabled libraries' collections listed under the library's name (picking a mode copies the
- collection in). *Left:* QA with larger real-world libraries. 1 h.
+ collection in). Instance boolean and text properties bound to boolean and string variables
+ (following modes, renames, reset and detach); layout grid settings in the Design panel whose
+ count, size, margin/offset and gutter bind to number variables; fills, strokes, effects and
+ layout grids reordered by dragging their rows, their variable bindings following. *Left:* QA with larger real-world libraries. 1 h.
5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor,
flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter.
Custom bezier and custom spring easings with editor fields; device frames (phone, tablet,
@@ -267,7 +270,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Auto layout & constraints | 9 | 76% | grid `fr` rows around fixed-size children, QA with real-world files | 1–2 |
| Text | 8 | 78% | list markers on the right of right-to-left paragraphs, visual QA | 1–2 |
| Components & variants | 9 | 76% | QA with larger real-world libraries | 1 |
-| Styles & variables | 5 | 86% | grid and component property bindings, reordering paints and effects in the panel | 1–2 |
+| Styles & variables | 5 | 89% | variant properties and gradient stops bound to variables, REST import of bindings past the first paint and on layout grids, a colour picker for layout grid colours | 1 |
| Prototyping | 6 | 86% | building conditionals in the interaction editor, hotspot hints, inspect in the presenter | 3–5 |
| Import/export | 6 | 66% | .fig import (best effort), variable-mode lookups in prototype expressions, PDF print options (bleed, CMYK) | 10–20 |
| Dev mode / inspect | 2 | 84% | changes since ready for dev (compare view), focus view on one frame, annotation categories, visual QA | 1–2 |
diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs
index 09419dc..c64b960 100644
--- a/crates/engine/src/cmd/component.rs
+++ b/crates/engine/src/cmd/component.rs
@@ -433,6 +433,8 @@ fn detach(s: &mut Session, p: &Value) -> Result {
n.source = None;
n.overrides.clear();
n.prop_refs.remove("mainComponent");
+ // No properties left to drive.
+ n.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
for nn in nested {
rebase_instance(doc, nn);
}
@@ -471,6 +473,10 @@ fn reset_instance(s: &mut Session, p: &Value) -> Result {
{
*props = defaults;
}
+ // ...and stop following variables.
+ if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) {
+ doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
+ }
done.push(inst);
}
if done.is_empty() {
@@ -797,6 +803,43 @@ fn swap(s: &mut Session, p: &Value) -> Result {
ok()
}
+/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
+/// boolean property takes a boolean variable, a text property a string one).
+pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> {
+ let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else {
+ return Err(format!("{id} is not an instance (component properties are bound on instances)"));
+ };
+ match (prop_defs(doc, *component).get(name), kind) {
+ (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()),
+ (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")),
+ (Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")),
+ (Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")),
+ (None, _) => Err(format!("the instance has no property named `{name}`")),
+ }
+}
+
+/// Write bound variable values into instance `id`'s boolean and text property values (only what
+/// changed; the instance then follows its new values like after `instance.setProperties`).
+pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) {
+ let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return };
+ let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return };
+ let mut next = props.clone();
+ for (name, val) in sets {
+ let v = match (defs.get(name), val) {
+ (Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b),
+ (Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t),
+ _ => continue,
+ };
+ next.insert(name.clone(), v);
+ }
+ if next != *props
+ && let Ok(n) = doc.get_mut(id)
+ && let NodeKind::Instance { props, .. } = &mut n.kind
+ {
+ *props = next;
+ }
+}
+
/// Coerce a property value to the definition's type.
fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Result {
match def {
@@ -1097,13 +1140,21 @@ fn rename_prop_refs(doc: &mut Document, comp: NodeId, old: &str, new: Option<&st
}
}
}
+ // Instances' values and the variables bound to them follow (a deleted property drops both).
+ let old_key = format!("{PROP_BINDING}{old}");
for inst in instances_of(doc, comp) {
- if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind
+ let n = doc.get_mut(inst)?;
+ if let NodeKind::Instance { props, .. } = &mut n.kind
&& let Some(v) = props.remove(old)
&& let Some(nw) = new
{
props.insert(nw.to_string(), v);
}
+ if let Some(var) = n.bound.remove(&old_key)
+ && let Some(nw) = new
+ {
+ n.bound.insert(format!("{PROP_BINDING}{nw}"), var);
+ }
}
Ok(())
}
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 043600d..d824ea1 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -215,37 +215,8 @@ pub fn specs() -> Vec {
spec!("effect.remove", "Remove effect", "{ids?, index}", true, |s, p| {
let i = req_index("effect.remove", p, "index")?;
for id in req_targets("effect.remove", s, p)? {
- let n = s.doc_mut()?.get_mut(id)?;
- if i < n.effects.len() {
- n.effects.remove(i);
- // Its bindings go with it; later effects' move down one.
- super::styles::remap_effect_bindings(n, |k| match k.cmp(&i) {
- std::cmp::Ordering::Less => Some(k),
- std::cmp::Ordering::Equal => None,
- std::cmp::Ordering::Greater => Some(k - 1),
- });
- }
- }
- ok()
- }),
- spec!("effect.move", "Move effect", "{ids?, from, to} (indices in the effect list, bottom first)", true, |s, p| {
- let from = req_index("effect.move", p, "from")?;
- let to = req_index("effect.move", p, "to")?;
- for id in req_targets("effect.move", s, p)? {
- let n = s.doc_mut()?.get_mut(id)?;
- if from >= n.effects.len() || to >= n.effects.len() || from == to {
- continue;
- }
- let e = n.effects.remove(from);
- n.effects.insert(to, e);
- // Bindings follow their effects.
- super::styles::remap_effect_bindings(n, |k| {
- if k == from {
- return Some(to);
- }
- let k = if k > from { k - 1 } else { k };
- Some(if k >= to { k + 1 } else { k })
- });
+ // Its bindings go with it; later effects' move down one.
+ super::lists::remove_item(s.doc_mut()?.get_mut(id)?, super::lists::BoundList::Effects, i);
}
ok()
}),
@@ -268,12 +239,9 @@ pub fn specs() -> Vec {
ok()
}),
spec!("grid.remove", "Remove layout grid", "{ids?, index}", true, |s, p| {
- let i = req_num("grid.remove", p, "index")? as usize;
+ let i = req_index("grid.remove", p, "index")?;
for id in req_targets("grid.remove", s, p)? {
- let n = s.doc_mut()?.get_mut(id)?;
- if i < n.grids.len() {
- n.grids.remove(i);
- }
+ super::lists::remove_item(s.doc_mut()?.get_mut(id)?, super::lists::BoundList::Grids, i);
}
ok()
}),
@@ -980,6 +948,7 @@ fn reparent(s: &mut Session, p: &Value) -> Result {
fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result {
let cmd = if fills { "fill" } else { "stroke" };
let ids = req_targets(cmd, s, p)?;
+ let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) };
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
@@ -994,17 +963,9 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result/color`, `effects//radius`,
+//! `layoutGrids//gutter`, …) with their items when a list is reordered or shrinks.
+//!
+//! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top;
+//! the Design panel shows them in reverse, the topmost row first.
+
+use figcraft_doc::*;
+use serde_json::Value;
+
+use super::{CommandSpec, ok};
+use crate::params::*;
+use crate::{Result, Session, spec};
+
+pub fn specs() -> Vec {
+ vec![
+ spec!("fill.move", "Move fill", "{ids?, from, to} (indices in the fill list, bottom first)", true, |s, p| {
+ move_item(s, p, "fill.move", BoundList::Fills)
+ }),
+ spec!("stroke.move", "Move stroke", "{ids?, from, to} (indices in the stroke list, bottom first)", true, |s, p| {
+ move_item(s, p, "stroke.move", BoundList::Strokes)
+ }),
+ spec!("effect.move", "Move effect", "{ids?, from, to} (indices in the effect list, bottom first)", true, |s, p| {
+ move_item(s, p, "effect.move", BoundList::Effects)
+ }),
+ spec!("grid.move", "Move layout grid", "{ids?, from, to} (indices in the layout grid list, bottom first)", true, |s, p| {
+ move_item(s, p, "grid.move", BoundList::Grids)
+ }),
+ ]
+}
+
+/// One of a layer's ordered lists whose items carry variable bindings.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum BoundList {
+ Fills,
+ Strokes,
+ Effects,
+ Grids,
+}
+
+impl BoundList {
+ /// The binding keys' prefix (`//`).
+ pub fn prefix(self) -> &'static str {
+ match self {
+ BoundList::Fills => "fills",
+ BoundList::Strokes => "strokes",
+ BoundList::Effects => "effects",
+ BoundList::Grids => "layoutGrids",
+ }
+ }
+
+ fn len(self, n: &Node) -> usize {
+ match self {
+ BoundList::Fills => n.fills.len(),
+ BoundList::Strokes => n.strokes.len(),
+ BoundList::Effects => n.effects.len(),
+ BoundList::Grids => n.grids.len(),
+ }
+ }
+
+ /// Unlink the style that defined the whole list (it no longer matches it).
+ fn detach_style(self, n: &mut Node) {
+ match self {
+ BoundList::Fills => n.styles.fill = None,
+ BoundList::Strokes => n.styles.stroke = None,
+ BoundList::Effects => n.styles.effect = None,
+ BoundList::Grids => n.styles.grid = None,
+ }
+ }
+
+ /// The binding key of `field` on item `index` (`fills/2/color`).
+ pub fn key(self, index: usize, field: &str) -> String {
+ format!("{}/{index}/{field}", self.prefix())
+ }
+}
+
+/// The list item a binding key refers to: its list, index and field. `fill`, `fills` and
+/// `fills/0` (and `stroke`) are the first paint's colour.
+pub fn list_binding(key: &str) -> Option<(BoundList, usize, &str)> {
+ match key {
+ "fill" | "fills" | "fills/0" => return Some((BoundList::Fills, 0, "color")),
+ "stroke" => return Some((BoundList::Strokes, 0, "color")),
+ _ => {}
+ }
+ let (prefix, rest) = key.split_once('/')?;
+ let list = [BoundList::Fills, BoundList::Strokes, BoundList::Effects, BoundList::Grids].into_iter().find(|l| l.prefix() == prefix)?;
+ let (i, field) = rest.split_once('/')?;
+ // Plain digits only (no sign, no "+1").
+ if i.is_empty() || !i.bytes().all(|b| b.is_ascii_digit()) || field.is_empty() {
+ return None;
+ }
+ Some((list, i.parse().ok()?, field))
+}
+
+/// The binding key on paint `index`'s colour that `n` uses, if any (`fill` for the first fill's,
+/// or `fills//color`).
+pub fn paint_binding_key(n: &Node, fills: bool, index: usize) -> Option<&str> {
+ let list = if fills { BoundList::Fills } else { BoundList::Strokes };
+ n.bound.keys().map(String::as_str).find(|k| list_binding(k) == Some((list, index, "color")))
+}
+
+/// Where item `k` lands when the item at `from` moves to `to`.
+pub fn moved_index(k: usize, from: usize, to: usize) -> usize {
+ if k == from {
+ return to;
+ }
+ let k = if k > from { k - 1 } else { k };
+ if k >= to { k + 1 } else { k }
+}
+
+/// Where item `k` lands when the item at `removed` goes (None: it is the one removed).
+pub fn removed_index(k: usize, removed: usize) -> Option {
+ match k.cmp(&removed) {
+ std::cmp::Ordering::Less => Some(k),
+ std::cmp::Ordering::Equal => None,
+ std::cmp::Ordering::Greater => Some(k - 1),
+ }
+}
+
+/// Keep `n`'s bindings on `list`'s items with their items when the list changes: `map` gives an
+/// item's new index, or None when it was removed (its bindings go with it). Bindings whose item
+/// keeps its place keep their key.
+pub(crate) fn remap_list_bindings(n: &mut Node, list: BoundList, map: impl Fn(usize) -> Option) {
+ let moved: Vec<(String, Option)> = n
+ .bound
+ .keys()
+ .filter_map(|k| {
+ let (l, i, field) = list_binding(k).filter(|(l, _, _)| *l == list)?;
+ match map(i) {
+ Some(j) if j == i => None,
+ Some(j) => Some((k.clone(), Some(l.key(j, field)))),
+ None => Some((k.clone(), None)),
+ }
+ })
+ .collect();
+ let mut kept = Vec::new();
+ for (old, new) in moved {
+ if let Some(var) = n.bound.remove(&old)
+ && let Some(new) = new
+ {
+ kept.push((new, var));
+ }
+ }
+ n.bound.extend(kept);
+}
+
+/// Move `v[from]` to `to`; false (and nothing changes) when either is out of range or they match.
+fn reorder(v: &mut Vec, from: usize, to: usize) -> bool {
+ if from >= v.len() || to >= v.len() || from == to {
+ return false;
+ }
+ let item = v.remove(from);
+ v.insert(to, item);
+ true
+}
+
+/// `fill.move` / `stroke.move` / `effect.move` / `grid.move`: the item keeps everything (an image
+/// fill its image and transform, a gradient its stops) and its bindings go with it.
+fn move_item(s: &mut Session, p: &Value, cmd: &str, list: BoundList) -> Result {
+ let from = req_index(cmd, p, "from")?;
+ let to = req_index(cmd, p, "to")?;
+ for id in req_targets(cmd, s, p)? {
+ let len = s.doc()?.node(id).map(|n| list.len(n)).unwrap_or(0);
+ if from >= len || to >= len || from == to {
+ continue;
+ }
+ let n = s.doc_mut()?.get_mut(id)?;
+ let moved = match list {
+ BoundList::Fills => reorder(&mut n.fills, from, to),
+ BoundList::Strokes => reorder(&mut n.strokes, from, to),
+ BoundList::Effects => reorder(&mut n.effects, from, to),
+ BoundList::Grids => reorder(&mut n.grids, from, to),
+ };
+ if moved {
+ remap_list_bindings(n, list, |k| Some(moved_index(k, from, to)));
+ list.detach_style(n);
+ }
+ }
+ ok()
+}
+
+/// Drop item `index` of `list` on `n` with its bindings (later items' bindings move down one).
+/// False when there is no such item.
+pub(crate) fn remove_item(n: &mut Node, list: BoundList, index: usize) -> bool {
+ if index >= list.len(n) {
+ return false;
+ }
+ match list {
+ BoundList::Fills => {
+ let _ = n.fills.remove(index);
+ }
+ BoundList::Strokes => {
+ let _ = n.strokes.remove(index);
+ }
+ BoundList::Effects => {
+ let _ = n.effects.remove(index);
+ }
+ BoundList::Grids => {
+ let _ = n.grids.remove(index);
+ }
+ }
+ remap_list_bindings(n, list, |k| removed_index(k, index));
+ true
+}
+
+/// Write bound numbers into a node's layout grids (`layoutGrids//`;
+/// only what changed, so unchanged layers stay shared).
+pub(crate) fn apply_grid_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, f64)]) {
+ let Some(mut grids) = doc.node(id).map(|n| n.grids.clone()) else { return };
+ for (i, field, x) in sets {
+ let Some(g) = grids.get_mut(*i).filter(|_| x.is_finite()) else { continue };
+ match field.as_str() {
+ "size" => g.size = x.max(0.0),
+ "count" => g.count = x.round().clamp(1.0, 1000.0) as u32,
+ "gutter" => g.gutter = x.max(0.0),
+ "offset" => g.offset = *x,
+ _ => {}
+ }
+ }
+ if doc.node(id).is_some_and(|n| n.grids != grids)
+ && let Ok(n) = doc.get_mut(id)
+ {
+ n.grids = grids;
+ }
+}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index a79e604..04b888a 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -9,6 +9,7 @@ pub mod file;
pub mod image;
pub mod layout;
pub mod library;
+pub mod lists;
pub mod page;
pub mod props;
pub mod proto;
@@ -67,6 +68,7 @@ fn registry() -> &'static Registry {
let mut list = Vec::new();
list.extend(create::specs());
list.extend(edit::specs());
+ list.extend(lists::specs());
list.extend(arrange::specs());
list.extend(structure::specs());
list.extend(section::specs());
diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs
index b0f4569..d8b338c 100644
--- a/crates/engine/src/cmd/styles.rs
+++ b/crates/engine/src/cmd/styles.rs
@@ -147,7 +147,7 @@ pub fn specs() -> Vec {
spec!(
"variable.bind",
"Bind variable",
- "{ids?, field (fill|stroke|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…), variable (null unbinds)}",
+ "{ids?, field (fill|stroke (first paint)|fills//color|strokes//color|effects//|layoutGrids//|componentProperties/ (instances: boolean/text properties)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
true,
bind
),
@@ -513,6 +513,12 @@ pub fn bind(s: &mut Session, p: &Value) -> Result {
Some(v) => Some(doc.variables.get(v).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such variable"))?),
None => None,
};
+ // An instance's component property takes a variable of its type.
+ if let (Some(name), Some(v)) = (field.strip_prefix(crate::components::PROP_BINDING), vid.as_ref().and_then(|v| doc.variables.get(v))) {
+ for i in &ids {
+ super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?;
+ }
+ }
for i in ids {
let n = doc.get_mut(i)?;
match &vid {
@@ -645,6 +651,7 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result {
/// Write bound variable values into nodes (called during normalisation).
pub fn apply_bindings(doc: &mut Document) {
+ use super::lists::{BoundList, list_binding};
if doc.variables.variables.is_empty() {
return;
}
@@ -655,24 +662,26 @@ pub fn apply_bindings(doc: &mut Document) {
let binds = n.bound.clone();
let mut sets = serde_json::Map::new();
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
- let mut paint_sets: Vec<(bool, Color)> = Vec::new();
+ let mut paint_sets: Vec<(bool, usize, Color)> = Vec::new();
+ let mut grid_sets: Vec<(usize, String, f64)> = Vec::new();
+ let mut prop_sets: Vec<(String, VariableValue)> = Vec::new();
for (field, var) in binds {
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
- // The first fill's or stroke's colour (the rest of the list stays).
- let paint = match field.as_str() {
- "fill" | "fills" | "fills/0" | "fills/0/color" => Some(true),
- "stroke" | "strokes/0/color" => Some(false),
- _ => None,
- };
- if let (Some(fills), VariableValue::Color(c)) = (paint, &val) {
- paint_sets.push((fills, *c));
+ // One list item's value: a paint's colour, an effect's or a layout grid's field.
+ if let Some((list, i, prop)) = list_binding(&field) {
+ match (list, &val) {
+ (BoundList::Fills | BoundList::Strokes, VariableValue::Color(c)) if prop == "color" => {
+ paint_sets.push((list == BoundList::Fills, i, *c));
+ }
+ (BoundList::Effects, _) => effect_sets.push((i, prop.to_string(), val)),
+ (BoundList::Grids, VariableValue::Number(x)) => grid_sets.push((i, prop.to_string(), *x)),
+ _ => {}
+ }
continue;
}
- // `effects//`: one effect's value.
- if let Some((i, prop)) = field.strip_prefix("effects/").and_then(|r| r.split_once('/'))
- && let Ok(i) = i.parse::()
- {
- effect_sets.push((i, prop.to_string(), val));
+ // `componentProperties/`: an instance's boolean or text property value.
+ if let Some(name) = field.strip_prefix(crate::components::PROP_BINDING) {
+ prop_sets.push((name.to_string(), val));
continue;
}
let jv = match &val {
@@ -684,34 +693,38 @@ pub fn apply_bindings(doc: &mut Document) {
VariableValue::Boolean(b) => json!(b),
VariableValue::Alias(_) => continue,
};
- let key = match field.as_str() {
- "fill" | "fills" | "fills/0" | "fills/0/color" => "fill",
- "stroke" | "strokes/0/color" => "stroke",
- other => other,
- };
- sets.insert(key.to_string(), jv);
+ sets.insert(field, jv);
+ }
+ if !sets.is_empty() {
+ let _ = super::props::apply("variables", doc, id, &sets);
}
- let _ = super::props::apply("variables", doc, id, &sets);
if !paint_sets.is_empty() {
apply_paint_bindings(doc, id, &paint_sets);
}
if !effect_sets.is_empty() {
apply_effect_bindings(doc, id, &effect_sets);
}
+ if !grid_sets.is_empty() {
+ super::lists::apply_grid_bindings(doc, id, &grid_sets);
+ }
+ if !prop_sets.is_empty() {
+ super::component::apply_prop_bindings(doc, id, &prop_sets);
+ }
}
}
-/// Write bound colours into a node's first fill (`true`) or stroke, keeping the rest of the list and
-/// the paint's opacity, visibility and blend mode (only what changed, so unchanged layers stay
-/// shared).
-fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, Color)]) {
+/// Write bound colours into a node's fills (`true`) or strokes by index, keeping the rest of the
+/// list and each paint's opacity, visibility and blend mode (only what changed, so unchanged layers
+/// stay shared). A first-paint binding on an empty list adds the paint.
+fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, usize, Color)]) {
let Some(n) = doc.node(id) else { return };
let (mut fills, mut strokes) = (n.fills.clone(), n.strokes.clone());
- for (is_fill, c) in sets {
+ for (is_fill, i, c) in sets {
let list = if *is_fill { &mut fills } else { &mut strokes };
- match list.first_mut() {
+ match list.get_mut(*i) {
Some(p) => p.kind = PaintKind::Solid { color: *c },
- None => list.push(Paint::solid(*c)),
+ None if *i == 0 => list.push(Paint::solid(*c)),
+ None => {}
}
}
if doc.node(id).is_some_and(|n| n.fills != fills || n.strokes != strokes)
@@ -722,29 +735,6 @@ fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, Color)])
}
}
-/// Keep a node's bindings on its effects' fields (`effects//`) with their effects
-/// when the list changes: `map` gives an effect's new index, or None when it was removed.
-pub(crate) fn remap_effect_bindings(n: &mut Node, map: impl Fn(usize) -> Option) {
- let moved: Vec<(String, Option)> = n
- .bound
- .keys()
- .filter_map(|k| {
- let (i, field) = k.strip_prefix("effects/")?.split_once('/')?;
- let i = i.parse::().ok()?;
- Some((k.clone(), map(i).map(|j| format!("effects/{j}/{field}"))))
- })
- .collect();
- let mut kept = Vec::new();
- for (old, new) in moved {
- if let Some(var) = n.bound.remove(&old)
- && let Some(new) = new
- {
- kept.push((new, var));
- }
- }
- n.bound.extend(kept);
-}
-
/// Write bound values into a node's effects (only what changed, so unchanged layers stay shared).
fn apply_effect_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, VariableValue)]) {
let Some(mut effects) = doc.node(id).map(|n| n.effects.clone()) else { return };
diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs
index eeb2833..a5ad6c7 100644
--- a/crates/engine/src/cmd/variables.rs
+++ b/crates/engine/src/cmd/variables.rs
@@ -6,6 +6,7 @@ use std::collections::BTreeSet;
use figcraft_doc::*;
use serde_json::{Value, json};
+use super::lists::BoundList;
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
@@ -237,14 +238,17 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static
"characters" => "TEXT_CONTENT",
"fontFamily" => "FONT_FAMILY",
"fontStyle" => "FONT_STYLE",
- "stroke" | "strokes/0/color" => "STROKE_COLOR",
- f if f.starts_with("effects/") && f.ends_with("/color") => "EFFECT_COLOR",
- f if f.starts_with("effects/") => "EFFECT_FLOAT",
- "fill" | "fills" | "fills/0" | "fills/0/color" => match doc.node(node).map(|n| &n.kind) {
- Some(NodeKind::Text(_)) => "TEXT_FILL",
- Some(k) if k.is_frame_like() => "FRAME_FILL",
- _ => "SHAPE_FILL",
+ f => match super::lists::list_binding(f)? {
+ (BoundList::Strokes, _, _) => "STROKE_COLOR",
+ (BoundList::Effects, _, "color") => "EFFECT_COLOR",
+ (BoundList::Effects, _, _) => "EFFECT_FLOAT",
+ (BoundList::Fills, _, _) => match doc.node(node).map(|n| &n.kind) {
+ Some(NodeKind::Text(_)) => "TEXT_FILL",
+ Some(k) if k.is_frame_like() => "FRAME_FILL",
+ _ => "SHAPE_FILL",
+ },
+ // Layout grid fields take any number variable.
+ (BoundList::Grids, _, _) => return None,
},
- _ => return None,
})
}
diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs
index 7737386..3f60a68 100644
--- a/crates/engine/src/components.rs
+++ b/crates/engine/src/components.rs
@@ -344,6 +344,7 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) {
*b = a.clone();
}
+ carry_prop_bindings(from, to);
}
_ => {}
}
@@ -382,6 +383,18 @@ fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap`): a boolean property takes a boolean variable, a text property a
+/// string variable.
+pub const PROP_BINDING: &str = "componentProperties/";
+
+/// Give `to` the component property bindings of instance `from` (they go with its property
+/// values, which an instance keeps across rebuilds).
+fn carry_prop_bindings(from: &Node, to: &mut Node) {
+ to.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
+ to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone())));
+}
+
/// The override group a property-bound field belongs to.
fn field_group(field: &str) -> &'static str {
match field {
@@ -619,6 +632,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
fresh.children = order;
fresh.transform = root.transform;
fresh.kind = root.kind.clone();
+ carry_prop_bindings(&root, &mut fresh);
// A nested instance keeps mirroring its layer of the outer main.
fresh.source = if inside_instance(doc, inst) { root.source } else { Some(component) };
fresh.constraints = root.constraints;
diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs
index d5a76cf..ac5cbb1 100644
--- a/crates/engine/src/devinspect.rs
+++ b/crates/engine/src/devinspect.rs
@@ -213,42 +213,29 @@ pub fn properties(doc: &Document, id: NodeId) -> Vec {
if let Some(t) = n.kind.text() {
out.push(typography(doc, n, t));
}
- let paint_rows = |paints: &[figcraft_color::Paint]| -> Vec {
- paints
+ // Each paint's row names the variable bound to its colour; a style names the first row.
+ let paint_rows = |fills: bool, style: Option<&StyleId>| -> Vec {
+ let paints = if fills { &n.fills } else { &n.strokes };
+ let rows: Vec = paints
.iter()
- .filter(|p| p.visible)
- .map(|p| match &p.kind {
- PaintKind::Solid { color } => {
- let c = color.with_alpha(color.a * p.opacity);
- prop("Solid", color_text(c)).color(c)
- }
- PaintKind::Gradient(g) => {
- let kind = match g.kind {
- GradientKind::Linear => "Linear",
- GradientKind::Radial => "Radial",
- GradientKind::Angular => "Angular",
- GradientKind::Diamond => "Diamond",
- };
- let stops: Vec = g.stops.iter().map(|s| format!("#{} {}%", s.color.to_hex(), (s.position * 100.0).round())).collect();
- let r = prop(kind, stops.join(", "));
- match g.stops.first() {
- Some(s) => r.color(s.color),
- None => r,
- }
- }
- PaintKind::Image(_) => prop("Image", format!("{}%", (p.opacity * 100.0).round())),
+ .enumerate()
+ .filter(|(_, p)| p.visible)
+ .map(|(i, p)| {
+ let token = crate::cmd::lists::paint_binding_key(n, fills, i).and_then(|k| variable(doc, n, k));
+ paint_row(p).token(token)
})
- .collect()
+ .collect();
+ match style_name(doc, style) {
+ Some(name) => tag_first(rows, Some(name)),
+ None => rows,
+ }
};
- let fills = paint_rows(&n.fills);
+ let fills = paint_rows(true, n.styles.fill.as_ref());
if !fills.is_empty() {
- let token = style_name(doc, n.styles.fill.as_ref()).or_else(|| variable(doc, n, "fill"));
- out.push(Section { title: "Fills", rows: tag_first(fills, token) });
+ out.push(Section { title: "Fills", rows: fills });
}
- let mut strokes = paint_rows(&n.strokes);
+ let mut strokes = paint_rows(false, n.styles.stroke.as_ref());
if !strokes.is_empty() {
- let token = style_name(doc, n.styles.stroke.as_ref()).or_else(|| variable(doc, n, "stroke"));
- strokes = tag_first(strokes, token);
let align = match n.stroke.align {
StrokeAlign::Inside => "inside",
StrokeAlign::Center => "center",
@@ -306,6 +293,31 @@ pub fn properties(doc: &Document, id: NodeId) -> Vec {
}
/// A style or variable applies to the whole list: show it on the first row.
+/// One paint's row: its kind and colour (or stops, or opacity for images).
+fn paint_row(p: &figcraft_color::Paint) -> Prop {
+ match &p.kind {
+ PaintKind::Solid { color } => {
+ let c = color.with_alpha(color.a * p.opacity);
+ prop("Solid", color_text(c)).color(c)
+ }
+ PaintKind::Gradient(g) => {
+ let kind = match g.kind {
+ GradientKind::Linear => "Linear",
+ GradientKind::Radial => "Radial",
+ GradientKind::Angular => "Angular",
+ GradientKind::Diamond => "Diamond",
+ };
+ let stops: Vec = g.stops.iter().map(|s| format!("#{} {}%", s.color.to_hex(), (s.position * 100.0).round())).collect();
+ let r = prop(kind, stops.join(", "));
+ match g.stops.first() {
+ Some(s) => r.color(s.color),
+ None => r,
+ }
+ }
+ PaintKind::Image(_) => prop("Image", format!("{}%", (p.opacity * 100.0).round())),
+ }
+}
+
fn tag_first(mut rows: Vec, token: Option) -> Vec {
if let Some(first) = rows.first_mut() {
first.token = token;
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 5a81700..b6b6ec0 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -974,6 +974,8 @@ mod measure_tests;
#[cfg(test)]
mod nested_override_tests;
#[cfg(test)]
+mod paint_reorder_tests;
+#[cfg(test)]
mod paint_tests;
#[cfg(test)]
mod pixel_preview_tests;
diff --git a/crates/engine/src/paint_reorder_tests.rs b/crates/engine/src/paint_reorder_tests.rs
new file mode 100644
index 0000000..c86d490
--- /dev/null
+++ b/crates/engine/src/paint_reorder_tests.rs
@@ -0,0 +1,289 @@
+//! Reordering paints, effects and layout grids (`fill.move`, `stroke.move`, `effect.move`,
+//! `grid.move`): one undo step each, items keep everything, variable bindings and style links
+//! follow; layout grid fields and instances' component properties bound to variables.
+
+use figcraft_color::{Color, Gradient, GradientKind, ImageScaleMode, Paint};
+use figcraft_doc::{GridKind, LayoutGrid, Node, NodeId, NodeKind};
+use serde_json::{Value, json};
+
+use crate::Session;
+
+fn id(v: Value) -> NodeId {
+ NodeId(v["id"].as_u64().unwrap())
+}
+
+fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
+ s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
+}
+
+fn node(s: &Session, id: NodeId) -> Node {
+ s.doc().unwrap().node(id).unwrap().clone()
+}
+
+fn rect(s: &mut Session) -> NodeId {
+ id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})))
+}
+
+fn hexes(paints: &[Paint]) -> Vec {
+ paints.iter().map(|p| p.solid_color().map(|c| c.to_hex()).unwrap_or_else(|| "-".into())).collect()
+}
+
+fn vars(s: &mut Session) {
+ ex(s, "variable.createCollection", json!({"name": "T", "modes": ["Light", "Dark"]}));
+ for (name, kind, value) in [("brand", "color", json!("#FF0000")), ("gap", "number", json!(12)), ("cols", "number", json!(6))] {
+ ex(s, "variable.create", json!({"collection": "T", "name": name, "type": kind, "value": value}));
+ }
+}
+
+#[test]
+fn fill_move_reorders_as_one_undo_step_and_keeps_every_paint() {
+ let mut s = Session::new();
+ let r = rect(&mut s);
+ let gradient = Paint::gradient(Gradient::default_for(GradientKind::Radial, Color::from_hex("#00FF00").unwrap()));
+ let mut image = Paint::image("synthetic-hash", ImageScaleMode::Fit);
+ image.opacity = 0.4;
+ let mut red = Paint::solid(Color::from_hex("#FF0000").unwrap());
+ red.visible = false;
+ let fills = vec![red.clone(), gradient.clone(), image.clone()];
+ ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": fills}}));
+ let undo = s.ds().unwrap().history.undo.len();
+ // Bottom (0) to top (2).
+ ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 2}));
+ assert_eq!(node(&s, r).fills, vec![gradient.clone(), image.clone(), red.clone()], "image, gradient and hidden paint survive whole");
+ assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
+ ex(&mut s, "fill.move", json!({"ids": [r], "from": 2, "to": 1}));
+ assert_eq!(node(&s, r).fills, vec![gradient.clone(), red.clone(), image.clone()]);
+ ex(&mut s, "edit.undo", json!({}));
+ ex(&mut s, "edit.undo", json!({}));
+ assert_eq!(node(&s, r).fills, fills);
+ ex(&mut s, "edit.redo", json!({}));
+ assert_eq!(node(&s, r).fills, vec![gradient, image, red]);
+}
+
+#[test]
+fn moves_out_of_range_change_nothing_and_junk_is_refused() {
+ let mut s = Session::new();
+ let r = rect(&mut s);
+ ex(&mut s, "fill.add", json!({"ids": [r], "color": "#00FF00"}));
+ let before = node(&s, r).fills;
+ let undo = s.ds().unwrap().history.undo.len();
+ for cmd in ["fill.move", "stroke.move", "effect.move", "grid.move"] {
+ // Nothing to move: no change.
+ ex(&mut s, cmd, json!({"ids": [r], "from": 0, "to": 9}));
+ ex(&mut s, cmd, json!({"ids": [r], "from": 1, "to": 1}));
+ for bad in [json!({"ids": [r], "from": -1, "to": 0}), json!({"ids": [r], "from": 0.5, "to": 1}), json!({"ids": [r], "from": 0})] {
+ assert!(s.execute(cmd, &bad).is_err(), "{cmd} {bad}");
+ }
+ }
+ assert_eq!(node(&s, r).fills, before);
+ assert_eq!(s.ds().unwrap().history.undo.len(), undo, "no-op moves add no undo steps");
+ // A negative index never removes the first paint or grid.
+ assert!(s.execute("fill.remove", &json!({"ids": [r], "index": -1})).is_err());
+ ex(&mut s, "node.set", json!({"ids": [r], "props": {"grids": [LayoutGrid::default()]}}));
+ assert!(s.execute("grid.remove", &json!({"ids": [r], "index": -1})).is_err());
+ assert_eq!(node(&s, r).fills, before);
+ assert_eq!(node(&s, r).grids.len(), 1);
+}
+
+#[test]
+fn a_bound_paint_colour_follows_its_paint() {
+ let mut s = Session::new();
+ let r = rect(&mut s);
+ vars(&mut s);
+ ex(&mut s, "fill.set", json!({"ids": [r], "index": 0, "color": "#0000FF"}));
+ ex(&mut s, "fill.add", json!({"ids": [r], "color": "#00FF00"}));
+ ex(&mut s, "fill.add", json!({"ids": [r], "color": "#FFFFFF"}));
+ ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
+ assert_eq!(hexes(&node(&s, r).fills), ["FF0000", "00FF00", "FFFFFF"]);
+ // The bound bottom paint goes to the top: the binding goes with it.
+ ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 2}));
+ assert_eq!(node(&s, r).bound.keys().collect::>(), ["fills/2/color"]);
+ ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
+ assert_eq!(hexes(&node(&s, r).fills), ["00FF00", "FFFFFF", "123456"], "the neighbours aren't recoloured");
+ // Modes recolour the moved paint too.
+ ex(&mut s, "variable.set", json!({"variable": "brand", "mode": "Dark", "value": "#ABCDEF"}));
+ ex(&mut s, "variable.setMode", json!({"collection": "T", "mode": "Dark", "ids": [r]}));
+ assert_eq!(hexes(&node(&s, r).fills), ["00FF00", "FFFFFF", "ABCDEF"]);
+ // Removing a paint below shifts the binding down with its paint.
+ ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
+ assert_eq!(node(&s, r).bound.keys().collect::>(), ["fills/1/color"]);
+ ex(&mut s, "variable.set", json!({"variable": "brand", "mode": "Dark", "value": "#010203"}));
+ assert_eq!(hexes(&node(&s, r).fills), ["FFFFFF", "010203"]);
+ // Undo brings back the binding where it was.
+ ex(&mut s, "edit.undo", json!({}));
+ ex(&mut s, "edit.undo", json!({}));
+ ex(&mut s, "edit.undo", json!({}));
+ ex(&mut s, "edit.undo", json!({}));
+ assert_eq!(node(&s, r).bound.keys().collect::>(), ["fills/2/color"]);
+ // Removing the bound paint drops its binding.
+ ex(&mut s, "fill.remove", json!({"ids": [r], "index": 2}));
+ assert!(node(&s, r).bound.is_empty());
+}
+
+#[test]
+fn strokes_move_with_their_bindings_and_moves_unlink_styles() {
+ let mut s = Session::new();
+ let (a, b) = (rect(&mut s), rect(&mut s));
+ vars(&mut s);
+ for r in [a, b] {
+ ex(&mut s, "stroke.add", json!({"ids": [r], "color": "#000000"}));
+ ex(&mut s, "stroke.add", json!({"ids": [r], "color": "#00FF00"}));
+ }
+ ex(&mut s, "variable.bind", json!({"ids": [a, b], "field": "stroke", "variable": "brand"}));
+ ex(&mut s, "style.create", json!({"type": "paint", "name": "Two", "from": a}));
+ ex(&mut s, "style.apply", json!({"ids": [a, b], "style": "Two"}));
+ assert!(node(&s, a).styles.fill.is_some());
+ ex(&mut s, "fill.add", json!({"ids": [a, b], "color": "#FFFFFF"}));
+ // One command moves the strokes of every selected layer.
+ ex(&mut s, "stroke.move", json!({"ids": [a, b], "from": 0, "to": 1}));
+ for r in [a, b] {
+ assert_eq!(hexes(&node(&s, r).strokes), ["00FF00", "FF0000"]);
+ assert_eq!(node(&s, r).bound.keys().collect::>(), ["strokes/1/color"]);
+ }
+ ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
+ assert_eq!(hexes(&node(&s, b).strokes), ["00FF00", "0000FF"]);
+ // A paint style defined the whole fill list: reordering it unlinks the style.
+ let fills = node(&s, a).fills.len();
+ assert!(fills >= 2);
+ ex(&mut s, "fill.move", json!({"ids": [a], "from": 0, "to": fills - 1}));
+ assert!(node(&s, a).styles.fill.is_none());
+ assert!(node(&s, b).styles.fill.is_some(), "only the moved layer unlinks");
+}
+
+#[test]
+fn bound_paint_keys_resolve_and_scope_by_index() {
+ use crate::cmd::lists::{BoundList, list_binding};
+ assert_eq!(list_binding("fill"), Some((BoundList::Fills, 0, "color")));
+ assert_eq!(list_binding("stroke"), Some((BoundList::Strokes, 0, "color")));
+ assert_eq!(list_binding("fills/3/color"), Some((BoundList::Fills, 3, "color")));
+ assert_eq!(list_binding("layoutGrids/1/gutter"), Some((BoundList::Grids, 1, "gutter")));
+ for junk in
+ ["fills/-1/color", "fills/+1/color", "fills//color", "fills/1/", "effects/x/radius", "grids/0/size", "fills/99999999999999999999999/color"]
+ {
+ assert_eq!(list_binding(junk), None, "{junk}");
+ }
+ let mut s = Session::new();
+ let r = rect(&mut s);
+ let doc = s.doc().unwrap();
+ use crate::cmd::variables::field_scope;
+ assert_eq!(field_scope(doc, r, "fills/2/color"), Some("SHAPE_FILL"));
+ assert_eq!(field_scope(doc, r, "strokes/1/color"), Some("STROKE_COLOR"));
+ assert_eq!(field_scope(doc, r, "effects/0/color"), Some("EFFECT_COLOR"));
+ assert_eq!(field_scope(doc, r, "layoutGrids/0/count"), None);
+}
+
+#[test]
+fn inspect_names_the_variable_of_each_paint() {
+ let mut s = Session::new();
+ let r = rect(&mut s);
+ vars(&mut s);
+ ex(&mut s, "fill.add", json!({"ids": [r], "color": "#00FF00"}));
+ ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
+ ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 1}));
+ let props = crate::devinspect::properties(s.doc().unwrap(), r);
+ let fills = props.iter().find(|p| p.title == "Fills").unwrap();
+ let tokens: Vec