mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
M3: reorder paints, effects and layout grids in the Design panel; grid and instance property variable bindings (#81)
Fill, stroke, effect and layout grid rows drag to reorder by their grip (a handle shows over the
row, an insertion line where it lands, the row's label follows the pointer like a dragged layer;
Escape cancels; lists that differ across the selection have nothing to drag). New commands
`fill.move`, `stroke.move` and `grid.move {ids?, from, to}` join `effect.move` (lists bottom first,
one undo step; items keep everything, linked list styles are unlinked).
Variable bindings on list items follow their items when a list is reordered or shrinks:
paint colours bind per paint (`fills/<i>/color`, `strokes/<i>/color`; `fill`/`stroke` stay the
first paint's), so a moved bound paint keeps its variable and its neighbours are never recoloured.
`fill.remove`, `stroke.remove` and `grid.remove` refuse negative indices.
Layout grids get settings in the Design panel (size; count, type, width or height, margin or
offset, gutter; colour) whose numbers bind to number variables (`layoutGrids/<i>/<field>`),
re-applied on variable and mode changes. Instance boolean and text properties bind to boolean and
string variables (`componentProperties/<name>`, from the instance's properties panel), kept across
instance rebuilds, following property renames and dropped on delete, reset and detach.
Inspect names the variable of each paint.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -433,6 +433,8 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
{
|
||||
*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<Value> {
|
||||
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<Value> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -215,37 +215,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<Value> {
|
||||
fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Value> {
|
||||
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<Valu
|
||||
};
|
||||
list.push(paint);
|
||||
} else {
|
||||
let idx = num(p, "index").map(|x| x as usize).unwrap_or(list.len().saturating_sub(1));
|
||||
if idx < list.len() {
|
||||
list.remove(idx);
|
||||
// A binding on the first paint's colour belongs to the paint just removed.
|
||||
if idx == 0 {
|
||||
let keys: &[&str] = if fills { &["fill", "fills", "fills/0", "fills/0/color"] } else { &["stroke", "strokes/0/color"] };
|
||||
for k in keys {
|
||||
n.bound.remove(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
let idx = index.unwrap_or(list.len().saturating_sub(1));
|
||||
// The paint's colour binding goes with it; later paints' move down one.
|
||||
super::lists::remove_item(n, if fills { super::lists::BoundList::Fills } else { super::lists::BoundList::Strokes }, idx);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! A layer's ordered lists (fills, strokes, effects, layout grids): reordering them, and keeping
|
||||
//! the variable bindings on their items (`fills/<i>/color`, `effects/<i>/radius`,
|
||||
//! `layoutGrids/<i>/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<CommandSpec> {
|
||||
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 (`<prefix>/<index>/<field>`).
|
||||
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/<index>/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<usize> {
|
||||
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<usize>) {
|
||||
let moved: Vec<(String, Option<String>)> = 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<T>(v: &mut Vec<T>, 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<Value> {
|
||||
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/<i>/<size|count|gutter|offset>`;
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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/<i>/color|strokes/<i>/color|effects/<i>/<offsetX|offsetY|radius|spread|color>|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (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<Value> {
|
||||
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<Value> {
|
||||
|
||||
/// 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/<index>/<offsetX|offsetY|radius|spread|color>`: one effect's value.
|
||||
if let Some((i, prop)) = field.strip_prefix("effects/").and_then(|r| r.split_once('/'))
|
||||
&& let Ok(i) = i.parse::<usize>()
|
||||
{
|
||||
effect_sets.push((i, prop.to_string(), val));
|
||||
// `componentProperties/<name>`: 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/<index>/<field>`) 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<usize>) {
|
||||
let moved: Vec<(String, Option<String>)> = n
|
||||
.bound
|
||||
.keys()
|
||||
.filter_map(|k| {
|
||||
let (i, field) = k.strip_prefix("effects/")?.split_once('/')?;
|
||||
let i = i.parse::<usize>().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 };
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<NodeId, S
|
||||
out
|
||||
}
|
||||
|
||||
/// The binding key prefix of an instance's component property value
|
||||
/// (`componentProperties/<name>`): 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;
|
||||
|
||||
@@ -213,42 +213,29 @@ pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
|
||||
if let Some(t) = n.kind.text() {
|
||||
out.push(typography(doc, n, t));
|
||||
}
|
||||
let paint_rows = |paints: &[figcraft_color::Paint]| -> Vec<Prop> {
|
||||
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<Prop> {
|
||||
let paints = if fills { &n.fills } else { &n.strokes };
|
||||
let rows: Vec<Prop> = 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<String> = 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<Section> {
|
||||
}
|
||||
|
||||
/// 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<String> = 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<Prop>, token: Option<String>) -> Vec<Prop> {
|
||||
if let Some(first) = rows.first_mut() {
|
||||
first.token = token;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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<Option<&str>> = fills.rows.iter().map(|p| p.token.as_deref()).collect();
|
||||
assert_eq!(tokens, [None, Some("brand")], "the token follows the moved paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_grid_fields_bind_to_number_variables_and_follow_moves() {
|
||||
let mut s = Session::new();
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300})));
|
||||
vars(&mut s);
|
||||
ex(&mut s, "grid.add", json!({"ids": [f], "grid": {"kind": "columns", "count": 4, "gutter": 20}}));
|
||||
ex(&mut s, "grid.add", json!({"ids": [f], "grid": {"kind": "rows", "count": 3}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "layoutGrids/0/gutter", "variable": "gap"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "layoutGrids/0/count", "variable": "cols"}));
|
||||
let g = node(&s, f).grids[0].clone();
|
||||
assert_eq!((g.kind, g.count, g.gutter), (GridKind::Columns, 6, 12.0));
|
||||
// Values and modes flow in.
|
||||
ex(&mut s, "variable.set", json!({"variable": "gap", "mode": "Dark", "value": 32}));
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "T", "mode": "Dark", "ids": [f]}));
|
||||
assert_eq!(node(&s, f).grids[0].gutter, 32.0);
|
||||
// Junk numbers are kept in range.
|
||||
ex(&mut s, "variable.set", json!({"variable": "cols", "mode": "Dark", "value": -5}));
|
||||
assert_eq!(node(&s, f).grids[0].count, 1);
|
||||
ex(&mut s, "variable.set", json!({"variable": "cols", "mode": "Dark", "value": 1e12}));
|
||||
assert_eq!(node(&s, f).grids[0].count, 1000);
|
||||
ex(&mut s, "variable.set", json!({"variable": "cols", "mode": "Dark", "value": 5}));
|
||||
// Move the bound grid on top: its bindings go with it, the rows grid isn't touched.
|
||||
ex(&mut s, "grid.move", json!({"ids": [f], "from": 0, "to": 1}));
|
||||
let keys: Vec<String> = node(&s, f).bound.keys().cloned().collect();
|
||||
assert_eq!(keys, ["layoutGrids/1/count", "layoutGrids/1/gutter"]);
|
||||
ex(&mut s, "variable.set", json!({"variable": "gap", "mode": "Dark", "value": 8}));
|
||||
let gs = node(&s, f).grids;
|
||||
assert_eq!((gs[0].kind, gs[0].count), (GridKind::Rows, 3));
|
||||
assert_eq!((gs[1].kind, gs[1].count, gs[1].gutter), (GridKind::Columns, 5, 8.0));
|
||||
// Removing the grid below shifts them down; removing the bound grid drops them.
|
||||
ex(&mut s, "grid.remove", json!({"ids": [f], "index": 0}));
|
||||
assert!(node(&s, f).bound.keys().all(|k| k.starts_with("layoutGrids/0/")));
|
||||
ex(&mut s, "grid.remove", json!({"ids": [f], "index": 0}));
|
||||
assert!(node(&s, f).bound.is_empty());
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, f).bound.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_properties_bind_to_boolean_and_string_variables() {
|
||||
let mut s = Session::new();
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let title = id(ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f})));
|
||||
let dot = id(ex(&mut s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f})));
|
||||
let c = id(ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
|
||||
let i = id(ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||||
let copy = |s: &Session, src: NodeId| {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.subtree(i).into_iter().find(|x| doc.node(*x).and_then(|n| n.source) == Some(src)).unwrap()
|
||||
};
|
||||
let (ititle, idot) = (copy(&s, title), copy(&s, dot));
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Flags", "modes": ["Short", "Long"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Flags", "name": "badge", "type": "boolean", "value": false}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Flags", "name": "label", "type": "string", "value": "Hi"}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Long", "value": "Hello there"}));
|
||||
// Types must fit, and only instances take them.
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "label"})).is_err());
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/Title", "variable": "badge"})).is_err());
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/Nope", "variable": "badge"})).is_err());
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [c], "field": "componentProperties/Show dot", "variable": "badge"})).is_err());
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "badge"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Title", "variable": "label"}));
|
||||
assert!(!node(&s, idot).visible, "the bound boolean hides the dot");
|
||||
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "Hi");
|
||||
// Values and modes flow in.
|
||||
ex(&mut s, "variable.set", json!({"variable": "badge", "value": true}));
|
||||
assert!(node(&s, idot).visible);
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "Flags", "mode": "Long", "ids": [i]}));
|
||||
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "Hello there");
|
||||
assert!(node(&s, ititle).overrides.is_empty(), "property values aren't overrides");
|
||||
// A main edit rebuilds the instance: its bindings stay.
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#00FF00"}}));
|
||||
assert!(node(&s, i).bound.contains_key("componentProperties/Title"));
|
||||
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Long", "value": "After edit"}));
|
||||
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "After edit");
|
||||
// Renaming the property keeps the binding; deleting it drops the binding.
|
||||
ex(&mut s, "component.editProperty", json!({"id": c, "name": "Title", "newName": "Heading"}));
|
||||
assert!(node(&s, i).bound.contains_key("componentProperties/Heading"));
|
||||
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Long", "value": "Renamed"}));
|
||||
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "Renamed");
|
||||
ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
|
||||
assert!(!node(&s, i).bound.keys().any(|k| k.contains("Heading")));
|
||||
assert!(matches!(node(&s, i).kind, NodeKind::Instance { .. }));
|
||||
// Unbinding keeps the value (the Long mode's `false`).
|
||||
assert!(!node(&s, idot).visible);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": null}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "badge", "mode": "Long", "value": true}));
|
||||
assert!(!node(&s, idot).visible);
|
||||
ex(&mut s, "variable.set", json!({"variable": "badge", "mode": "Long", "value": false}));
|
||||
// Resetting the instance drops its property bindings with its values; detaching too.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "badge"}));
|
||||
assert!(!node(&s, idot).visible);
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
|
||||
assert!(node(&s, i).bound.is_empty());
|
||||
assert!(node(&s, idot).visible, "back to the default");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "badge"}));
|
||||
ex(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
assert!(node(&s, i).bound.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user