mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
@@ -678,8 +678,8 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
}
|
||||
// Copy every overridden layer's values to its source in the main (geometry, swaps, property
|
||||
// values and slot content stay with the instance).
|
||||
// Copy every overridden layer's values to its source in the main (geometry, resizing, swaps,
|
||||
// property values and slot content stay with the instance).
|
||||
let mut pushed = 0;
|
||||
for d in doc.subtree(inst) {
|
||||
let Some(n) = doc.node(d).cloned() else { continue };
|
||||
@@ -691,7 +691,9 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let groups: BTreeSet<String> = n
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name"))
|
||||
.filter(|g| {
|
||||
!matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for g in &groups {
|
||||
|
||||
@@ -783,12 +783,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
if !unknown.is_empty() {
|
||||
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
|
||||
}
|
||||
// Inside an instance, record which property groups were overridden (an instance's own export
|
||||
// settings and interactions are its own, not overrides).
|
||||
// Inside an instance, record which property groups were overridden (an instance's own
|
||||
// resizing, export settings and interactions are its own, not overrides).
|
||||
let own_root = matches!(doc.get(id)?.kind, NodeKind::Instance { .. }) && !crate::components::inside_instance(doc, id);
|
||||
let n = doc.get_mut(id)?;
|
||||
if n.source.is_some() {
|
||||
for g in props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "exports" | "interactions"))) {
|
||||
for g in
|
||||
props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "resizing" | "exports" | "interactions")))
|
||||
{
|
||||
n.overrides.insert(g.to_string());
|
||||
}
|
||||
}
|
||||
@@ -810,7 +812,8 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
|
||||
| "hangingPunctuation" | "hangingList" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
|
||||
"width" | "height" | "w" | "h" => "size",
|
||||
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
|
||||
"gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
|
||||
"padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
|
||||
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment",
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
|
||||
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
|
||||
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
|
||||
//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested
|
||||
//! instance) and `slot` (a slot holding the instance's own content). An instance's own export
|
||||
//! settings and interactions are its own, like its place. `size` and `position` overrides of
|
||||
//! layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the
|
||||
//! main; the instance itself resizes.
|
||||
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
|
||||
//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
|
||||
//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
|
||||
//! export settings, interactions and resizing are its own, like its place. `position`
|
||||
//! overrides of layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
|
||||
//!
|
||||
//! Switching variants keeps an override where the layer's name path matches and the two variants
|
||||
//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
|
||||
@@ -58,6 +58,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
|
||||
"exports",
|
||||
"interactions",
|
||||
"size",
|
||||
"resizing",
|
||||
"position",
|
||||
"component",
|
||||
"props",
|
||||
@@ -388,11 +389,14 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
"size" => {
|
||||
to.width = from.width;
|
||||
to.height = from.height;
|
||||
to.child_layout = from.child_layout.clone();
|
||||
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
|
||||
b.auto_resize = a.auto_resize;
|
||||
}
|
||||
}
|
||||
"resizing" => {
|
||||
let (a, b) = (&from.child_layout, &mut to.child_layout);
|
||||
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
|
||||
}
|
||||
"position" => to.transform = from.transform,
|
||||
"component" => {
|
||||
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
|
||||
@@ -773,6 +777,15 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
for s in &src {
|
||||
let Some(nid) = map.get(s).copied() else { continue };
|
||||
let Some(o) = old.get(&nid) else { continue };
|
||||
// A frame that kept the size its own resizing gave it: its layers (copied at their main
|
||||
// layers' places) follow their constraints from its main layer's size.
|
||||
if o.overrides.contains("resizing")
|
||||
&& !o.overrides.contains("size")
|
||||
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
|
||||
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
|
||||
{
|
||||
figcraft_layout::apply_constraints(doc, nid, mw, mh);
|
||||
}
|
||||
if !o.overrides.contains("size") && !o.overrides.contains("position") {
|
||||
continue;
|
||||
}
|
||||
@@ -985,6 +998,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 {
|
||||
out.push("size");
|
||||
}
|
||||
if resizing(a) != resizing(b) {
|
||||
out.push("resizing");
|
||||
}
|
||||
if a.transform != b.transform {
|
||||
out.push("position");
|
||||
}
|
||||
@@ -1027,6 +1043,12 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes.
|
||||
fn resizing(n: &Node) -> (Sizing, Sizing, [Option<f64>; 4]) {
|
||||
let c = &n.child_layout;
|
||||
(c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h])
|
||||
}
|
||||
|
||||
/// Whether two texts differ in anything but their characters (the `textStyle` group).
|
||||
fn text_style_differs(x: &TextData, y: &TextData) -> bool {
|
||||
x.style != y.style
|
||||
@@ -1107,9 +1129,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
}
|
||||
let raw = diff_groups(b, n);
|
||||
let mut groups = raw.clone();
|
||||
// An instance's own place, export settings and interactions are its own, not overrides.
|
||||
// An instance's own place, resizing, export settings and interactions are its own, not
|
||||
// overrides.
|
||||
if top_root {
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions"));
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions"));
|
||||
}
|
||||
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
|
||||
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
|
||||
@@ -1414,7 +1437,7 @@ fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> b
|
||||
fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool {
|
||||
let Some(p) = doc.node(parent) else { return false };
|
||||
let constrains = p.layout.is_none() || absolute;
|
||||
let resized = p.overrides.contains("size")
|
||||
let resized = sized_own(p)
|
||||
|| match &p.kind {
|
||||
NodeKind::Instance { component, .. } => {
|
||||
doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9)
|
||||
@@ -1518,7 +1541,7 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
|
||||
chain.windows(2).all(|w| {
|
||||
let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false };
|
||||
let constrains = a.layout.is_none() || below.child_layout.absolute;
|
||||
if a.overrides.contains("size") && constrains {
|
||||
if sized_own(a) && constrains {
|
||||
return false;
|
||||
}
|
||||
match &a.kind {
|
||||
@@ -1530,6 +1553,12 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a layer inside an instance overrides its size or resizing: it may not have its main
|
||||
/// layer's size, so the layers in it can't take their main layers' places as they are.
|
||||
fn sized_own(n: &Node) -> bool {
|
||||
n.overrides.contains("size") || n.overrides.contains("resizing")
|
||||
}
|
||||
|
||||
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
|
||||
/// outwards), each outermost instance once, inner components first.
|
||||
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
|
||||
@@ -1665,10 +1694,9 @@ pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), St
|
||||
|
||||
/// What about layer `id` an edit from `before` to `after` changed that only its main can: the
|
||||
/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the
|
||||
/// position (rotation too), size, constraints or resizing of a layer inside an instance (the
|
||||
/// instance itself resizes, and its layers follow; text whose box follows its content still
|
||||
/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a
|
||||
/// library update).
|
||||
/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer
|
||||
/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also
|
||||
/// when the main changed the same way in this edit (a library update).
|
||||
pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> {
|
||||
let (n, b) = (after.node(id)?, before.node(id)?);
|
||||
if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() {
|
||||
@@ -1684,8 +1712,12 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
l
|
||||
})
|
||||
};
|
||||
let size = |x: &Node| (x.width, x.height);
|
||||
let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6;
|
||||
// Child layout settings other than the resizing ones.
|
||||
let placing = |x: &Node| {
|
||||
let mut c = x.child_layout.clone();
|
||||
(c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None);
|
||||
c
|
||||
};
|
||||
if mode(n) != mode(b) && mode(ma) == mode(mb) {
|
||||
return Some("The auto layout direction");
|
||||
}
|
||||
@@ -1698,14 +1730,11 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
|
||||
return Some("A layer's position");
|
||||
}
|
||||
if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") {
|
||||
return Some("A layer's size");
|
||||
}
|
||||
if n.constraints != b.constraints && ma.constraints == mb.constraints {
|
||||
return Some("A layer's constraints");
|
||||
}
|
||||
if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout {
|
||||
return Some("A layer's resizing");
|
||||
if placing(n) != placing(b) && placing(ma) == placing(mb) {
|
||||
return Some("A layer's place in auto layout");
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1923,6 +1952,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
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()))
|
||||
.filter(|g| placed_source.is_some() || g != "resizing")
|
||||
.collect();
|
||||
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
|
||||
if named {
|
||||
@@ -1946,7 +1976,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
}
|
||||
if let Some(n) = doc.node_mut(inst) {
|
||||
n.source = root_base;
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
|
||||
// A top-level instance's resizing is its own (a nested one's overrides its main layer's).
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
|
||||
n.overrides.extend(root_groups);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,14 +316,6 @@ fn what_instances_cannot_override_is_refused() {
|
||||
("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"),
|
||||
("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"),
|
||||
("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"),
|
||||
(
|
||||
"resize a layer",
|
||||
"node.set",
|
||||
json!({"id": "$", "props": {"width": 50}}),
|
||||
Layer::Bg,
|
||||
"not among the overridable properties; the instance resizes",
|
||||
),
|
||||
("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""),
|
||||
(
|
||||
"change constraints",
|
||||
"node.set",
|
||||
@@ -331,13 +323,6 @@ fn what_instances_cannot_override_is_refused() {
|
||||
Layer::Bg,
|
||||
"\"any constraints applied to the layers\"",
|
||||
),
|
||||
(
|
||||
"make a layer fill its auto layout",
|
||||
"node.set",
|
||||
json!({"id": "$", "props": {"sizingH": "fill"}}),
|
||||
Layer::Chip,
|
||||
"resizing comes with the layer's size",
|
||||
),
|
||||
(
|
||||
"take a layer out of auto layout",
|
||||
"node.set",
|
||||
|
||||
@@ -67,8 +67,6 @@ fn overrides_survive_main_edits() {
|
||||
ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}}));
|
||||
ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"}));
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}}));
|
||||
// A layer inside an instance keeps its main's size (the instance itself resizes).
|
||||
assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err());
|
||||
let ov = node(&s, ibg).overrides;
|
||||
for g in ["fills", "strokes", "effects", "name"] {
|
||||
assert!(ov.contains(g), "{g} in {ov:?}");
|
||||
|
||||
@@ -202,15 +202,16 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
|
||||
let before = s.doc().unwrap().world_bounds(inner).unwrap();
|
||||
assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0));
|
||||
ex(&mut s, "select.set", json!({"ids": [inner]}));
|
||||
// Resizing it by its right edge: only the main component can.
|
||||
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow");
|
||||
// Resizing it by its right edge: a size of its own (an override).
|
||||
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow");
|
||||
s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap();
|
||||
assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed);
|
||||
assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed);
|
||||
s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone");
|
||||
// Moving it: refused too.
|
||||
let before = s.doc().unwrap().world_bounds(inner).unwrap();
|
||||
assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays");
|
||||
// Moving it: only the main component can.
|
||||
s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap();
|
||||
|
||||
@@ -157,3 +157,49 @@ fn an_alt_drag_into_auto_layout_sets_no_step() {
|
||||
cmd_d(&mut s);
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
|
||||
}
|
||||
|
||||
/// Drag `from` by `by` with no modifier: moving the selection, not copying it.
|
||||
fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0);
|
||||
s.pointer(&ev(PointerKind::Down, from)).unwrap();
|
||||
for k in 1..=4 {
|
||||
let t = f64::from(k) / 4.0;
|
||||
s.pointer(&ev(PointerKind::Move, (from.0 + by.0 * t, from.1 + by.1 * t))).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap();
|
||||
}
|
||||
|
||||
/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests
|
||||
/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done,
|
||||
/// and the step is measured from where the copy ended up, whichever way it got there.
|
||||
#[test]
|
||||
fn duplicate_repeats_the_step_set_by_dragging_the_copy() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place");
|
||||
|
||||
drag_move(&mut s, (50.0, 50.0), (150.0, 20.0));
|
||||
assert_eq!(at(&s, c1), (150.0, 20.0), "the drag moved the copy, it did not make another one");
|
||||
assert_eq!(s.selection(), vec![c1], "and left it selected");
|
||||
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
|
||||
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again");
|
||||
let c3 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c3), (450.0, 60.0), "and again");
|
||||
}
|
||||
|
||||
/// And a step set with the arrow keys, which is how a small offset is usually made exact.
|
||||
#[test]
|
||||
fn duplicate_repeats_a_nudged_step() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "transform.nudge", json!({"dx": 40, "dy": 0}));
|
||||
assert_eq!(at(&s, c1), (40.0, 0.0));
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (80.0, 0.0), "the nudge is the step");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Size and resizing overrides of layers inside instances: a layer in an instance can hug, fill or
|
||||
//! keep a fixed size of its own, and that override is kept through relayout, edits of the main
|
||||
//! (only the overridden property stays), undo, save and reopen (native and .fig), REST import,
|
||||
//! detach and reset.
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{copy_of, ex, healthy, idv, node};
|
||||
use figcraft_doc::*;
|
||||
|
||||
/// A "Field" component: a 240×40 fixed horizontal auto layout frame (no padding or gap) holding
|
||||
/// "Atom", an auto layout frame hugging its 20×20 "Dot" with a min width of 100; and an instance of
|
||||
/// it. Returns (component, atom, dot, instance).
|
||||
fn field(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 240, "height": 40, "name": "Field"})));
|
||||
ex(s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "fixed", "sizingV": "fixed"}}));
|
||||
let atom = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": f, "name": "Atom"})));
|
||||
ex(
|
||||
s,
|
||||
"node.set",
|
||||
json!({"id": atom, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "hug", "sizingV": "hug", "minWidth": 100}}),
|
||||
);
|
||||
let dot = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": atom, "props": {"name": "Dot"}})));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
let i = idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 100})));
|
||||
(c, atom, dot, i)
|
||||
}
|
||||
|
||||
fn sizing_h(s: &Session, id: NodeId) -> Sizing {
|
||||
node(s, id).child_layout.sizing_h
|
||||
}
|
||||
|
||||
/// The layer named `name` below instance `inst`.
|
||||
fn named(s: &Session, inst: NodeId, name: &str) -> NodeId {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.subtree(inst).into_iter().find(|d| *d != inst && doc.node(*d).is_some_and(|n| n.name == name)).unwrap()
|
||||
}
|
||||
|
||||
/// The first instance in the document.
|
||||
fn instance(s: &Session) -> NodeId {
|
||||
s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap().id
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, bytes: &[u8], name: &str) {
|
||||
ex(s, "file.open", json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes), "name": name}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_in_an_instance_fills_and_keeps_it_through_main_edits() {
|
||||
let mut s = Session::new();
|
||||
let (c, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0), "hugs its dot, at least 100 wide");
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0), "fills the instance");
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
|
||||
// Edits of the main relay the instance out: the override stays, other properties follow.
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30, "fill": "#ff0000"}}));
|
||||
assert_eq!((node(&s, ia).width, node(&s, idot).width), (240.0, 30.0));
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120, "fill": "#00ff00"}}));
|
||||
assert_eq!(node(&s, atom).width, 120.0);
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).child_layout.min_w), (Sizing::Fill, 240.0, Some(100.0)));
|
||||
assert_eq!(node(&s, ia).fills, node(&s, atom).fills, "the fill isn't overridden: it follows the main");
|
||||
// The main fixing its own width is the same property: the instance keeps filling.
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"width": 80}}));
|
||||
assert_eq!((sizing_h(&s, atom), sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, Sizing::Fill, 240.0));
|
||||
// A structure edit of the main rebuilds the instance: the atom fills what the new layer leaves.
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": c}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 235.0));
|
||||
healthy(&s);
|
||||
// Back to the main's resizing: no override left.
|
||||
ex(&mut s, "component.resetOverride", json!({"id": ia, "group": "resizing"}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, node(&s, atom).width));
|
||||
assert!(!node(&s, ia).overrides.contains("resizing"));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_and_resizing_overrides_undo_reset_and_detach() {
|
||||
let mut s = Session::new();
|
||||
let (_, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0));
|
||||
assert!(node(&s, ia).overrides.is_empty());
|
||||
// A fixed size of its own: kept when the main resizes the layer.
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 50, "height": 10}}));
|
||||
assert!(node(&s, idot).overrides.contains("size"));
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
|
||||
assert_eq!((node(&s, idot).width, node(&s, idot).height, node(&s, dot).width), (50.0, 10.0, 30.0));
|
||||
// A min width of its own.
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"minWidth": 150}}));
|
||||
assert_eq!(node(&s, ia).width, 150.0);
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 90}}));
|
||||
assert_eq!(node(&s, ia).width, 150.0);
|
||||
healthy(&s);
|
||||
// Reset all changes: the main's values again.
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
|
||||
assert_eq!((node(&s, ia).width, node(&s, idot).width), (90.0, 30.0));
|
||||
assert!(node(&s, ia).overrides.is_empty() && node(&s, idot).overrides.is_empty());
|
||||
// Detaching keeps what the instance showed.
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).source), (Sizing::Fill, 240.0, None));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_in_instances_resize_but_keep_their_place() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let title = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||||
let it = copy_of(&s, i, title);
|
||||
ex(&mut s, "node.set", json!({"id": it, "props": {"width": 150}}));
|
||||
assert_eq!(node(&s, it).width, 150.0, "a text box resizes");
|
||||
assert!(node(&s, it).overrides.contains("size"));
|
||||
// Its place, rotation and constraints are still the main's.
|
||||
for props in [json!({"x": 50}), json!({"rotation": 15}), json!({"constraints": {"horizontal": "right"}})] {
|
||||
assert!(s.execute("node.set", &json!({"id": it, "props": props})).is_err(), "{props} is refused");
|
||||
}
|
||||
// The instance's own resizing is its own, not an override.
|
||||
ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 100}}));
|
||||
assert!(!node(&s, i).overrides.contains("resizing"));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_overrides_survive_save_and_reopen() {
|
||||
let mut s = Session::new();
|
||||
let (_, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 40}}));
|
||||
let native = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let (fig, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
for (bytes, name) in [(native, "field.figcraft"), (fig, "field.fig")] {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &bytes, name);
|
||||
let i = instance(&s);
|
||||
let (ia, idot) = (named(&s, i, "Atom"), named(&s, i, "Dot"));
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{name}: {:?}", node(&s, ia).overrides);
|
||||
assert!(node(&s, idot).overrides.contains("size"), "{name}: {:?}", node(&s, idot).overrides);
|
||||
// An edit of the main relays the instance out.
|
||||
let main = |s: &Session, id: NodeId| node(s, id).source.unwrap();
|
||||
let (atom, dot) = (main(&s, ia), main(&s, idot));
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120}}));
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, idot).width), (Sizing::Fill, 240.0, 40.0), "{name}");
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_resizing_overrides_survive_relayout() {
|
||||
let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]);
|
||||
let dot = |id: &str| json!({"id": id, "name": "Dot", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}});
|
||||
let atom = |id: &str, dot_id: &str, w: f64, more: Value| {
|
||||
let mut a = json!({"id": id, "name": "Atom", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": w, "y": 20},
|
||||
"layoutMode": "HORIZONTAL", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "AUTO", "minWidth": 100, "children": [dot(dot_id)]});
|
||||
a.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
|
||||
a
|
||||
};
|
||||
let field = |id: &str, ty: &str, y: f64, kids: Value| {
|
||||
json!({"id": id, "name": "Field", "type": ty, "relativeTransform": at(0.0, y), "size": {"x": 240, "y": 40}, "layoutMode": "HORIZONTAL",
|
||||
"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED", "children": kids})
|
||||
};
|
||||
let mut inst =
|
||||
field("2:1", "INSTANCE", 100.0, json!([atom("I2:1;1:2", "I2:1;1:3", 240.0, json!({"layoutSizingHorizontal": "FILL", "layoutGrow": 1}))]));
|
||||
inst["componentId"] = json!("1:1");
|
||||
inst["overrides"] = json!([{"id": "I2:1;1:2", "overriddenFields": ["layoutSizingHorizontal", "layoutGrow"]}]);
|
||||
let file = json!({"name": "Field", "document": {"id": "0:0", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [field("1:1", "COMPONENT", 0.0, json!([atom("1:2", "1:3", 100.0, json!({}))])), inst]}
|
||||
]}, "components": {"1:1": {"key": "k", "name": "Field", "description": ""}}, "styles": {}});
|
||||
let mut s = Session::new();
|
||||
open(&mut s, file.to_string().as_bytes(), "field.json");
|
||||
let i = instance(&s);
|
||||
let ia = named(&s, i, "Atom");
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
|
||||
// The main's first edit relays every instance out from it.
|
||||
let main_atom = node(&s, ia).source.unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": main_atom, "props": {"minWidth": 120}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
|
||||
healthy(&s);
|
||||
}
|
||||
@@ -610,6 +610,9 @@ pub struct Prefs {
|
||||
/// The widths of the main window's left and right panels (the interface keeps them within its
|
||||
/// limits; other windows have their own while they are open).
|
||||
pub panel_widths: (f32, f32),
|
||||
/// The height asked for the main window's pages list, dragged at its lower edge (`None`: a
|
||||
/// share of the panel; the interface keeps it within its limits).
|
||||
pub pages_height: Option<f32>,
|
||||
/// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser
|
||||
/// language). Languages without a catalog show English.
|
||||
pub language: String,
|
||||
@@ -678,6 +681,7 @@ impl Default for Prefs {
|
||||
show_dimensions: true,
|
||||
theme: "system".into(),
|
||||
panel_widths: (240.0, 240.0),
|
||||
pages_height: None,
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
@@ -1620,6 +1624,8 @@ mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_resizing_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_links_tests;
|
||||
|
||||
@@ -449,9 +449,27 @@ struct Stamp {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Entry {
|
||||
stamp: Option<Stamp>,
|
||||
/// When the file was read: a stamp only proves the file unchanged once its modification time
|
||||
/// is safely older than that, since file systems keep the time in coarse ticks.
|
||||
read_at: Option<std::time::SystemTime>,
|
||||
result: std::result::Result<Arc<PublishedLibrary>, String>,
|
||||
}
|
||||
|
||||
/// How far a file's modification time must lie before its read for the stamp to be trusted.
|
||||
const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
impl Entry {
|
||||
/// A file stamped in the future (clock skew, unpacked archives) never settles: it is re-read on
|
||||
/// every load until the clock passes it.
|
||||
fn still_valid(&self, now: &Stamp) -> bool {
|
||||
let settled = match (now.modified, self.read_at) {
|
||||
(Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at),
|
||||
_ => false,
|
||||
};
|
||||
settled && self.stamp.as_ref() == Some(now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Library files read by a session, by path (re-read when they change on disk).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cache {
|
||||
@@ -481,10 +499,11 @@ impl Cache {
|
||||
return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024)));
|
||||
}
|
||||
if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp)
|
||||
&& e.stamp.as_ref() == Some(st)
|
||||
&& e.still_valid(st)
|
||||
{
|
||||
return e.result.clone().map_err(EngineError::Other);
|
||||
}
|
||||
let read_at = now();
|
||||
let result = match &stamp {
|
||||
Ok(_) => read_file(path).map(Arc::new),
|
||||
Err(e) => Err(e.clone()),
|
||||
@@ -492,7 +511,7 @@ impl Cache {
|
||||
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
|
||||
self.entries.clear();
|
||||
}
|
||||
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() });
|
||||
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() });
|
||||
result.map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
@@ -506,10 +525,20 @@ impl Cache {
|
||||
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
|
||||
self.entries.clear();
|
||||
}
|
||||
self.entries.insert(path.to_string(), Entry { stamp: None, result });
|
||||
self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn now() -> Option<std::time::SystemTime> {
|
||||
Some(std::time::SystemTime::now())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn now() -> Option<std::time::SystemTime> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stat(path: &str) -> std::result::Result<Stamp, String> {
|
||||
let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
|
||||
@@ -445,3 +445,21 @@ fn library_survives_saving_with_versions() {
|
||||
healthy(&user);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
let read_at = SystemTime::now();
|
||||
let entry = |modified: SystemTime| Entry {
|
||||
stamp: Some(Stamp { len: 10, modified: Some(modified) }),
|
||||
read_at: Some(read_at),
|
||||
result: Err("unused".into()),
|
||||
};
|
||||
// Written in the same coarse tick as the read: a same-length rewrite would keep this stamp.
|
||||
let fresh = entry(read_at);
|
||||
assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) }));
|
||||
// Written well before the read: an unchanged stamp means an unchanged file.
|
||||
let old = read_at - Duration::from_secs(10);
|
||||
assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));
|
||||
assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user