mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
Merge fix/630
This commit is contained in:
@@ -529,6 +529,7 @@ fn load_with(
|
||||
/// innermost first so outer instances settle last.
|
||||
fn link_instances(doc: &mut Document) {
|
||||
crate::style_vars::after_load(doc);
|
||||
super::styles::apply_paint_bindings_on_load(doc);
|
||||
let mut insts: Vec<(usize, NodeId)> =
|
||||
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect();
|
||||
insts.sort_by_key(|a| std::cmp::Reverse(a.0));
|
||||
@@ -1272,6 +1273,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
map.insert(id, doc.alloc_id());
|
||||
}
|
||||
}
|
||||
// Library copies a .fig import keeps (cached definitions) have no library record to follow:
|
||||
// the layers of its store come along into the store of `doc`. Recorded copies are left to
|
||||
// `merge_store`, which shares them with copies `doc` already has.
|
||||
let cached: Vec<NodeId> = other
|
||||
.library_store()
|
||||
.and_then(|s| other.node(s))
|
||||
.map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect())
|
||||
.unwrap_or_default();
|
||||
for root in &cached {
|
||||
for id in other.subtree(*root) {
|
||||
map.insert(id, doc.alloc_id());
|
||||
}
|
||||
}
|
||||
let mut new_pages = Vec::new();
|
||||
for pg in &other.pages {
|
||||
let Some(pn) = other.node(*pg) else { continue };
|
||||
@@ -1289,6 +1303,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
new_pages.push(np);
|
||||
}
|
||||
if !cached.is_empty() {
|
||||
let store = doc.ensure_library_store();
|
||||
for root in &cached {
|
||||
let nodes: Vec<Node> = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect();
|
||||
doc.paste_nodes(&nodes, &map, *root, store, None)?;
|
||||
}
|
||||
}
|
||||
for new in map.values() {
|
||||
let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied());
|
||||
if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) {
|
||||
@@ -1354,6 +1375,70 @@ mod rest_import_tests {
|
||||
doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap()
|
||||
}
|
||||
|
||||
/// What a .fig import with a cached library component leaves: the main in the hidden store, a
|
||||
/// visible instance of it and a layer linked to the main's layer.
|
||||
fn cached_import() -> Document {
|
||||
let mut doc = Document::new("Cached");
|
||||
let page = doc.first_page().unwrap();
|
||||
let store = doc.ensure_library_store();
|
||||
let main = doc
|
||||
.create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0)
|
||||
.unwrap();
|
||||
let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
let inst =
|
||||
doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
let mut copy = doc.node(body).unwrap().clone();
|
||||
copy.id = doc.alloc_id();
|
||||
copy.parent = None;
|
||||
copy.source = Some(body);
|
||||
let copy_id = copy.id;
|
||||
doc.insert(copy, inst, None).unwrap();
|
||||
assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body));
|
||||
doc
|
||||
}
|
||||
|
||||
/// Pasting layers that use a cached main into another document leaves the main behind, as it
|
||||
/// does for other mains that are not published: no link points at a layer of the target.
|
||||
#[test]
|
||||
fn pasting_instances_of_cached_components_leaves_no_dangling_links() {
|
||||
let src = cached_import();
|
||||
let mut doc = Document::new("Target");
|
||||
let page = doc.first_page().unwrap();
|
||||
for i in 0..8 {
|
||||
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
}
|
||||
let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id;
|
||||
let ids = src.subtree(inst);
|
||||
let map: std::collections::BTreeMap<NodeId, NodeId> = ids.iter().map(|i| (*i, doc.alloc_id())).collect();
|
||||
let nodes: Vec<Node> = ids.iter().filter_map(|i| src.node(*i).cloned()).collect();
|
||||
doc.paste_nodes(&nodes, &map, inst, page, None).unwrap();
|
||||
crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap();
|
||||
let pasted = doc.node(map[&inst]).unwrap();
|
||||
let NodeKind::Instance { component, .. } = pasted.kind else { panic!() };
|
||||
assert!(!doc.contains(component), "the main stays behind: {component:?}");
|
||||
assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_pages_with_cached_library_components_brings_the_components() {
|
||||
let other = cached_import();
|
||||
let mut doc = Document::new("Target");
|
||||
let page = doc.first_page().unwrap();
|
||||
// Ids of the target that would collide with the other document's store.
|
||||
for i in 0..8 {
|
||||
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
}
|
||||
append_pages(&mut doc, &other).unwrap();
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc));
|
||||
let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap();
|
||||
let NodeKind::Instance { component, .. } = inst.kind else { panic!() };
|
||||
assert_eq!(doc.node(component).unwrap().name, "Cached main");
|
||||
assert!(doc.is_remote(component), "the main comes along into the store");
|
||||
let layer = doc.node(inst.children[0]).unwrap();
|
||||
assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_json_loads_with_linked_instances() {
|
||||
let bytes = serde_json::to_vec(&kit()).unwrap();
|
||||
|
||||
@@ -913,6 +913,24 @@ pub fn apply_bindings(doc: &mut Document) {
|
||||
|
||||
/// [`apply_bindings`] for the layers `only` (every bound layer when `None`).
|
||||
pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>) {
|
||||
apply_bindings_matching(doc, only, |_| true);
|
||||
}
|
||||
|
||||
/// Settle imported paint colours before the document is first shown. Other bindings can affect
|
||||
/// layout or component structure, which must be resolved through the normal edit pipeline.
|
||||
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
let pending = doc.take_dirty();
|
||||
apply_bindings_matching(doc, None, |field| {
|
||||
super::lists::list_binding(field).is_some_and(|(list, _, prop)| {
|
||||
matches!(list, super::lists::BoundList::Fills | super::lists::BoundList::Strokes)
|
||||
&& (prop == "color" || super::lists::stop_binding(prop).is_some())
|
||||
})
|
||||
});
|
||||
doc.take_dirty();
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
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() {
|
||||
return;
|
||||
@@ -932,6 +950,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
let mut grid_sets: Vec<(usize, String, f64)> = Vec::new();
|
||||
let mut prop_sets: Vec<(String, VariableValue)> = Vec::new();
|
||||
for (field, var) in binds {
|
||||
if !include(&field) {
|
||||
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) {
|
||||
|
||||
+203
-23
@@ -1710,6 +1710,165 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
None
|
||||
}
|
||||
|
||||
/// Bounded source and parent lookups while a rebase reads an unchanged document.
|
||||
struct RebaseSources<'a> {
|
||||
doc: &'a Document,
|
||||
chains: BTreeMap<NodeId, Option<Vec<NodeId>>>,
|
||||
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
|
||||
main_changes: BTreeMap<NodeId, bool>,
|
||||
}
|
||||
|
||||
impl<'a> RebaseSources<'a> {
|
||||
const PARENTS: usize = 512;
|
||||
const CACHE: usize = 8192;
|
||||
|
||||
fn new(doc: &'a Document) -> Self {
|
||||
Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() }
|
||||
}
|
||||
|
||||
fn chain(&mut self, source: NodeId) -> Option<Vec<NodeId>> {
|
||||
if let Some(chain) = self.chains.get(&source) {
|
||||
return chain.clone();
|
||||
}
|
||||
let read = || {
|
||||
let mut chain = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut current = Some(source);
|
||||
for _ in 0..MAX_DEPTH {
|
||||
let Some(id) = current else { return Some(chain) };
|
||||
if !seen.insert(id) {
|
||||
return None;
|
||||
}
|
||||
let n = self.doc.node(id)?;
|
||||
chain.push(id);
|
||||
current = n.source;
|
||||
}
|
||||
current.is_none().then_some(chain)
|
||||
};
|
||||
let chain = read();
|
||||
if self.chains.len() < Self::CACHE {
|
||||
self.chains.insert(source, chain.clone());
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// Validate the complete parent chain, even if `main` was found before a cycle.
|
||||
fn in_main(&mut self, id: NodeId, main: NodeId) -> Option<bool> {
|
||||
if let Some(found) = self.parents.get(&(id, main)) {
|
||||
return *found;
|
||||
}
|
||||
let read = || {
|
||||
let mut current = Some(id);
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut found = false;
|
||||
for _ in 0..Self::PARENTS {
|
||||
let Some(id) = current else { return Some(found) };
|
||||
if !seen.insert(id) {
|
||||
return None;
|
||||
}
|
||||
found |= id == main;
|
||||
current = self.doc.node(id)?.parent;
|
||||
}
|
||||
current.is_none().then_some(found)
|
||||
};
|
||||
let found = read();
|
||||
if self.parents.len() < Self::CACHE {
|
||||
self.parents.insert((id, main), found);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option<NodeId> {
|
||||
let chain = self.chain(source)?;
|
||||
if chain.contains(&target) {
|
||||
return None;
|
||||
}
|
||||
let mut member = None;
|
||||
for id in chain {
|
||||
if self.in_main(id, main)? && id != main && member.is_none() {
|
||||
member = Some(id);
|
||||
}
|
||||
}
|
||||
member
|
||||
}
|
||||
|
||||
/// Nearest enclosing instances and their current mains. Slots with own content end scope.
|
||||
fn owners(&self, id: NodeId) -> Option<Vec<(NodeId, NodeId)>> {
|
||||
let mut owners = Vec::new();
|
||||
let mut current = self.doc.node(id)?.parent;
|
||||
let mut seen = BTreeSet::from([id]);
|
||||
for _ in 0..Self::PARENTS {
|
||||
let Some(id) = current else { return Some(owners) };
|
||||
if !seen.insert(id) {
|
||||
return None;
|
||||
}
|
||||
let n = self.doc.node(id)?;
|
||||
if holds_content(n) {
|
||||
return Some(owners);
|
||||
}
|
||||
if let NodeKind::Instance { component, .. } = &n.kind {
|
||||
owners.push((id, *component));
|
||||
}
|
||||
current = n.parent;
|
||||
}
|
||||
current.is_none().then_some(owners)
|
||||
}
|
||||
|
||||
fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
|
||||
owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main)
|
||||
}
|
||||
|
||||
/// A contextual child belongs to the same placed slot as its enclosing instance.
|
||||
fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
|
||||
let Some((owner, main)) = owners.first() else { return false };
|
||||
if self.member(source, *main, target).is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.in_main(source, *main) == Some(true) {
|
||||
return true;
|
||||
}
|
||||
let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false };
|
||||
matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main)
|
||||
&& self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner)
|
||||
}
|
||||
|
||||
fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option<NodeId> {
|
||||
let source = self.doc.node(id)?.source?;
|
||||
if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame)
|
||||
|| !self.valid(source, id, owners)
|
||||
|| !self.contextual(source, owners)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(source)
|
||||
}
|
||||
|
||||
fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool {
|
||||
for (index, (id, main)) in owners.iter().enumerate() {
|
||||
if *id == stop {
|
||||
break;
|
||||
}
|
||||
let changed = if let Some(changed) = self.main_changes.get(id) {
|
||||
*changed
|
||||
} else {
|
||||
let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) {
|
||||
Some(NodeKind::Instance { component, .. }) => component != main,
|
||||
Some(NodeKind::Frame) => true,
|
||||
_ => false,
|
||||
});
|
||||
if self.main_changes.len() < Self::CACHE {
|
||||
self.main_changes.insert(*id, changed);
|
||||
}
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
|
||||
/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
|
||||
/// the look is kept exactly. Values a component property sets aren't overrides.
|
||||
@@ -1722,22 +1881,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
|
||||
};
|
||||
let mut updates = Vec::new();
|
||||
let mut sources = RebaseSources::new(doc);
|
||||
let root_owners = sources.owners(inst).unwrap_or_default();
|
||||
let placed_source = sources.placed(inst, &root_owners);
|
||||
let mut contexts = vec![(inst, component)];
|
||||
contexts.extend_from_slice(&root_owners);
|
||||
for (id, path) in &mine {
|
||||
let Some(n) = doc.node(*id) else { continue };
|
||||
// Follow the source chain into the main first (exact), then fall back to the name path.
|
||||
let mut main = None;
|
||||
let mut cur = n.source;
|
||||
for _ in 0..MAX_DEPTH {
|
||||
let Some(c) = cur else { break };
|
||||
if c != component && doc.contains(c) && doc.is_ancestor(component, c) {
|
||||
main = Some(c);
|
||||
break;
|
||||
}
|
||||
cur = doc.node(c).and_then(|x| x.source);
|
||||
let owners = sources.owners(*id).unwrap_or_default();
|
||||
let nearest_main = owners.first().map(|(_, main)| *main);
|
||||
let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners));
|
||||
let mut main = valid_source.and_then(|source| sources.member(source, component, *id));
|
||||
// A swapped or Frame-to-Instance subtree is compared to its current main by the inner
|
||||
// rebase. An outer name path must not rebind those children to the old expanded copies.
|
||||
if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) {
|
||||
updates.push((*id, valid_source, n.overrides.clone()));
|
||||
continue;
|
||||
}
|
||||
let main = main.or_else(|| theirs.get(path).copied());
|
||||
main = main
|
||||
.or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some())));
|
||||
let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main);
|
||||
let mut overrides: BTreeSet<String> = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect();
|
||||
if let Some(m) = main.and_then(|m| doc.node(m)) {
|
||||
if let Some(m) = source.and_then(|source| doc.node(source)) {
|
||||
let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
|
||||
bound.extend(variable_groups(n, m));
|
||||
overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
|
||||
@@ -1745,36 +1910,51 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
overrides.insert("component".into());
|
||||
}
|
||||
}
|
||||
updates.push((*id, main, overrides));
|
||||
updates.push((*id, source, overrides));
|
||||
}
|
||||
for (id, main, overrides) in updates {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.source = main;
|
||||
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
|
||||
}
|
||||
}
|
||||
let root_groups = match (doc.node(component), doc.node(inst)) {
|
||||
// A placed nested root compares to its actual slot. A detached root compares to its own main.
|
||||
let root_base = placed_source.or_else(|| {
|
||||
let chain = sources.chain(component)?;
|
||||
(!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component)
|
||||
});
|
||||
let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) {
|
||||
(Some(m), Some(n)) => {
|
||||
let from_variables = variable_groups(n, m);
|
||||
let mut groups: Vec<String> = value_groups(m, n)
|
||||
.into_iter()
|
||||
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
|
||||
.collect();
|
||||
// A layer name of its own (a nested instance renamed in its old main) stays.
|
||||
if n.name != default_instance_name(doc, component) {
|
||||
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
|
||||
if named {
|
||||
groups.push("name".into());
|
||||
}
|
||||
if placed_source.is_some()
|
||||
&& matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b)
|
||||
{
|
||||
groups.push("component".into());
|
||||
}
|
||||
groups
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
drop(sources);
|
||||
for (id, main, overrides) in updates {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.source = main;
|
||||
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
|
||||
}
|
||||
}
|
||||
if let Some(n) = doc.node_mut(inst) {
|
||||
n.source = Some(component);
|
||||
n.source = root_base;
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
|
||||
n.overrides.extend(root_groups);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "components_rebase_source_tests.rs"]
|
||||
mod rebase_source_tests;
|
||||
|
||||
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
|
||||
/// the left-most).
|
||||
pub fn default_variant(doc: &Document, set: NodeId) -> Option<NodeId> {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId {
|
||||
doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap()
|
||||
}
|
||||
|
||||
fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
|
||||
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name)
|
||||
}
|
||||
|
||||
fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId {
|
||||
let mut n = doc.node(source).unwrap().clone();
|
||||
let children = std::mem::take(&mut n.children);
|
||||
n.id = doc.alloc_id();
|
||||
n.parent = None;
|
||||
n.source = Some(source);
|
||||
let id = n.id;
|
||||
doc.insert(n, parent, None).unwrap();
|
||||
for child in children {
|
||||
mirror(doc, child, id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId {
|
||||
let mut n = doc.node(main).unwrap().clone();
|
||||
let children = std::mem::take(&mut n.children);
|
||||
n.id = doc.alloc_id();
|
||||
n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() };
|
||||
n.name = name.into();
|
||||
n.parent = None;
|
||||
n.source = None;
|
||||
let id = n.id;
|
||||
doc.insert(n, parent, None).unwrap();
|
||||
for child in children {
|
||||
mirror(doc, child, id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
struct Scene {
|
||||
doc: Document,
|
||||
inner: NodeId,
|
||||
base: NodeId,
|
||||
slot: NodeId,
|
||||
context: NodeId,
|
||||
root: NodeId,
|
||||
nested: NodeId,
|
||||
child: NodeId,
|
||||
}
|
||||
|
||||
fn scene() -> Scene {
|
||||
let mut doc = Document::new("Source context");
|
||||
let page = doc.first_page().unwrap();
|
||||
let inner = component(&mut doc, page, "Inner");
|
||||
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
|
||||
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
let outer = component(&mut doc, page, "Outer");
|
||||
let slot = instance(&mut doc, inner, outer, "Nested");
|
||||
doc.node_mut(slot).unwrap().source = Some(inner);
|
||||
let context = doc.node(slot).unwrap().children[0];
|
||||
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
|
||||
doc.node_mut(context).unwrap().opacity = 0.3;
|
||||
let root = instance(&mut doc, outer, page, "Outer copy");
|
||||
let nested = doc.node(root).unwrap().children[0];
|
||||
let child = doc.node(nested).unwrap().children[0];
|
||||
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
doc.node_mut(child).unwrap().opacity = 0.2;
|
||||
Scene { doc, inner, base, slot, context, root, nested, child }
|
||||
}
|
||||
|
||||
fn both(s: &mut Scene) {
|
||||
rebase_instance(&mut s.doc, s.nested);
|
||||
rebase_instance(&mut s.doc, s.root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_default_only_sources_promote_to_the_actual_outer_context() {
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.nested).unwrap().source = None;
|
||||
s.doc.node_mut(s.child).unwrap().source = None;
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_rejects_a_context_source_from_another_nested_slot() {
|
||||
let mut s = scene();
|
||||
let outer = s.doc.node(s.slot).unwrap().parent.unwrap();
|
||||
let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot");
|
||||
s.doc.node_mut(sibling).unwrap().source = Some(s.inner);
|
||||
let sibling_child = s.doc.node(sibling).unwrap().children[0];
|
||||
mirror(&mut s.doc, sibling, s.root);
|
||||
s.doc.node_mut(s.child).unwrap().source = Some(sibling_child);
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0);
|
||||
assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_does_not_mark_inherited_context_values_as_local_overrides() {
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.child).unwrap().opacity = 0.3;
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_preserves_multilevel_context_without_shortening_source_chains() {
|
||||
let mut doc = Document::new("Nested source contexts");
|
||||
let page = doc.first_page().unwrap();
|
||||
let inner = component(&mut doc, page, "Inner");
|
||||
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
|
||||
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
let wrapper = component(&mut doc, page, "Wrapper");
|
||||
let default_slot = instance(&mut doc, inner, wrapper, "Nested");
|
||||
doc.node_mut(default_slot).unwrap().source = Some(inner);
|
||||
let outer = component(&mut doc, page, "Outer");
|
||||
let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot");
|
||||
doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper);
|
||||
let source_slot = doc.node(wrapper_slot).unwrap().children[0];
|
||||
let context = doc.node(source_slot).unwrap().children[0];
|
||||
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
|
||||
let root = instance(&mut doc, outer, page, "Outer copy");
|
||||
let wrapper_copy = doc.node(root).unwrap().children[0];
|
||||
let nested = doc.node(wrapper_copy).unwrap().children[0];
|
||||
let child = doc.node(nested).unwrap().children[0];
|
||||
doc.node_mut(nested).unwrap().name = "Renamed nested".into();
|
||||
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
for id in [nested, wrapper_copy, root] {
|
||||
rebase_instance(&mut doc, id);
|
||||
assert_eq!(doc.node(nested).unwrap().source, Some(source_slot));
|
||||
assert_eq!(doc.node(child).unwrap().source, Some(context));
|
||||
}
|
||||
assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0]));
|
||||
assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0);
|
||||
assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_preserves_placed_frame_to_instance_and_current_main_children() {
|
||||
let mut doc = Document::new("Typed nested source");
|
||||
let page = doc.first_page().unwrap();
|
||||
let inner = component(&mut doc, page, "Inner");
|
||||
let body = add(&mut doc, inner, NodeKind::Rectangle, "Body");
|
||||
let outer = component(&mut doc, page, "Outer");
|
||||
let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot");
|
||||
let root = instance(&mut doc, outer, page, "Outer copy");
|
||||
let nested = doc.node(root).unwrap().children[0];
|
||||
doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() };
|
||||
let child = mirror(&mut doc, body, nested);
|
||||
rebase_instance(&mut doc, nested);
|
||||
rebase_instance(&mut doc, root);
|
||||
assert_eq!(doc.node(nested).unwrap().source, Some(slot));
|
||||
assert_eq!(doc.node(child).unwrap().source, Some(body));
|
||||
assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() {
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let replacement = component(&mut s.doc, page, "Replacement");
|
||||
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
|
||||
s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0));
|
||||
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
|
||||
s.doc.remove(s.child).unwrap();
|
||||
s.child = mirror(&mut s.doc, body, s.nested);
|
||||
s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0));
|
||||
s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into();
|
||||
rebase_instance(&mut s.doc, s.nested);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
|
||||
rebase_instance(&mut s.doc, s.root);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(body));
|
||||
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() {
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let detached = mirror(&mut s.doc, s.nested, page);
|
||||
let child = s.doc.node(detached).unwrap().children[0];
|
||||
// The old source references still exist, but their Outer context is absent here.
|
||||
s.doc.node_mut(detached).unwrap().source = Some(s.slot);
|
||||
s.doc.node_mut(child).unwrap().source = Some(s.context);
|
||||
s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0));
|
||||
rebase_instance(&mut s.doc, detached);
|
||||
assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner));
|
||||
assert_eq!(s.doc.node(child).unwrap().source, Some(s.base));
|
||||
assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0);
|
||||
assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_does_not_preserve_existing_foreign_or_stale_sources() {
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let foreign = component(&mut s.doc, page, "Foreign");
|
||||
let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body");
|
||||
s.doc.node_mut(s.child).unwrap().source = Some(foreign_body);
|
||||
both(&mut s);
|
||||
assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let replacement = component(&mut s.doc, page, "Replacement");
|
||||
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
|
||||
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
|
||||
s.doc.node_mut(s.child).unwrap().source = Some(s.context);
|
||||
both(&mut s);
|
||||
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap");
|
||||
assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() {
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.base).unwrap().source = Some(s.context);
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, None);
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.context).unwrap().parent = Some(s.context);
|
||||
both(&mut s);
|
||||
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.nested).unwrap().parent = Some(s.child);
|
||||
s.doc.node_mut(s.child).unwrap().parent = Some(s.nested);
|
||||
let sources = RebaseSources::new(&s.doc);
|
||||
assert!(sources.owners(s.child).is_none());
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() };
|
||||
rebase_instance(&mut s.doc, s.root);
|
||||
assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root));
|
||||
}
|
||||
@@ -50,3 +50,43 @@ fn local_samples_match_their_exports() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layers of an instance copy keep their link to the layer they stand for when the file is
|
||||
/// opened, also after they were renamed in the copy (a name is no identity), and the override
|
||||
/// that a rename is stays an override.
|
||||
#[test]
|
||||
fn opened_fig_links_instance_layers_by_identity_not_by_name() {
|
||||
use base64::Engine as _;
|
||||
let mut s = Session::new();
|
||||
let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap();
|
||||
let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"}));
|
||||
let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}}));
|
||||
let inner = make(&mut s, "component.create", json!({"ids": [body]}));
|
||||
let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"}));
|
||||
let outer = make(&mut s, "component.create", json!({"ids": [frame]}));
|
||||
let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer}));
|
||||
s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap();
|
||||
let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200}));
|
||||
let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0];
|
||||
let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0];
|
||||
s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap();
|
||||
s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap();
|
||||
let _ = label;
|
||||
|
||||
let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id()));
|
||||
std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let mut opened = Session::new();
|
||||
let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
result.unwrap();
|
||||
let doc = opened.doc().unwrap();
|
||||
let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap();
|
||||
let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap();
|
||||
let slot = doc.node(copy.children[0]).unwrap();
|
||||
let label = doc.node(slot.children[0]).unwrap();
|
||||
assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label"));
|
||||
assert_eq!(slot.source, Some(outer.children[0]));
|
||||
assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0]));
|
||||
assert!(slot.overrides.contains("name") && label.overrides.contains("name"));
|
||||
}
|
||||
|
||||
@@ -401,6 +401,8 @@ impl DocState {
|
||||
doc.repair();
|
||||
}
|
||||
doc.link_layer_text_styles();
|
||||
cmd::styles::apply_paint_bindings_on_load(&mut doc);
|
||||
// Initial import settlement belongs to the saved baseline, not the user's first edit.
|
||||
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
|
||||
let doc = Arc::new(doc);
|
||||
let mut st = Self {
|
||||
|
||||
@@ -1221,9 +1221,16 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r
|
||||
if !variables_unchanged(before, after) {
|
||||
return Err(READ_ONLY.into());
|
||||
}
|
||||
let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) };
|
||||
let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store);
|
||||
if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) {
|
||||
let changed_remote = |id| {
|
||||
if !before.is_remote(id) && !after.is_remote(id) {
|
||||
return false;
|
||||
}
|
||||
match (before.node_arc(id), after.node_arc(id)) {
|
||||
(Some(old), Some(new)) => !Arc::ptr_eq(old, new),
|
||||
_ => true,
|
||||
}
|
||||
};
|
||||
if after.dirty().iter().any(|id| changed_remote(*id)) {
|
||||
return Err(READ_ONLY.into());
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -45,6 +45,38 @@ fn path_str(p: &std::path::Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn dirty_library_document() -> (Document, NodeId) {
|
||||
let mut doc = Document::new("Dirty library cache");
|
||||
let store = doc.ensure_library_store();
|
||||
let remote = doc.alloc_id();
|
||||
doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap();
|
||||
(doc, remote)
|
||||
}
|
||||
|
||||
/// An imported cache is dirty until the first edit; that alone is no edit of the library copies.
|
||||
#[test]
|
||||
fn read_only_allows_unchanged_predirty_remote_nodes() {
|
||||
let (before, remote) = dirty_library_document();
|
||||
assert!(before.dirty().contains(&remote));
|
||||
|
||||
let mut after = before.clone();
|
||||
after.add_page("Unrelated page");
|
||||
assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set");
|
||||
check_read_only("page.add", &before, &after).unwrap();
|
||||
|
||||
let mut edited = before.clone();
|
||||
edited.get_mut(remote).unwrap().opacity = 0.5;
|
||||
assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into()));
|
||||
|
||||
let mut moved = before.clone();
|
||||
moved.reparent(remote, before.first_page().unwrap(), None).unwrap();
|
||||
assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into()));
|
||||
|
||||
let mut deleted = before.clone();
|
||||
deleted.remove(remote).unwrap();
|
||||
assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into()));
|
||||
}
|
||||
|
||||
/// The library file's layers.
|
||||
struct Kit {
|
||||
button: NodeId,
|
||||
@@ -179,8 +211,16 @@ fn add_place_and_update_keep_overrides() {
|
||||
NodeKind::Instance { component, .. } => component,
|
||||
_ => panic!("not an instance"),
|
||||
};
|
||||
let unchanged = s.doc().unwrap().clone();
|
||||
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be edited"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
||||
for (cmd, args) in
|
||||
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
||||
{
|
||||
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
||||
}
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
@@ -281,10 +321,16 @@ fn library_styles_apply_and_import_with_pages() {
|
||||
let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap();
|
||||
let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") };
|
||||
assert!(doc.is_remote(component));
|
||||
let store_roots = |s: &Session| {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len())
|
||||
};
|
||||
assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice");
|
||||
// A second import shares the copies it already has.
|
||||
ex(&mut t, "file.import", json!({"path": path}));
|
||||
healthy(&t);
|
||||
assert_eq!(t.doc().unwrap().libraries.components.len(), 2);
|
||||
assert_eq!(store_roots(&t), 2);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,79 @@ use serde_json::json;
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn stale_bound_color_document() -> (figcraft_doc::Document, figcraft_doc::NodeId) {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&s.execute("shape.rectangle", &json!({"x": 4000, "y": 4000, "width": 23, "height": 17})).unwrap()["id"]).unwrap();
|
||||
let page = s.doc().unwrap().first_page().unwrap();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FFFFFF"})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": "size", "type": "number", "value": 23})).unwrap();
|
||||
s.execute("variable.set", &json!({"variable": "surface", "mode": "Dark", "value": "#202124"})).unwrap();
|
||||
s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 99})).unwrap();
|
||||
s.execute("variable.bind", &json!({"ids": [id], "field": "fill", "variable": "surface"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"ids": [id], "field": "width", "variable": "size"})).unwrap();
|
||||
s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
let node = doc.get_mut(id).unwrap();
|
||||
node.name = "load-binding-sentinel".into();
|
||||
node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)];
|
||||
node.width = 5.0;
|
||||
node.source = Some(page);
|
||||
node.overrides.insert("sentinel".into());
|
||||
doc.take_dirty();
|
||||
(doc, id)
|
||||
}
|
||||
|
||||
fn assert_dark_bound_color(doc: &figcraft_doc::Document, transform: figcraft_doc::geom::Affine) -> figcraft_doc::NodeId {
|
||||
let node = doc.nodes().find(|n| n.name == "load-binding-sentinel").unwrap();
|
||||
assert_eq!(node.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some("202124".into()));
|
||||
assert_eq!((node.transform, node.width, node.height), (transform, 5.0, 17.0));
|
||||
assert!(node.bound.contains_key("fills/0/color") || node.bound.contains_key("fill"));
|
||||
node.id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_color_bindings_settle_in_constructor_and_loaders() {
|
||||
let (stale, _id) = stale_bound_color_document();
|
||||
let name_transform = stale.nodes().find(|n| n.name == "load-binding-sentinel").unwrap().transform;
|
||||
|
||||
let mut state = Session::with_document(stale.clone());
|
||||
let id = assert_dark_bound_color(state.doc().unwrap(), name_transform);
|
||||
assert_eq!(state.doc().unwrap().node(id).unwrap().source, stale.first_page());
|
||||
assert!(state.doc().unwrap().node(id).unwrap().overrides.contains("sentinel"));
|
||||
assert!(!state.ds().unwrap().is_dirty());
|
||||
assert!(state.doc().unwrap().dirty().is_empty());
|
||||
assert_eq!(state.undo_depth(), (0, 0));
|
||||
state.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
|
||||
assert_dark_bound_color(state.doc().unwrap(), name_transform);
|
||||
|
||||
let native = figcraft_format::save_bytes(&stale, None).unwrap();
|
||||
let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap();
|
||||
// A native file settles when it opens as a document, not in the loader.
|
||||
let opened = Session::with_document(loaded);
|
||||
let loaded = opened.doc().unwrap();
|
||||
let loaded_id = assert_dark_bound_color(loaded, name_transform);
|
||||
assert_eq!(loaded_id, id);
|
||||
assert_eq!(loaded.node(id).unwrap().source, stale.first_page());
|
||||
assert!(loaded.node(id).unwrap().overrides.contains("sentinel"));
|
||||
assert!(loaded.dirty().is_empty());
|
||||
|
||||
let (fig, _) = figcraft_format::fig::export(&stale, None).unwrap();
|
||||
let (loaded, _) = crate::cmd::file::load_bytes(&fig, "bindings.fig").unwrap();
|
||||
assert_dark_bound_color(&loaded, name_transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_paint_settlement_preserves_existing_dirty_nodes() {
|
||||
let (mut doc, _) = stale_bound_color_document();
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.mark_dirty([page]);
|
||||
|
||||
crate::cmd::styles::apply_paint_bindings_on_load(&mut doc);
|
||||
|
||||
assert_eq!(doc.dirty().iter().copied().collect::<Vec<_>>(), [page]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_layers_follow_variable_values_and_unbind() {
|
||||
let mut s = Session::new();
|
||||
|
||||
Reference in New Issue
Block a user