mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 22:06:49 -06:00
Merge main into #870
This commit is contained in:
@@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() {
|
||||
ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7}));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
|
||||
}
|
||||
|
||||
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
|
||||
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
|
||||
}
|
||||
|
||||
fn brand_id(s: &Session) -> String {
|
||||
s.doc().unwrap().variables.get("brand").unwrap().id.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_paint_edits_detach_the_colour_binding() {
|
||||
// An edit that reported success keeps its colour: the binding it overwrote lets go (as
|
||||
// selection colours already do), instead of putting the variable's colour back.
|
||||
let edits = [
|
||||
("fill.set", json!({"color": "#00FF00"})),
|
||||
("node.set", json!({"props": {"fill": "#00FF00"}})),
|
||||
("node.set", json!({"props": {"fills": ["#00FF00"]}})),
|
||||
];
|
||||
for (cmd, mut p) in edits {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
p["ids"] = json!([r]);
|
||||
ex(&mut s, cmd, p.clone());
|
||||
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}");
|
||||
assert!(bound(&s, r).is_empty(), "{cmd} {p}");
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
|
||||
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable");
|
||||
}
|
||||
for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"}));
|
||||
p["ids"] = json!([r]);
|
||||
ex(&mut s, cmd, p);
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}");
|
||||
assert!(n.bound.is_empty(), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_bound_fills_adds_no_paint_back() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
|
||||
assert!(bound(&s, r).is_empty());
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_opacity_edits_keep_the_colour_binding() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
|
||||
assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable");
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5);
|
||||
// The same colour with another opacity, through node.set, keeps it too.
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}}));
|
||||
assert_eq!(bound(&s, r).len(), 1);
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_set_with_their_binding_in_the_same_call_keep_it() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
let brand = brand_id(&s);
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}}));
|
||||
assert_eq!(bound(&s, r), [("fill".to_string(), brand)]);
|
||||
assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies");
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result
|
||||
}
|
||||
return Ok(json!({"ids": ids, "operation": op}));
|
||||
}
|
||||
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
|
||||
let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?;
|
||||
// Operands of a new group follow its operation.
|
||||
if let Some(g) = crate::params::id_of(&out["id"]) {
|
||||
let doc = s.doc_mut()?;
|
||||
|
||||
@@ -528,6 +528,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0));
|
||||
let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0);
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get(i)?;
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
@@ -551,6 +552,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -608,7 +608,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?;
|
||||
let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name));
|
||||
let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null);
|
||||
add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
}
|
||||
|
||||
fn remove_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1039,7 +1039,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.addProperty";
|
||||
add_prop_for("component.addProperty", s, p)
|
||||
}
|
||||
|
||||
/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its
|
||||
/// slot property here).
|
||||
fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
|
||||
let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?;
|
||||
let t = req_string(cmd, p, "type")?;
|
||||
|
||||
@@ -261,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
|
||||
let i = req_index("effect.set", p, "index")?;
|
||||
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
for id in req_targets("effect.set", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if let Some(slot) = n.effects.get_mut(i) {
|
||||
@@ -952,9 +952,13 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
|
||||
fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let digits = req_string("object.opacityKeys", p, "digits")?;
|
||||
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
|
||||
for i in req_targets("object.opacityKeys", s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.opacity = o;
|
||||
let ids = req_targets("object.opacityKeys", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
doc.get_mut(i)?.opacity = o;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
Ok(json!({"opacity": o}))
|
||||
}
|
||||
|
||||
@@ -1202,10 +1206,12 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let props = if skipped.contains(&i) { &without_layout } else { &props };
|
||||
super::props::apply("node.set", doc, i, props)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
if let Some(o) = pivot {
|
||||
for (i, q) in pinned {
|
||||
let d = o - doc.world_transform(i) * q;
|
||||
@@ -1363,7 +1369,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
if let Some(b) = blend(p, "blend") {
|
||||
slot.blend = b;
|
||||
}
|
||||
super::lists::remap_stop_bindings(n, fills, at, &old);
|
||||
super::lists::rebind_edited_paint(n, fills, at, &old);
|
||||
if fills {
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
@@ -1397,8 +1403,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e: Effect = match get(p, "effect") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
|
||||
let e: Effect = match super::props::effect_from("effect.add", p, "effect")? {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into());
|
||||
Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))?
|
||||
|
||||
@@ -92,6 +92,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
|
||||
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
|
||||
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
|
||||
let from = req_index("file.moveTab", p, "from")?;
|
||||
let to = req_index("file.moveTab", p, "to")?;
|
||||
if from >= s.docs.len() || to >= s.docs.len() {
|
||||
return Err(bad("file.moveTab", "no such document"));
|
||||
}
|
||||
if from != to {
|
||||
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
||||
let doc = s.docs.remove(from);
|
||||
s.docs.insert(to, doc);
|
||||
s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0);
|
||||
}
|
||||
Ok(json!({"index": to}))
|
||||
}),
|
||||
spec!(
|
||||
"file.recover",
|
||||
"Recover file",
|
||||
@@ -1374,14 +1388,36 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
|
||||
}
|
||||
let uid = ds.uid;
|
||||
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
||||
s.docs.remove(i);
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
// Removing a tab before the active one shifts its index; keep the same document selected.
|
||||
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tab_order_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn moving_and_closing_inactive_tabs_preserves_the_active_document() {
|
||||
let mut session = Session::new();
|
||||
for name in ["Two", "Three", "Four"] {
|
||||
session.execute("file.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
session.execute("doc.switch", &json!({"index": 2})).unwrap();
|
||||
let active_uid = session.docs[session.active].uid;
|
||||
let moved_uid = session.docs[0].uid;
|
||||
session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap();
|
||||
assert_eq!(session.docs[2].uid, moved_uid);
|
||||
assert_eq!(session.docs[session.active].uid, active_uid);
|
||||
session.execute("file.close", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(session.docs[session.active].uid, active_uid);
|
||||
assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
|
||||
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let n = s.docs.len() as i64;
|
||||
|
||||
@@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option<usize> {
|
||||
/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after
|
||||
/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's
|
||||
/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices.
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
/// Returns the removed stop's index.
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option<usize> {
|
||||
use figcraft_color::PaintKind;
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) };
|
||||
@@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
// The first stop that differs is the removed one when the rest moved down unchanged.
|
||||
let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len());
|
||||
if a.stops.get(k + 1..) != b.stops.get(k..) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
(_, Some(PaintKind::Gradient(_))) => return,
|
||||
(_, Some(PaintKind::Gradient(_))) => return None,
|
||||
_ => None,
|
||||
};
|
||||
let keys: Vec<(String, Option<String>)> = n
|
||||
@@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
}
|
||||
}
|
||||
n.bound.extend(kept);
|
||||
removed
|
||||
}
|
||||
|
||||
/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop
|
||||
/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the
|
||||
/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the
|
||||
/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings.
|
||||
pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
let removed = remap_stop_bindings(n, fills, index, old);
|
||||
let mut old = old.clone();
|
||||
if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind)
|
||||
&& k < g.stops.len()
|
||||
{
|
||||
g.stops.remove(k);
|
||||
}
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned();
|
||||
n.bound.retain(|key, _| match list_binding(key) {
|
||||
Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)),
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a paint's bound `field` (`color`, `opacity`, `stops/<j>/color`, …) has the same value in
|
||||
/// `new` as in `old`.
|
||||
fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool {
|
||||
use figcraft_color::PaintKind;
|
||||
match field {
|
||||
"color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b),
|
||||
"opacity" => old.opacity == new.opacity,
|
||||
_ => match (stop_binding(field), &old.kind, &new.kind) {
|
||||
(Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color),
|
||||
(Some(_), _, _) => false,
|
||||
_ => old == new,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Where item `k` lands when the item at `from` moves to `to`.
|
||||
|
||||
@@ -61,6 +61,48 @@ pub fn snake(v: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word,
|
||||
/// so a shadow given `offsetY` kept the default offset.
|
||||
pub fn effects_from(cmd: &str, v: &Value) -> Result<Vec<Effect>> {
|
||||
let effects: Vec<Effect> = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?;
|
||||
for (i, given) in v.as_array().into_iter().flatten().enumerate() {
|
||||
refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?;
|
||||
}
|
||||
Ok(effects)
|
||||
}
|
||||
|
||||
/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read.
|
||||
pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
|
||||
let Some(v) = get(p, key) else { return Ok(None) };
|
||||
let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
|
||||
refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?;
|
||||
Ok(Some(effect))
|
||||
}
|
||||
|
||||
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
|
||||
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
|
||||
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
|
||||
let Some(fields) = given.as_object() else { return Ok(()) };
|
||||
let parsed = parse(given);
|
||||
let ignored: Vec<&str> = fields
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_null())
|
||||
.filter(|(k, _)| {
|
||||
let mut probe = fields.clone();
|
||||
probe.insert((*k).clone(), Value::Null);
|
||||
parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed)
|
||||
})
|
||||
.map(|(k, _)| k.as_str())
|
||||
.collect();
|
||||
if ignored.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default();
|
||||
let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string();
|
||||
let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", "))))
|
||||
}
|
||||
|
||||
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let s = v.as_str()?;
|
||||
// Accept camelCase, snake/upper-case and spaced variants.
|
||||
@@ -297,6 +339,12 @@ pub const LAYOUT_KEYS: &[&str] = &[
|
||||
"gridRowTracks",
|
||||
];
|
||||
|
||||
/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or
|
||||
/// re-applying them would bring it back.
|
||||
pub fn drop_layout_bindings(n: &mut Node) {
|
||||
n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str()));
|
||||
}
|
||||
|
||||
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
|
||||
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
|
||||
/// (`node.set` on several layers skips them instead, see `edit::set`).
|
||||
@@ -315,18 +363,32 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
|
||||
Ok(ensure_layout(n))
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
// Geometry keys first so rotation applies to the final size.
|
||||
let order = |k: &str| match k {
|
||||
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
|
||||
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
|
||||
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
|
||||
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
|
||||
for (i, p) in old.iter().enumerate() {
|
||||
super::lists::rebind_edited_paint(n, fills, i, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
|
||||
/// bindings last so paints set in the same call don't detach them.
|
||||
fn apply_order(k: &str) -> u8 {
|
||||
match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"boundVariables" => 4,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
|
||||
keys.sort_by_key(|(k, _)| order(k));
|
||||
keys.sort_by_key(|(k, _)| apply_order(k));
|
||||
for (k, v) in keys {
|
||||
let k = k.as_str();
|
||||
match k {
|
||||
@@ -398,15 +460,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
|
||||
doc.get_mut(id)?.blend = b;
|
||||
}
|
||||
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
|
||||
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
|
||||
"fill" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
|
||||
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
}
|
||||
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
|
||||
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
|
||||
"stroke" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
|
||||
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
}
|
||||
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
|
||||
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
|
||||
@@ -462,7 +524,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
|
||||
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
|
||||
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
|
||||
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
|
||||
"devStatus" => {
|
||||
@@ -486,8 +548,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
return Err(bad(cmd, "auto layout needs a frame"));
|
||||
}
|
||||
match v {
|
||||
Value::Null => n.layout = None,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
|
||||
Value::Null => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::String(s) => {
|
||||
let mode = match s.to_ascii_lowercase().as_str() {
|
||||
"horizontal" | "row" => LayoutMode::Horizontal,
|
||||
|
||||
@@ -10,11 +10,13 @@ use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
|
||||
/// add/remove, move to page and vector path edits.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)),
|
||||
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)),
|
||||
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
|
||||
spec!(
|
||||
"object.convertToGroup",
|
||||
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Wrap in new section",
|
||||
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
|
||||
true,
|
||||
|s, p| wrap(s, p, Wrap::Section)
|
||||
|s, p| wrap(s, p, "object.wrapInSection", Wrap::Section)
|
||||
),
|
||||
spec!(
|
||||
"object.resizeToFit",
|
||||
@@ -71,7 +73,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// Hugging needs auto layout and the children that filled have nothing to fill: both
|
||||
// become fixed (the frame keeps filling its own auto layout parent).
|
||||
let doc = s.doc_mut()?;
|
||||
doc.get_mut(i)?.layout = None;
|
||||
let n = doc.get_mut(i)?;
|
||||
n.layout = None;
|
||||
super::props::drop_layout_bindings(n);
|
||||
figcraft_layout::coherent_sizing(doc, i);
|
||||
}
|
||||
ok()
|
||||
@@ -128,26 +132,27 @@ const SECTION_PAD: f64 = 40.0;
|
||||
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
|
||||
|
||||
/// Wrap the selection in a new boolean group doing `op`.
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, Wrap::Bool(op))
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, cmd, Wrap::Bool(op))
|
||||
}
|
||||
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place.
|
||||
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets("object.group", s, p)?;
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`,
|
||||
/// the command that asked.
|
||||
fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
|
||||
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?;
|
||||
// Sections sit on the page or in sections, never in frames or groups.
|
||||
let board = |doc: &Document, i: NodeId| {
|
||||
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
};
|
||||
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
|
||||
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
|
||||
return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section"));
|
||||
}
|
||||
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
|
||||
return Err(bad("object.group", "sections can't go in groups or frames"));
|
||||
return Err(bad(cmd, "sections can't go in groups or frames"));
|
||||
}
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
@@ -285,7 +290,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.grids.clear();
|
||||
n.guides.clear();
|
||||
n.styles.grid = None;
|
||||
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
|
||||
super::props::drop_layout_bindings(n);
|
||||
let kids = n.children.clone();
|
||||
for k in kids {
|
||||
// Out of the flow there is nothing to fill or to be absolute against.
|
||||
@@ -338,7 +343,7 @@ fn mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return ok();
|
||||
}
|
||||
// Group, then the bottom-most child is the mask.
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?;
|
||||
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(first) = doc.get(gid)?.children.first().copied() {
|
||||
@@ -472,7 +477,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = doc.get_mut(fill_id)?;
|
||||
f.strokes.clear();
|
||||
}
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?;
|
||||
if let Some(g) = id_of(&r["id"]) {
|
||||
out.push(g);
|
||||
}
|
||||
@@ -519,7 +524,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ids
|
||||
} else {
|
||||
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
|
||||
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
|
||||
let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?;
|
||||
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
|
||||
let f = s.doc_mut()?.get_mut(fid)?;
|
||||
f.fills.clear();
|
||||
|
||||
@@ -110,16 +110,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
))
|
||||
}),
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
|
||||
let cmd = "variable.createCollection";
|
||||
let name = req_string(cmd, p, "name")?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad(cmd, "name can't be empty"));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(),
|
||||
_ => vec!["Mode 1".into()],
|
||||
};
|
||||
if modes.is_empty() {
|
||||
return Err(bad(cmd, "a collection needs at least one mode"));
|
||||
}
|
||||
if modes.iter().any(String::is_empty) {
|
||||
return Err(bad(cmd, "mode names can't be empty"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
|
||||
doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None });
|
||||
Ok(json!({"id": id}))
|
||||
@@ -950,6 +960,104 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
apply_bindings_matching(doc, only, |_| true);
|
||||
}
|
||||
|
||||
/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings).
|
||||
pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec<std::sync::Arc<Node>> {
|
||||
ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect()
|
||||
}
|
||||
|
||||
/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit
|
||||
/// changed drops its binding, which would otherwise write the variable's value back (#809).
|
||||
/// Compared by value, so keys written unchanged keep their bindings and fields moved along with
|
||||
/// another (proportions, min/max) lose theirs.
|
||||
pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc<Node>]) {
|
||||
for old in before {
|
||||
let Some(n) = doc.node(old.id) else { continue };
|
||||
let edited: Vec<String> = n
|
||||
.bound
|
||||
.keys()
|
||||
// A binding the edit itself made (`boundVariables`) stays.
|
||||
.filter(|f| old.bound.get(*f) == n.bound.get(*f))
|
||||
.filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING))
|
||||
.filter(|f| bound_field(old, f) != bound_field(n, f))
|
||||
.cloned()
|
||||
.collect();
|
||||
if edited.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(n) = doc.get_mut(old.id) {
|
||||
for f in edited {
|
||||
n.bound.remove(&f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field).
|
||||
fn bound_field(n: &Node, field: &str) -> Option<String> {
|
||||
let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind {
|
||||
NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::<Vec<_>>().join(",")),
|
||||
_ => None,
|
||||
};
|
||||
let layout = n.layout.as_ref();
|
||||
let cl = &n.child_layout;
|
||||
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
|
||||
Some(match field {
|
||||
"width" => format!("{}", n.width),
|
||||
"height" => format!("{}", n.height),
|
||||
"minWidth" => format!("{:?}", cl.min_w),
|
||||
"maxWidth" => format!("{:?}", cl.max_w),
|
||||
"minHeight" => format!("{:?}", cl.min_h),
|
||||
"maxHeight" => format!("{:?}", cl.max_h),
|
||||
"opacity" => format!("{}", n.opacity),
|
||||
"visible" => format!("{}", n.visible),
|
||||
"gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)),
|
||||
"crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)),
|
||||
"padding" => format!("{:?}", layout.map(|l| l.padding)),
|
||||
"paddingTop" => format!("{:?}", pad(0)),
|
||||
"paddingRight" => format!("{:?}", pad(1)),
|
||||
"paddingBottom" => format!("{:?}", pad(2)),
|
||||
"paddingLeft" => format!("{:?}", pad(3)),
|
||||
"paddingHorizontal" => format!("{:?}", (pad(1), pad(3))),
|
||||
"paddingVertical" => format!("{:?}", (pad(0), pad(2))),
|
||||
"cornerRadius" => format!("{:?}", n.corners.radii),
|
||||
"topLeftRadius" => format!("{:?}", n.corners.radii.first()),
|
||||
"topRightRadius" => format!("{:?}", n.corners.radii.get(1)),
|
||||
"bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)),
|
||||
"bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)),
|
||||
"strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)),
|
||||
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides),
|
||||
"arcStart" | "arcSweep" | "arcInner" => match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => format!(
|
||||
"{}",
|
||||
if field == "arcStart" {
|
||||
start
|
||||
} else if field == "arcSweep" {
|
||||
sweep
|
||||
} else {
|
||||
inner
|
||||
}
|
||||
),
|
||||
_ => return None,
|
||||
},
|
||||
"characters" => match &n.kind {
|
||||
NodeKind::Text(t) => t.characters.clone(),
|
||||
_ => return None,
|
||||
},
|
||||
"paragraphSpacing" | "paragraphIndent" => match &n.kind {
|
||||
NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }),
|
||||
_ => return None,
|
||||
},
|
||||
"fontFamily" => text(&|s| s.font_family.clone())?,
|
||||
"fontStyle" => text(&|s| s.font_style.clone())?,
|
||||
"fontWeight" => text(&|s| s.weight().to_string())?,
|
||||
"fontSize" => text(&|s| s.font_size.to_string())?,
|
||||
"lineHeight" => text(&|s| format!("{:?}", s.line_height))?,
|
||||
"letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?,
|
||||
// shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice.
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
|
||||
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
|
||||
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
|
||||
@@ -1007,6 +1115,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound
|
||||
/// node when `None`). Auto layout fields wait while their node has no auto layout.
|
||||
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
if doc.variables.variables.is_empty() {
|
||||
@@ -1021,6 +1131,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
let modes = doc.modes_for(id);
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let binds = n.bound.clone();
|
||||
let has_layout = n.layout.is_some();
|
||||
let mut sets = serde_json::Map::new();
|
||||
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
|
||||
let mut paint_sets: Vec<(bool, usize, Option<usize>, Color)> = Vec::new();
|
||||
@@ -1030,6 +1141,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
if !include(&field) {
|
||||
continue;
|
||||
}
|
||||
// A binding never creates an auto layout: without one it waits for the next.
|
||||
if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
||||
// 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) {
|
||||
|
||||
@@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
let ds = s.ds_mut()?;
|
||||
let before = ds.doc.clone();
|
||||
let doc = ds.doc_mut();
|
||||
let bound = super::styles::bound_before(doc, &[id]);
|
||||
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
|
||||
if a == b {
|
||||
t.restyle(0, t.char_count(), &f);
|
||||
@@ -25,19 +26,21 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
t.restyle(a, b, &f);
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &bound);
|
||||
crate::normalize(doc, &before);
|
||||
return ok();
|
||||
}
|
||||
let ids = req_targets("text", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
for d in doc.subtree(i) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for d in ids {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -348,11 +351,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
||||
let ids = req_targets("text.set", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let mut m = Map::new();
|
||||
m.insert("characters".into(), json!(c));
|
||||
super::props::apply("text.set", doc, i, &m)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
@@ -364,6 +369,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ids = req_targets("text.style", s, p)?;
|
||||
let m = p.as_object().cloned().unwrap_or_default();
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
|
||||
@@ -377,6 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::props::apply_text_style("text.style", t, a, b, k, v)?;
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
),
|
||||
|
||||
@@ -259,6 +259,25 @@ fn stroke_color(n: &Node) -> Option<Color> {
|
||||
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
|
||||
}
|
||||
|
||||
/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border
|
||||
/// on each side that has a weight.
|
||||
fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> {
|
||||
let Some(c) = stroke_color(n) else { return Vec::new() };
|
||||
let Some(sides) = n.stroke.sides else {
|
||||
let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t);
|
||||
return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))];
|
||||
};
|
||||
let color = tok(doc, n, &["stroke"], c.to_css(), t);
|
||||
let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES);
|
||||
named
|
||||
.zip(sides)
|
||||
.filter(|(_, weight)| *weight > 0.0)
|
||||
.map(|((side, field), weight)| {
|
||||
(format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn drop_shadow(n: &Node) -> Option<&Shadow> {
|
||||
n.effects.iter().filter(|e| e.visible).find_map(|e| match &e.kind {
|
||||
EffectKind::DropShadow(s) => Some(s),
|
||||
@@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
||||
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
|
||||
}
|
||||
}
|
||||
if let Some(c) = stroke_color(n) {
|
||||
let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t);
|
||||
put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)));
|
||||
for (key, value) in css_borders(doc, n, t) {
|
||||
put(&key, value);
|
||||
}
|
||||
let shadows: Vec<String> = n
|
||||
.effects
|
||||
@@ -858,11 +876,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra
|
||||
swift_layer(ctx, n, depth, out, extra);
|
||||
}
|
||||
|
||||
/// A layer name safe for a single-line `//` comment: line breaks become spaces.
|
||||
fn one_line(name: &str) -> String {
|
||||
name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
|
||||
}
|
||||
|
||||
fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
|
||||
let doc = ctx.doc;
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", n.name));
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
return;
|
||||
}
|
||||
// Nested instances are calls; an instance swap parameter is a view.
|
||||
@@ -1035,7 +1058,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
let doc = ctx.doc;
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", n.name));
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
return;
|
||||
}
|
||||
// Nested instances are calls; an instance swap parameter is a composable.
|
||||
@@ -1168,4 +1191,26 @@ mod tests {
|
||||
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
|
||||
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
|
||||
let mut s = Session::new();
|
||||
let name = "deep\nevil()\r\u{2028}more()";
|
||||
let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap();
|
||||
let root = parent;
|
||||
for _ in 0..16 {
|
||||
parent =
|
||||
id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"])
|
||||
.unwrap();
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
|
||||
let code = code_for(doc, root, lang);
|
||||
assert!(code.contains("// deep evil() more() …\n"), "{code}");
|
||||
for line in code.lines() {
|
||||
let t = line.trim_start();
|
||||
assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() {
|
||||
assert!(doc.node(glyph).unwrap().overrides.is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() {
|
||||
// Without unbinding first: the explicit colour detaches the variable on the instance layer only,
|
||||
// and stays through main edits until the override is reset.
|
||||
let mut s = Session::new();
|
||||
let (chip, _) = bound_chip(&mut s);
|
||||
let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap();
|
||||
let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}}));
|
||||
let n = s.doc().unwrap().node(bg).unwrap().clone();
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
||||
assert!(!n.bound.contains_key("fill"));
|
||||
assert!(n.overrides.contains("fills"));
|
||||
let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"}));
|
||||
let doc = s.doc().unwrap();
|
||||
healthy(doc);
|
||||
assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
||||
assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding");
|
||||
ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"}));
|
||||
let n = s.doc().unwrap().node(bg).unwrap();
|
||||
assert!(n.bound.contains_key("fill"));
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566");
|
||||
}
|
||||
|
||||
@@ -194,3 +194,26 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() {
|
||||
assert!(err.contains("holds sections"), "{err}");
|
||||
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_errors_name_the_command_that_was_run() {
|
||||
// Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names
|
||||
// the command that asked, not `object.group`.
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] {
|
||||
let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}");
|
||||
}
|
||||
let r = rect(&mut s, page, 0.0, 0.0);
|
||||
let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`object.mask`"), "{err}");
|
||||
let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}}));
|
||||
let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`vector.outlineStroke`"), "{err}");
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100})));
|
||||
let inner = rect(&mut s, f, 10.0, 10.0);
|
||||
let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`object.wrapInSection`"), "{err}");
|
||||
}
|
||||
|
||||
@@ -51,6 +51,32 @@ fn effect_set_edits_noise_texture_and_glass() {
|
||||
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err());
|
||||
}
|
||||
|
||||
/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word,
|
||||
/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset.
|
||||
#[test]
|
||||
fn effects_refuse_keys_they_dont_have() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3});
|
||||
let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string();
|
||||
assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}");
|
||||
assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored");
|
||||
let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string();
|
||||
assert!(err.contains("radious"), "{err}");
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
|
||||
let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string();
|
||||
assert!(err.contains("spred"), "{err}");
|
||||
// Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports.
|
||||
for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] {
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
|
||||
}
|
||||
let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone();
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap();
|
||||
let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true});
|
||||
let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false});
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_scales_grain_and_glass() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -214,6 +214,26 @@ fn css_for_common_layers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't
|
||||
/// boxed in, and a side bound to a variable names it.
|
||||
#[test]
|
||||
fn css_borders_follow_individual_stroke_sides() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]}));
|
||||
let c = css(&mut s, r);
|
||||
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
|
||||
assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}");
|
||||
|
||||
let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"}));
|
||||
let col = col["id"].as_str().unwrap().to_string();
|
||||
ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
|
||||
let c = css(&mut s, r);
|
||||
assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}");
|
||||
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_code_for_a_simple_layer() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1100,8 +1100,11 @@ impl Session {
|
||||
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
|
||||
let mut doc = (*it.doc).clone();
|
||||
let mut sel = it.selection.clone();
|
||||
// What the gesture changes on the selected layers is an explicit edit.
|
||||
let before = cmd::styles::bound_before(&doc, &it.selection);
|
||||
let res = f(&mut doc, &mut sel);
|
||||
if res.is_ok() {
|
||||
cmd::styles::detach_edited(&mut doc, &before);
|
||||
normalize(&mut doc, &it.doc);
|
||||
// The same preview again (a handle held still) keeps the document: a new one would
|
||||
// re-render the canvas for nothing.
|
||||
|
||||
@@ -81,7 +81,7 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97
|
||||
|
||||
/// `#RRGGBB` → (r, g, b); `None` for anything else.
|
||||
pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> {
|
||||
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?;
|
||||
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
|
||||
Some((byte(0)?, byte(2)?, byte(4)?))
|
||||
}
|
||||
|
||||
@@ -507,3 +507,11 @@ fn hosting_commands_check_what_they_are_given() {
|
||||
assert!(e.contains("join a file"), "{e}");
|
||||
assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_color_rejects_signs_and_non_hex() {
|
||||
use crate::live::parse_color;
|
||||
assert_eq!(parse_color("#+1+2+3"), None);
|
||||
assert_eq!(parse_color("#-1-2-3"), None);
|
||||
assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255)));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::lists::{BoundList, list_binding};
|
||||
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
|
||||
use crate::params::*;
|
||||
use crate::{CommandSpec, Result, Session, bad, spec};
|
||||
|
||||
@@ -153,37 +153,6 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>
|
||||
Ok(ts)
|
||||
}
|
||||
|
||||
/// Detach variables only where the explicit edit changed their value. In particular, an
|
||||
/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings.
|
||||
fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) {
|
||||
// Range fills override the node's paints; the node binding still belongs to other chars.
|
||||
if t.range.is_some() {
|
||||
return;
|
||||
}
|
||||
let list = if t.fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
n.bound.retain(|key, _| {
|
||||
let Some((l, index, field)) = list_binding(key) else { return true };
|
||||
if l != list || index != t.index {
|
||||
return true;
|
||||
}
|
||||
match field {
|
||||
"color" => match (&old.kind, &new.kind) {
|
||||
(PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b,
|
||||
_ => false,
|
||||
},
|
||||
"opacity" => old.opacity == new.opacity,
|
||||
_ => {
|
||||
let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::<usize>().ok());
|
||||
match (stop, &old.kind, &new.kind) {
|
||||
(Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color),
|
||||
(Some(_), _, _) => false,
|
||||
_ => old == new,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "selection.setPaint";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
@@ -197,18 +166,22 @@ fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let old = slot.clone();
|
||||
*slot = paint.clone();
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
unbind_changed(n, target, &old, &paint);
|
||||
// Variables detach only where the edit changed their value; range fills override the node's
|
||||
// paints, whose bindings still belong to the other characters.
|
||||
if let Some((start, end)) = target.range {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
|
||||
}
|
||||
} else if target.fills {
|
||||
continue;
|
||||
}
|
||||
if target.fills {
|
||||
n.fills = paints;
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.strokes = paints;
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
rebind_edited_paint(n, target.fills, target.index, &old);
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec<Value> {
|
||||
ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A refused slot is reported by `component.makeSlot`, the command that was run, not by the
|
||||
/// property command it adds its slot property through.
|
||||
#[test]
|
||||
fn a_refused_slot_names_make_slot() {
|
||||
let mut s = Session::new();
|
||||
let (c, ..) = slot_card(&mut s);
|
||||
let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Other"})));
|
||||
let err = s.execute("component.makeSlot", &json!({"id": other, "name": "Content"})).unwrap_err().to_string();
|
||||
assert!(err.contains("`component.makeSlot`") && err.contains("a property named `Content` already exists"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_are_frames_of_a_main_with_a_slot_property() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -279,6 +279,34 @@ fn bindings_by_field_and_type() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_bindings_go_with_the_auto_layout() {
|
||||
// Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings,
|
||||
// and a binding never brings an auto layout back. One made without auto layout waits for it.
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C"}));
|
||||
run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12}));
|
||||
let removals =
|
||||
[("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))];
|
||||
for (cmd, mut p) in removals {
|
||||
let f = frame(&mut s, None);
|
||||
run(&mut s, "layout.add", json!({"ids": [f]}));
|
||||
for field in ["gap", "crossGap", "paddingTop"] {
|
||||
run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"}));
|
||||
}
|
||||
p["ids"] = json!([f]);
|
||||
run(&mut s, cmd, p.clone());
|
||||
let n = node(&s, f);
|
||||
assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed");
|
||||
assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped");
|
||||
}
|
||||
let f = frame(&mut s, None);
|
||||
run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"}));
|
||||
assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout");
|
||||
run(&mut s, "layout.add", json!({"ids": [f]}));
|
||||
assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_bound_variable_keeps_the_last_value() {
|
||||
// Documented: deleting a variable unbinds what used it, which keeps showing its last value.
|
||||
@@ -365,3 +393,90 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() {
|
||||
s.undo().unwrap();
|
||||
assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_collection_keeps_at_least_one_named_mode() {
|
||||
// A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode),
|
||||
// so createCollection refuses an empty mode list, blank mode names and a blank collection name.
|
||||
let mut s = Session::new();
|
||||
for p in [
|
||||
json!({"name": "C", "modes": []}),
|
||||
json!({"name": "C", "modes": [1]}),
|
||||
json!({"name": "C", "modes": ["", " "]}),
|
||||
json!({"name": "C", "modes": ["A", " "]}),
|
||||
json!({"name": " "}),
|
||||
json!({"name": ""}),
|
||||
] {
|
||||
assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused");
|
||||
}
|
||||
assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing");
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]}));
|
||||
let names: Vec<String> = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect();
|
||||
assert_eq!(names, ["Light", "Dark"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() {
|
||||
// #809: an edit of a bound field detaches its binding (as a paint edit does) instead of
|
||||
// the variable's value coming back; bindings whose value the edit kept stay.
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C"}));
|
||||
let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48})));
|
||||
let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40})));
|
||||
let r = rect(&mut s, None);
|
||||
for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] {
|
||||
run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v}));
|
||||
}
|
||||
assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4));
|
||||
|
||||
run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4}));
|
||||
let n = node(&s, r);
|
||||
assert_eq!(n.width, 100.0);
|
||||
assert!(!n.bound.contains_key("width"));
|
||||
assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings");
|
||||
|
||||
run(&mut s, "transform.resize", json!({"ids": [r], "height": 30}));
|
||||
assert_eq!(node(&s, r).height, 30.0);
|
||||
assert!(!node(&s, r).bound.contains_key("height"));
|
||||
|
||||
run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"}));
|
||||
assert_eq!(node(&s, r).opacity, 0.9);
|
||||
assert!(node(&s, r).bound.is_empty());
|
||||
|
||||
// A value set with its binding in the same call keeps the new binding.
|
||||
run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}}));
|
||||
assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C"}));
|
||||
let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8})));
|
||||
let r = rect(&mut s, None);
|
||||
run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v}));
|
||||
s.begin_interaction("Edit").unwrap();
|
||||
let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]);
|
||||
s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(node(&s, r).corners.radii, [2.0; 4]);
|
||||
assert!(node(&s, r).bound.is_empty());
|
||||
|
||||
let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"})));
|
||||
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
|
||||
run(&mut s, "text.style", json!({"id": t, "fontSize": 20}));
|
||||
assert!(node(&s, t).bound.is_empty());
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0);
|
||||
|
||||
// The font size/weight/tracking shortcuts and text content edits detach too.
|
||||
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0);
|
||||
run(&mut s, "text.sizeUp", json!({"ids": [t]}));
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0);
|
||||
assert!(node(&s, t).bound.is_empty());
|
||||
let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"})));
|
||||
run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c}));
|
||||
run(&mut s, "text.set", json!({"id": t, "characters": "Bye"}));
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye");
|
||||
assert!(node(&s, t).bound.is_empty());
|
||||
}
|
||||
|
||||
@@ -371,3 +371,29 @@ fn variant_properties_follow_string_variables() {
|
||||
assert_eq!(main(&s), variants[1]);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"}));
|
||||
let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone();
|
||||
// Removing the first stop moves both bindings down with their stops.
|
||||
let mut p = cur(&s);
|
||||
if let PaintKind::Gradient(g) = &mut p.kind {
|
||||
g.stops.remove(0);
|
||||
}
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
|
||||
assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]);
|
||||
// Recolouring one bound stop detaches that stop only.
|
||||
let mut p = cur(&s);
|
||||
if let PaintKind::Gradient(g) = &mut p.kind {
|
||||
g.stops[0].color = Color::WHITE;
|
||||
}
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
|
||||
assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]);
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"}));
|
||||
assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user