Merge branch 'pr/678' into batch1

This commit is contained in:
Brandon Thomas 2026-10-13 02:21:39 -04:00
commit 528f5138df
2 changed files with 132 additions and 0 deletions

View File

@ -1923,6 +1923,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
for ((id, r), t) in items.iter().zip(targets) {
let delta = t - lo(r);
if let Some(it) = d.item_mut(*id) {
// Keep locked items in the spacing calculation, as align does for its reference.
if it.locked {
continue;
}
let v = if horizontal { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) };
it.xf = Affine::translate(v) * it.xf;
}

View File

@ -0,0 +1,128 @@
//! Distribution preserves explicit item locks without changing reference/spacing calculations.
use std::sync::Arc;
use designcraft_doc::ItemId;
use designcraft_engine::Session;
use designcraft_geom::Affine;
use serde_json::{Value, json};
const STARTS: [f64; 4] = [10.0, 70.0, 150.0, 310.0];
const SIZES: [f64; 4] = [20.0, 30.0, 40.0, 50.0];
// An original, synthetic 1x1 RGBA PNG; used by both a locked and an unlocked frame.
const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGPYYqPxHwAE2AIYBVnQbwAAAABJRU5ErkJggg==";
fn fixture(horizontal: bool, locks: [bool; 4]) -> (Session, Vec<u64>) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let mut ids = Vec::new();
for (i, (&start, &size)) in STARTS.iter().zip(&SIZES).enumerate() {
let cross = 40.0 + i as f64 * 35.0;
let rect = if horizontal { [start, cross, start + size, cross + 25.0] } else { [cross, start, cross + 25.0, start + size] };
let graphic = i == 1 || i == 2;
let created =
s.execute("frame.create", &json!({"rect": rect, "content": if graphic {"unassigned"} else {"text"}, "text": "Keep this text"})).unwrap();
let id = created["id"].as_u64().unwrap();
ids.push(id);
if graphic {
s.execute("file.place", &json!({"frame": id, "base64": PIXEL, "name": "pixel.png"})).unwrap();
}
}
// An unselected control must retain all geometry and content too.
s.execute("frame.create", &json!({"rect": [400, 400, 450, 450], "content": "text", "text": "Outside selection"})).unwrap();
s.execute("object.fill", &json!({"ids": ids, "swatch": "[Black]", "tint": 0.5})).unwrap();
let locked: Vec<u64> = ids.iter().zip(locks).filter_map(|(&id, lock)| lock.then_some(id)).collect();
if !locked.is_empty() {
s.execute("object.lock", &json!({"ids": locked})).unwrap();
}
// Layers selection may include locked items. Deliberately avoid geometric order.
s.execute("selection.set", &json!({"ids": [ids[3], ids[1], ids[0], ids[2]]})).unwrap();
assert!(!s.doc().unwrap().doc.assets.is_empty());
s.doc().unwrap().doc.check().unwrap();
(s, ids)
}
fn modes() -> [(Value, [f64; 4]); 3] {
[
// Endpoint centers 20 and 335 give a center step of 105.
(json!({"by": "centers"}), [10.0, 110.0, 210.0, 310.0]),
// Outer span 350 minus total widths 140 gives three gaps of 70.
(json!({"by": "spacing"}), [10.0, 100.0, 200.0, 310.0]),
// An explicit spacing value selects fixed-gap mode without requiring `by`.
(json!({"spacing": 15}), [10.0, 45.0, 90.0, 145.0]),
]
}
fn check_distribution(horizontal: bool, mut params: Value, positions: [f64; 4], locks: [bool; 4], explicit_ids: bool) {
let (mut s, ids) = fixture(horizontal, locks);
params["axis"] = json!(if horizontal { "horizontal" } else { "vertical" });
if explicit_ids {
params["ids"] = json!([ids[2], ids[0], ids[3], ids[1]]);
}
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let mut expected = (*before).clone();
for (i, &id) in ids.iter().enumerate() {
if !locks[i] {
let delta = positions[i] - STARTS[i];
let translation = if horizontal { (delta, 0.0) } else { (0.0, delta) };
let item = expected.item_mut(ItemId(id)).unwrap();
item.xf = Affine::translate(translation) * item.xf;
}
}
s.execute("object.distribute", &params).unwrap();
let after = s.doc().unwrap().doc.clone();
for (i, &id) in ids.iter().enumerate() {
let item = after.item(ItemId(id)).unwrap();
let b = item.bounds();
let start = if horizontal { b.x0 } else { b.y0 };
assert_eq!(start, if locks[i] { STARTS[i] } else { positions[i] }, "item {i}: {params}, locks={locks:?}");
if locks[i] {
assert_eq!(item, before.item(ItemId(id)).unwrap(), "locked item's transform, path, content and flags are unchanged");
}
}
// Only the specified translations may change: all paths, sizes, other-axis coordinates,
// paint, content, stories, layer membership, item order and embedded asset bytes are exact.
assert_eq!(*after, expected, "{params}, locks={locks:?}");
assert_eq!(s.doc().unwrap().selection, selection);
after.check().unwrap();
if expected != *before {
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before));
assert_eq!(s.doc().unwrap().selection, selection);
s.execute("edit.redo", &json!({})).unwrap();
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &after));
assert_eq!(s.doc().unwrap().selection, selection);
}
}
#[test]
fn distribute_preserves_locked_items_and_moves_unlocked_controls() {
for horizontal in [true, false] {
for (params, positions) in modes() {
// Locked interior object still participates in the numeric reference calculation.
check_distribution(horizontal, params.clone(), positions, [false, true, false, false], false);
// Locked endpoints and an interior object; only the unlocked interior control moves.
check_distribution(horizontal, params, positions, [true, true, false, true], true);
}
}
}
#[test]
fn distribute_unlocked_spacing_modes_are_unchanged() {
for horizontal in [true, false] {
for (params, positions) in modes() {
check_distribution(horizontal, params, positions, [false; 4], true);
}
}
}
#[test]
fn distribute_all_locked_leaves_document_and_selection_unchanged() {
for horizontal in [true, false] {
for (params, positions) in modes() {
check_distribution(horizontal, params, positions, [true; 4], false);
}
}
}