From d42313cde2fde3cbcade9f4a375726a4df16a81a Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:36:02 +0200 Subject: [PATCH] ARRAYEDIT and ARRAYCLOSE for associative arrays (fixes #448) (#460) Squash of PR #460 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/array.rs | 19 + crates/engine/src/cmd/array.rs | 7 +- crates/engine/src/cmd/array/edit.rs | 930 +++++++++++++++++++++++ crates/engine/tests/assoc_array.rs | 164 ++++ crates/io/tests/assoc_array_roundtrip.rs | 1 + crates/ui-egui/src/i18n/uk.tsv | 2 + 6 files changed, 1122 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/array/edit.rs diff --git a/crates/doc/src/array.rs b/crates/doc/src/array.rs index 7ec0ba4..57d4da5 100644 --- a/crates/doc/src/array.rs +++ b/crates/doc/src/array.rs @@ -37,6 +37,23 @@ pub struct ArrayParams { /// Items that refer to another block than the source (ARRAYEDIT REPlace). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub replaced: Vec, + /// The source objects are out for editing (ARRAYEDIT Source, until ARRAYCLOSE). Not saved: + /// a drawing saved meanwhile keeps the objects as separate objects. + #[serde(skip)] + pub editing: Option, +} + +/// ARRAYEDIT Source in progress: the array's source objects placed in the drawing for editing. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SourceEdit { + /// The array (block reference) being edited. + pub array: crate::Handle, + /// The item the objects were placed at: (i, j, level). + pub item: (u32, u32, u32), + /// The objects placed for editing. + pub objects: Vec, + /// Objects with this handle or above were drawn while editing; they join the source too. + pub first_new: u64, } /// Rectangular or polar placement. @@ -272,6 +289,7 @@ mod tests { levels: 2, level_spacing: 5.0, replaced: Vec::new(), + editing: None, } } @@ -299,6 +317,7 @@ mod tests { levels: 1, level_spacing: 0.0, replaced: Vec::new(), + editing: None, }; let items = p.items().unwrap_or_default(); assert_eq!(items.len(), 8); diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 00cb51d..1bcd62e 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -20,6 +20,8 @@ use super::modify::transform_entities; use super::*; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +mod edit; + /// Most preview entities drawn while the array is edited. const PREVIEW_LIMIT: usize = 2000; /// Keyword-only prompts: each typed token is matched on its own (no free text). @@ -46,13 +48,16 @@ pub fn specs() -> Vec { ) .interactive(|_| Ok(Box::new(PolarM::default()))), ] + .into_iter() + .chain(edit::specs()) + .collect() } // ---------------- geometry ---------------- /// Array parameters around `kind` (the source block is named when the array is made). fn params(kind: ArrayKind, levels: u64, level_spacing: f64) -> ArrayParams { - ArrayParams { source: "*".into(), kind, levels: u32::try_from(levels).unwrap_or(u32::MAX), level_spacing, replaced: Vec::new() } + ArrayParams { source: "*".into(), kind, levels: u32::try_from(levels).unwrap_or(u32::MAX), level_spacing, replaced: Vec::new(), editing: None } } fn count32(n: u64) -> u32 { diff --git a/crates/engine/src/cmd/array/edit.rs b/crates/engine/src/cmd/array/edit.rs new file mode 100644 index 0000000..806a755 --- /dev/null +++ b/crates/engine/src/cmd/array/edit.rs @@ -0,0 +1,930 @@ +//! ARRAYEDIT and ARRAYCLOSE: change an associative array's parameters, move its base point, +//! replace items with other objects (REPlace) and undo that (RESet), and edit the source objects +//! (Source places them in the drawing; ARRAYCLOSE saves or discards the changes). +//! +//! Every change builds a new array block for the edited block reference, so copies of the array +//! that shared the old block keep it; blocks nothing refers to any more are removed. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use cadcraft_doc::{ + ArrayKind, ArrayParams, Block, Drawing, Entity, EntityKind, Handle, Insert, MAX_BLOCK_DEPTH, ReplacedItem, SourceEdit, entity_bounds, +}; +use cadcraft_geom::{Bounds2, Mat3, Vec2}; +use serde_json::{Value, json}; + +use super::super::helpers::v3; +use super::super::machines::{SelOutcome, SelectPhase, number}; +use super::super::*; +use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no}; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +pub(super) fn specs() -> Vec { + vec![ + CommandSpec::new("arrayedit", "Edit Array", run_arrayedit) + .menu(&["Modify", "Object", "Array"]) + .params( + "{handle, cols?, rows?, colSpacing?, rowSpacing?, axisAngle? (degrees), rowElevation?, levels?, levelSpacing?, items?, angle? (fill, degrees), angleBetween?, rotate?: bool, center? ([x, y]), base? ([x, y], the new base point), reset?: bool, replace?: {handles, base? ([x, y], default the objects' centre), items: [[i, j, level]…] | \"source\"}, source?: {item? ([i, j, level], default [0, 0, 0])} (places the source objects for editing; ARRAYCLOSE ends)}", + ) + .interactive(|_| Ok(Box::new(EditM::default()))), + CommandSpec::new("arrayclose", "Close Array Editing", run_arrayclose) + .params("{save?: bool (default true)}") + .interactive(|_| Ok(Box::new(CloseM))), + ] +} + +// ---------------- the array ---------------- + +/// The array block reference `h`, its array block and parameters (with the block names its items +/// use). +fn array_of(d: &Drawing, h: Handle) -> Result<(Insert, Arc, ArrayParams)> { + let e = d.entity(h).ok_or_else(|| other("No such object."))?; + let p = d.array_params(e).ok_or_else(|| other("The object is not an associative array."))?; + let EntityKind::Insert(ins) = &e.kind else { return Err(other("The object is not an associative array.")) }; + let blk = d.block(&ins.block).cloned().ok_or_else(|| other("The array's block is missing."))?; + Ok((ins.clone(), blk, p)) +} + +/// The open ARRAYEDIT Source, if any: the array block holding it and the state. +fn open_edit(d: &Drawing) -> Option<(String, SourceEdit)> { + d.blocks.iter().find_map(|(n, b)| b.array.as_ref().and_then(|a| a.editing.clone()).map(|e| (n.clone(), e))) +} + +fn refuse_while_editing(s: &Session) -> Result<()> { + if open_edit(s.doc()?).is_some() { + return Err(other("An array's source objects are being edited; enter ARRAYCLOSE first.")); + } + Ok(()) +} + +/// Where item (i, j, level) goes in array block coordinates: the image of the block's origin +/// and the item's rotation (a block's own base point is applied on top). +fn item_place(p: &ArrayParams, item: (u32, u32, u32)) -> Result { + let it = + p.items().unwrap_or_default().into_iter().find(|it| (it.i, it.j, it.level) == item).ok_or_else(|| other("The array has no such item."))?; + Ok(Mat3::translate(it.insert.xy()).then_before(Mat3::rotate(it.rotation))) +} + +/// Whether `e` refers to one of `names`, through nested block references. +fn refers_to(d: &Drawing, e: &Entity, names: &[&str]) -> bool { + let mut todo: Vec<(String, usize)> = match &e.kind { + EntityKind::Insert(i) => vec![(i.block.clone(), 0)], + _ => return false, + }; + let mut seen = HashSet::new(); + while let Some((b, depth)) = todo.pop() { + if names.iter().any(|n| n.eq_ignore_ascii_case(&b)) { + return true; + } + if depth > MAX_BLOCK_DEPTH || !seen.insert(b.to_ascii_uppercase()) { + continue; + } + if let Some(blk) = d.block(&b) { + todo.extend(blk.entities.iter().filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some((i.block.clone(), depth + 1)), + _ => None, + })); + } + } + false +} + +/// Remove the anonymous blocks among `names` that nothing refers to any more (in order, so a +/// source block goes after the array block that used it). +fn drop_unused(d: &mut Drawing, names: &[String]) { + for n in names { + if !n.starts_with('*') || d.block(n).is_none() { + continue; + } + let used = |e: &Arc| match &e.kind { + EntityKind::Insert(i) => i.block.eq_ignore_ascii_case(n), + EntityKind::MLeader(m) => m.block.as_ref().is_some_and(|b| b.block.eq_ignore_ascii_case(n)), + _ => false, + }; + let in_spaces = d.model.iter().any(used) || d.layouts.iter().any(|l| l.entities.iter().any(used)); + let in_blocks = d.blocks.values().any(|b| b.entities.iter().any(used) || b.array.as_ref().is_some_and(|a| a.source.eq_ignore_ascii_case(n))); + if !in_spaces && !in_blocks { + d.blocks.remove(n); + } + } +} + +/// Rebuild array `h` with parameters `p` (and its block reference moved to `insert` when +/// given): a new array block for this reference, old blocks removed when unused. +fn rebuild(s: &mut Session, h: Handle, p: ArrayParams, insert: Option) -> Result<()> { + if !p.is_valid() { + return Err(other("The array parameters are not valid.")); + } + let d = s.doc()?; + let (old_ins, _, old) = array_of(d, h)?; + let objs = d.block(&p.source).map(|b| b.entities.len()).ok_or_else(|| other("The array's source block is missing."))?; + check_size(s, p.item_count(), objs)?; + let d = s.doc_mut()?; + let items = p.item_entities(d).ok_or_else(|| other("The array parameters are not valid."))?; + let name = d.anonymous_block_name("*U"); + let mut blk = Block::new(&name); + for e in items { + blk.entities.push(e); + } + blk.array = Some(p); + d.blocks.insert(name.clone(), Arc::new(blk)); + d.modify_entity(h, |e| { + if let EntityKind::Insert(i) = &mut e.kind { + i.block.clone_from(&name); + if let Some(at) = insert { + i.insert = at.to3(i.insert.z); + } + } + }) + .map_err(|e| other(e.to_string()))?; + let mut gone = vec![old_ins.block, old.source]; + gone.extend(old.replaced.into_iter().map(|r| r.block)); + drop_unused(d, &gone); + Ok(()) +} + +/// A new anonymous block from objects `hs` in array `h`'s block coordinates, with its base point +/// at world point `base`. The objects stay in the drawing. +fn block_from(s: &mut Session, h: Handle, hs: &[Handle], base: Vec2) -> Result { + let d = s.doc()?; + let (ins, blk, p) = array_of(d, h)?; + let inv = ins.transform(blk.base.xy()).inverse().ok_or_else(|| other("The array's scale is zero."))?; + let ents: Vec = hs.iter().filter(|x| **x != h).filter_map(|x| d.entity(*x).map(|e| (**e).clone())).collect(); + if ents.is_empty() { + return Err(other("Select objects other than the array.")); + } + if ents.iter().any(|e| refers_to(d, e, &[&ins.block, &p.source])) { + return Err(other("An array's items can't contain the array itself.")); + } + let refs: Vec<&Entity> = ents.iter().collect(); + if nesting(d, &refs, &mut Default::default(), 0) + 2 > MAX_BLOCK_DEPTH { + return Err(other(format!("The objects nest block references too deeply (more than {MAX_BLOCK_DEPTH} levels)."))); + } + let d = s.doc_mut()?; + let name = d.anonymous_block_name("*U"); + let mut b = Block::new(&name); + b.base = v3(inv.apply(base)); + for mut e in ents { + e.handle = d.new_handle(); + e.kind.transform(&inv); + b.entities.push(e); + } + d.blocks.insert(name.clone(), Arc::new(b)); + Ok(name) +} + +/// The centre of objects `hs` (world). +fn centroid(d: &Drawing, hs: &[Handle]) -> Vec2 { + let b = hs.iter().filter_map(|h| d.entity(*h)).fold(Bounds2::EMPTY, |a, e| a.union(&entity_bounds(d, e, 0))); + Some(b.center()).filter(|c| c.is_finite()).unwrap_or_default() +} + +/// The item of array `h` under world point `at` (the lowest level when levels overlap). +fn item_at(d: &Drawing, h: Handle, at: Vec2, tol: f64) -> Option<(u32, u32, u32)> { + let (ins, blk, p) = array_of(d, h).ok()?; + let w = ins.transform(blk.base.xy()); + let mut best: Option<((u32, u32, u32), f64)> = None; + // Each block's own extents, measured once. + let mut extents: HashMap = HashMap::new(); + for it in p.items()? { + let name = p.block_for(it.i, it.j, it.level); + let (base, local) = match extents.get(name) { + Some(x) => *x, + None => { + let b = d.block(name)?; + let x = (b.base.xy(), b.entities.iter().fold(Bounds2::EMPTY, |a, e| a.union(&entity_bounds(d, e, 1)))); + extents.insert(name.to_string(), x); + x + } + }; + let m = w.then_before(Mat3::translate(it.insert.xy())).then_before(Mat3::rotate(it.rotation)).then_before(Mat3::translate(-base)); + if local.is_empty() { + continue; + } + let corners = [local.min, Vec2::new(local.max.x, local.min.y), local.max, Vec2::new(local.min.x, local.max.y)]; + let wb = Bounds2::from_points(corners.iter().map(|c| m.apply(*c))); + let inside = at.x >= wb.min.x - tol && at.x <= wb.max.x + tol && at.y >= wb.min.y - tol && at.y <= wb.max.y + tol; + let dist = wb.center().dist(at); + if inside && best.is_none_or(|(_, bd)| dist < bd - 1e-12) { + best = Some(((it.i, it.j, it.level), dist)); + } + } + best.map(|b| b.0) +} + +/// Move the base point of array `h` to world point `base` without moving the items. +fn rebase(s: &mut Session, h: Handle, base: Vec2) -> Result<()> { + let d = s.doc()?; + let (ins, blk, mut p) = array_of(d, h)?; + let w = ins.transform(blk.base.xy()); + let b = w.inverse().ok_or_else(|| other("The array's scale is zero."))?.apply(base); + if !b.is_finite() { + return Err(other("Requires a valid base point.")); + } + // The source block keeps its objects; its base point moves with the array's. + let src = d.block(&p.source).ok_or_else(|| other("The array's source block is missing."))?; + let mut ns = (**src).clone(); + ns.base = (ns.base.xy() + b).to3(ns.base.z); + let d = s.doc_mut()?; + ns.name = d.anonymous_block_name("*U"); + p.source.clone_from(&ns.name); + d.blocks.insert(ns.name.clone(), Arc::new(ns)); + if let ArrayKind::Polar(k) = &mut p.kind { + k.center -= b; + } + rebuild(s, h, p, Some(w.apply(b))) +} + +/// ARRAYEDIT Source: place the source objects of array `h` at `item` for editing. Returns them. +fn open_source(s: &mut Session, h: Handle, item: (u32, u32, u32)) -> Result> { + refuse_while_editing(s)?; + let d = s.doc()?; + let (ins, blk, p) = array_of(d, h)?; + let src = d.block(&p.source).cloned().ok_or_else(|| other("The array's source block is missing."))?; + let m = ins.transform(blk.base.xy()).then_before(item_place(&p, item)?).then_before(Mat3::translate(-src.base.xy())); + let space = d.space_of(h).unwrap_or_default(); + let d = s.doc_mut()?; + let mut out = Vec::new(); + for e in src.entities.iter() { + let mut ne = (**e).clone(); + ne.handle = d.new_handle(); + ne.kind.transform(&m); + out.push(ne.handle); + d.ensure_layer(&ne.common.layer); + if let Some(st) = d.space_mut(&space) { + st.push(ne); + } + } + let first_new = d.handseed; + let mut b = (*blk).clone(); + if let Some(a) = &mut b.array { + a.editing = Some(SourceEdit { array: h, item, objects: out.clone(), first_new }); + } + d.blocks.insert(b.name.clone(), Arc::new(b)); + s.set_selection(out.clone()); + s.echo("Edit the source objects, then enter ARRAYCLOSE to save or discard the changes."); + Ok(out) +} + +/// ARRAYCLOSE: end the open Source editing, saving the objects as the array's new source or +/// discarding them. +fn close_source(s: &mut Session, save: bool) -> Result { + let d = s.doc()?; + let (blk_name, ed) = open_edit(d).ok_or_else(|| other("No array is being edited."))?; + let space = d.space_of(ed.array).unwrap_or_default(); + let names: Vec = { + let b = d.block(&blk_name); + [Some(blk_name.clone()), b.and_then(|b| b.array.as_ref()).map(|a| a.source.clone())].into_iter().flatten().collect() + }; + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut objs: Vec = ed.objects.iter().copied().filter(|x| d.entity(*x).is_some()).collect(); + if let Some(st) = d.space(&space) { + let fresh: Vec = st + .iter() + .filter(|e| e.handle.0 >= ed.first_new && e.handle != ed.array && !objs.contains(&e.handle) && !refers_to(d, e, &refs)) + .map(|e| e.handle) + .collect(); + objs.extend(fresh); + } + // The array still refers to the block being edited (else it was erased or changed). + let live = d.entity(ed.array).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&blk_name))); + if save && live && objs.is_empty() { + return Err(other("The source would have no objects; enter ARRAYCLOSE No to discard the changes, or erase the array.")); + } + let d = s.doc_mut()?; + if let Some(b) = d.blocks.get_mut(&blk_name) { + let b = Arc::make_mut(b); + if let Some(a) = &mut b.array { + a.editing = None; + } + } + if !save { + for h in &objs { + d.remove_entity(*h); + } + s.set_selection(Vec::new()); + s.echo("The changes to the array were discarded."); + return Ok(json!({ "saved": false })); + } + if !live { + s.echo("The array no longer exists; the edited objects stay in the drawing."); + return Ok(json!({ "saved": false })); + } + // The new source block (base point at its origin) holds the objects as seen from the item + // they were placed at. + let d = s.doc()?; + let (ins, blk, mut p) = array_of(d, ed.array)?; + let inv = ins.transform(blk.base.xy()).then_before(item_place(&p, ed.item)?).inverse().ok_or_else(|| other("The array's scale is zero."))?; + let ents: Vec = objs.iter().filter_map(|x| d.entity(*x).map(|e| (**e).clone())).collect(); + let er: Vec<&Entity> = ents.iter().collect(); + if nesting(d, &er, &mut Default::default(), 0) + 2 > MAX_BLOCK_DEPTH { + return Err(other(format!("The objects nest block references too deeply (more than {MAX_BLOCK_DEPTH} levels)."))); + } + let d = s.doc_mut()?; + let name = d.anonymous_block_name("*U"); + let mut nb = Block::new(&name); + for mut e in ents { + e.handle = d.new_handle(); + e.kind.transform(&inv); + nb.entities.push(e); + } + d.blocks.insert(name.clone(), Arc::new(nb)); + for h in &objs { + d.remove_entity(*h); + } + p.source = name; + rebuild(s, ed.array, p, None)?; + s.set_selection(Vec::new()); + Ok(json!({ "saved": true })) +} + +// ---------------- JSON ---------------- + +fn item_param(v: &Value) -> Option<(u32, u32, u32)> { + let a = v.as_array()?; + let n = |k: usize| a.get(k).and_then(Value::as_u64).and_then(|x| u32::try_from(x).ok()); + Some((n(0)?, n(1).unwrap_or(0), n(2).unwrap_or(0))) +} + +fn count_param(p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .filter(|n| *n >= 1) + .and_then(|n| u32::try_from(n).ok()) + .map(Some) + .ok_or_else(|| bad("arrayedit", format!("`{key}` must be a whole number of at least 1"))), + } +} + +fn num_param(p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().filter(|x| x.is_finite()).map(Some).ok_or_else(|| bad("arrayedit", format!("`{key}` must be a number"))), + } +} + +/// The parameter changes of the JSON form applied to `a`. +fn apply_params(a: &mut ArrayParams, p: &Value, to_local: &Mat3) -> Result<()> { + if let Some(n) = count_param(p, "levels")? { + a.levels = n; + } + if let Some(x) = num_param(p, "levelSpacing")? { + a.level_spacing = x; + } + match &mut a.kind { + ArrayKind::Rect(r) => { + if let Some(n) = count_param(p, "cols")? { + r.cols = n; + } + if let Some(n) = count_param(p, "rows")? { + r.rows = n; + } + if let Some(x) = num_param(p, "colSpacing")? { + r.col_spacing = x; + } + if let Some(x) = num_param(p, "rowSpacing")? { + r.row_spacing = x; + } + if let Some(x) = num_param(p, "axisAngle")? { + r.axis_angle = x.to_radians(); + } + if let Some(x) = num_param(p, "rowElevation")? { + r.row_elevation = x; + } + } + ArrayKind::Polar(k) => { + if let Some(n) = count_param(p, "items")? { + k.items = n; + } + if let Some(n) = count_param(p, "rows")? { + k.rows = n; + } + if let Some(x) = num_param(p, "rowSpacing")? { + k.row_spacing = x; + } + if let Some(x) = num_param(p, "rowElevation")? { + k.row_elevation = x; + } + if let Some(f) = num_param(p, "angle")? { + k.fill = fill_angle(f)?; + } + if let Some(b) = num_param(p, "angleBetween")? { + k.fill = fill_from_between(b, k.items)?; + } + if let Some(r) = p.get("rotate").and_then(Value::as_bool) { + k.rotate = r; + } + if let Some(c) = point_param(p, "center") { + k.center = to_local.apply(c); + } + } + } + Ok(()) +} + +fn fill_angle(f: f64) -> Result { + if f.abs() < 1e-9 || f.abs() > 360.0 + 1e-9 { + return Err(other("Requires a fill angle between -360 and 360 degrees (not 0).")); + } + Ok(f.clamp(-360.0, 360.0)) +} + +fn fill_from_between(b: f64, items: u32) -> Result { + if b.abs() < 1e-9 { + return Err(other("Requires a nonzero angle.")); + } + let n = f64::from(items); + let fill = if (b.abs() * n - 360.0).abs() < 1e-9 { 360.0 * b.signum() } else { b * (n - 1.0).max(1.0) }; + if fill.abs() > 360.0 + 1e-9 { + return Err(other("The items would span more than 360 degrees; enter a smaller angle or fewer items.")); + } + Ok(fill) +} + +fn run_arrayedit(s: &mut Session, p: &Value) -> Result { + let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad("arrayedit", "`handle` (the array) is required"))?; + refuse_while_editing(s)?; + let (ins, blk, _) = array_of(s.doc()?, h)?; + let w = ins.transform(blk.base.xy()); + let to_local = w.inverse().ok_or_else(|| other("The array's scale is zero."))?; + if bool_or(p, "reset", false) { + let (_, _, mut a) = array_of(s.doc()?, h)?; + a.replaced.clear(); + rebuild(s, h, a, None)?; + } + if let Some(r) = p.get("replace").filter(|v| !v.is_null()) { + let hs = targets(s, r)?; + let base = point_param(r, "base").unwrap_or_else(|| s.doc().map(|d| centroid(d, &hs)).unwrap_or_default()); + let name = block_from(s, h, &hs, base)?; + let (_, _, mut a) = array_of(s.doc()?, h)?; + match r.get("items") { + Some(Value::String(t)) if t.eq_ignore_ascii_case("source") => a.source = name, + Some(Value::Array(list)) => { + for v in list.iter().take(cadcraft_doc::MAX_ARRAY_ITEMS as usize) { + let (i, j, level) = item_param(v).ok_or_else(|| bad("arrayedit", "`replace.items` holds [i, j, level] item numbers"))?; + a.replaced.push(ReplacedItem { i, j, level, block: name.clone() }); + } + } + _ => return Err(bad("arrayedit", "`replace.items` must be a list of [i, j, level] or \"source\"")), + } + rebuild(s, h, a, None)?; + } + if let Some(b) = point_param(p, "base") { + rebase(s, h, b)?; + } + let (_, _, mut a) = array_of(s.doc()?, h)?; + let before = a.clone(); + apply_params(&mut a, p, &to_local)?; + if a != before { + rebuild(s, h, a, None)?; + } + if let Some(src) = p.get("source").filter(|v| !v.is_null()) { + let item = src + .get("item") + .map(|v| item_param(v).ok_or_else(|| bad("arrayedit", "`source.item` is [i, j, level]"))) + .transpose()? + .unwrap_or((0, 0, 0)); + let objs = open_source(s, h, item)?; + return Ok(json!({ "handle": h.hex(), "source": objs.iter().map(|x| x.hex()).collect::>() })); + } + Ok(json!({ "handle": h.hex() })) +} + +fn run_arrayclose(s: &mut Session, p: &Value) -> Result { + close_source(s, bool_or(p, "save", true)) +} + +// ---------------- interactive ---------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Stage { + #[default] + Select, + Main, + Base, + ColsCount, + ColsSpace, + ColsTotal, + RowsCount, + RowsSpace, + RowsTotal, + RowsElev, + LevelsCount, + LevelsSpace, + LevelsTotal, + Items, + Between, + Fill, + Rotate, + /// REPlace: objects, their base point, then the items to replace. + RepSelect, + RepBase, + RepItems, + /// Source: the item whose objects are placed for editing. + SourceItem, +} + +/// ARRAYEDIT: select an array, then change it option by option until eXit. +#[derive(Default)] +struct EditM { + stage: Stage, + array: Option, + sel: SelectPhase, + rep_objs: Vec, + rep_block: Option, +} + +impl EditM { + /// The array's parameters as stored (cheap: the prompt asks for them on every redraw). + fn params(&self, s: &Session) -> Option { + let d = s.doc().ok()?; + match &d.entity(self.array?)?.kind { + EntityKind::Insert(i) => d.block(&i.block)?.array.clone(), + _ => None, + } + } + + /// Apply `f` to the array's parameters and rebuild it. + fn change(&self, s: &mut Session, f: impl FnOnce(&mut ArrayParams) -> Result<()>) -> Result<()> { + let h = self.array.ok_or_else(|| other("No array selected."))?; + let (_, _, mut p) = array_of(s.doc()?, h)?; + f(&mut p)?; + rebuild(s, h, p, None) + } + + fn objs(&self, s: &Session) -> usize { + self.params(s).and_then(|p| s.doc().ok().and_then(|d| d.block(&p.source).map(|b| b.entities.len()))).unwrap_or(1) + } + + /// The largest count for one direction when the others hold `others` items. + fn max_for(&self, s: &Session, others: u64) -> u64 { + super::max_count(s, others, self.objs(s)) + } + + /// A distance in the array's own units: typed, or picked as the distance from the base point. + fn distance(&self, s: &Session, i: &Input, cur: f64) -> Result> { + let d = match i { + Input::Enter => return Ok(Some(cur)), + Input::Text(t) => number(t), + Input::Point(p) => { + let h = self.array.ok_or_else(|| other("No array selected."))?; + let (ins, blk, _) = array_of(s.doc()?, h)?; + ins.transform(blk.base.xy()).inverse().map(|inv| inv.apply(*p).len()) + } + _ => return Ok(None), + }; + d.filter(|d| d.is_finite() && d.abs() > 1e-12).map(Some).ok_or_else(|| other("Requires a nonzero distance.")) + } + + fn main(&mut self, s: &mut Session, i: Input) -> Result { + let k = match i { + Input::Enter => return Ok(Step::Done), + Input::Keyword(k) => k, + _ => return Err(other("Invalid option keyword.")), + }; + self.stage = match k.as_str() { + "eXit" => return Ok(Step::Done), + "Source" => Stage::SourceItem, + "REPlace" => { + self.sel = SelectPhase::default(); + Stage::RepSelect + } + "Base point" => Stage::Base, + "Rows" => Stage::RowsCount, + "Columns" => Stage::ColsCount, + "Levels" => Stage::LevelsCount, + "Items" => Stage::Items, + "Angle between" => Stage::Between, + "Fill angle" => Stage::Fill, + "ROTate items" => Stage::Rotate, + "RESet" => { + self.change(s, |p| { + p.replaced.clear(); + Ok(()) + })?; + s.echo("Replaced items were restored."); + Stage::Main + } + _ => return Err(other("Invalid option keyword.")), + }; + Ok(Step::Continue) + } +} + +impl Interactive for EditM { + fn name(&self) -> &'static str { + "ARRAYEDIT" + } + fn begin(&mut self, s: &mut Session) -> Result { + refuse_while_editing(s)?; + let pre = s.selection(); + if let [h] = pre.as_slice() + && s.doc().ok().and_then(|d| d.entity(*h).and_then(|e| d.array_params(e))).is_some() + { + self.array = Some(*h); + self.stage = Stage::Main; + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + use Stage::*; + let p = self.params(s); + let rect = p.as_ref().and_then(|p| match &p.kind { + ArrayKind::Rect(r) => Some(r.clone()), + ArrayKind::Polar(_) => None, + }); + let polar = p.as_ref().and_then(|p| match &p.kind { + ArrayKind::Polar(k) => Some(k.clone()), + ArrayKind::Rect(_) => None, + }); + let (levels, ls) = p.as_ref().map(|p| (p.levels, p.level_spacing)).unwrap_or((1, 0.0)); + let (cols, rows, cs, rs, elev) = match (&rect, &polar) { + (Some(r), _) => (r.cols, r.rows, r.col_spacing, r.row_spacing, r.row_elevation), + (_, Some(k)) => (k.items, k.rows, 0.0, k.row_spacing, k.row_elevation), + _ => (1, 1, 0.0, 0.0, 0.0), + }; + let dist = |m: &str, d: f64| Prompt::new(m, Accept::POINT_OR_NUMBER).default(fmt(d)); + let total = |n: u32, d: f64| d * f64::from(n.saturating_sub(1)); + match self.stage { + Select => Prompt::new("Select array", Accept::SELECT), + Main if polar.is_some() => Prompt::new("Enter an option", KEYWORDS) + .kw(&["Source", "REPlace", "Base point", "Items", "Angle between", "Fill angle", "Rows", "Levels", "ROTate items", "RESet", "eXit"]) + .default("eXit"), + Main => Prompt::new("Enter an option", KEYWORDS) + .kw(&["Source", "REPlace", "Base point", "Rows", "Columns", "Levels", "RESet", "eXit"]) + .default("eXit"), + Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Key point"]).default("centroid"), + ColsCount => Prompt::new("Enter the number of columns", Accept::NUMBER).default(cols.to_string()), + ColsSpace => dist("Specify the distance between columns", cs).kw(&["Total"]), + ColsTotal => dist("Specify the distance between the first and last column", total(cols, cs)), + RowsCount => Prompt::new("Enter the number of rows", Accept::NUMBER).default(rows.to_string()), + RowsSpace => dist("Specify the distance between rows", rs).kw(&["Total"]), + RowsTotal => dist("Specify the distance between the first and last row", total(rows, rs)), + RowsElev => Prompt::new("Specify the incrementing elevation between rows", Accept::NUMBER).default(fmt(elev)), + LevelsCount => Prompt::new("Enter the number of levels", Accept::NUMBER).default(levels.to_string()), + LevelsSpace => dist("Specify the distance between levels", ls).kw(&["Total"]), + LevelsTotal => dist("Specify the distance between the first and last level", total(levels, ls)), + Items => Prompt::new("Enter number of items in array", Accept::NUMBER).default(cols.to_string()), + Between => { + let b = polar.as_ref().map_or(0.0, |k| { + let full = (k.fill.abs() - 360.0).abs() < 1e-9; + if k.items <= 1 { + k.fill + } else if full { + k.fill / f64::from(k.items) + } else { + k.fill / f64::from(k.items - 1) + } + }); + Prompt::new("Specify the angle between items", Accept::NUMBER).default(fmt(b)) + } + Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default(fmt(polar.as_ref().map_or(360.0, |k| k.fill))), + Rotate => Prompt::new("Rotate arrayed items?", KEYWORDS).kw(&["Yes", "No"]).default(yes(polar.as_ref().is_none_or(|k| k.rotate))), + RepSelect => Prompt::new("Select replacement objects", Accept::SELECT), + RepBase => Prompt::new("Select base point of replacement objects", Accept::POINT).kw(&["Key point"]).default("centroid"), + RepItems => Prompt::new("Select an item in array to replace", Accept::POINT).kw(&["Source objects"]), + SourceItem => Prompt::new("Select item in array", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + use Stage::*; + let stage = self.stage; + if stage == Select { + return match i { + Input::Pick(hs) => { + let d = s.doc()?; + match hs.iter().copied().find(|h| d.entity(*h).and_then(|e| d.array_params(e)).is_some()) { + Some(h) => { + self.array = Some(h); + self.stage = Main; + Ok(Step::Continue) + } + None => Err(other("The object is not an associative array.")), + } + } + Input::Enter | Input::Cancel => Ok(Step::Done), + _ => Ok(Step::Continue), + }; + } + if stage == RepSelect { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => { + self.stage = Main; + Ok(Step::Continue) + } + SelOutcome::Done(hs) => { + self.rep_objs = hs; + self.stage = RepBase; + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + }; + } + let h = self.array.ok_or_else(|| other("No array selected."))?; + let (cols, rows, levels) = self.params(s).map(|p| (p.grid().0, p.grid().1, p.levels)).unwrap_or((1, 1, 1)); + let (c64, r64, l64) = (u64::from(cols), u64::from(rows), u64::from(levels)); + let next = match (stage, &i) { + (Main, _) => return self.main(s, i), + (Base, Input::Point(p)) => { + rebase(s, h, *p)?; + Main + } + (Base, Input::Enter) => { + let d = s.doc()?; + let (ins, blk, p) = array_of(d, h)?; + let src = d.block(&p.source).ok_or_else(|| other("The array's source block is missing."))?; + let local = src.entities.iter().fold(Bounds2::EMPTY, |a, e| a.union(&entity_bounds(d, e, 1))); + let at = ins.transform(blk.base.xy()).then_before(Mat3::translate(-src.base.xy())).apply(local.center()); + if at.is_finite() { + rebase(s, h, at)?; + } + Main + } + (Base | RepBase, Input::Keyword(_)) => { + s.echo("Key points are not available yet; specify a point."); + stage + } + (ColsCount | Items, _) => match count(&i, c64, self.max_for(s, r64.saturating_mul(l64)))? { + Some(n) => { + let n = super::count32(n); + self.change(s, |p| { + match &mut p.kind { + ArrayKind::Rect(r) => r.cols = n, + ArrayKind::Polar(k) => k.items = n, + } + Ok(()) + })?; + if stage == ColsCount { ColsSpace } else { Main } + } + None => stage, + }, + (RowsCount, _) => match count(&i, r64, self.max_for(s, c64.saturating_mul(l64)))? { + Some(n) => { + let n = super::count32(n); + self.change(s, |p| { + match &mut p.kind { + ArrayKind::Rect(r) => r.rows = n, + ArrayKind::Polar(k) => k.rows = n, + } + Ok(()) + })?; + RowsSpace + } + None => stage, + }, + (LevelsCount, _) => match count(&i, l64, self.max_for(s, c64.saturating_mul(r64)))? { + Some(n) => { + let n = super::count32(n); + self.change(s, |p| { + p.levels = n; + Ok(()) + })?; + LevelsSpace + } + None => stage, + }, + (ColsSpace, Input::Keyword(_)) => ColsTotal, + (RowsSpace, Input::Keyword(_)) => RowsTotal, + (LevelsSpace, Input::Keyword(_)) => LevelsTotal, + (ColsSpace | ColsTotal | RowsSpace | RowsTotal | LevelsSpace | LevelsTotal, _) => { + let p = self.params(s).ok_or_else(|| other("No array selected."))?; + let (cur, n) = match (stage, &p.kind) { + (ColsSpace | ColsTotal, ArrayKind::Rect(r)) => (r.col_spacing, r.cols), + (RowsSpace | RowsTotal, ArrayKind::Rect(r)) => (r.row_spacing, r.rows), + (RowsSpace | RowsTotal, ArrayKind::Polar(k)) => (k.row_spacing, k.rows), + (LevelsSpace | LevelsTotal, _) => (p.level_spacing, p.levels), + _ => (0.0, 1), + }; + let is_total = matches!(stage, ColsTotal | RowsTotal | LevelsTotal); + let gaps = f64::from(n.saturating_sub(1).max(1)); + let shown = if is_total { cur * f64::from(n.saturating_sub(1)) } else { cur }; + match self.distance(s, &i, shown)? { + Some(d) => { + let v = if is_total { d / gaps } else { d }; + self.change(s, |p| { + match (stage, &mut p.kind) { + (ColsSpace | ColsTotal, ArrayKind::Rect(r)) => r.col_spacing = v, + (RowsSpace | RowsTotal, ArrayKind::Rect(r)) => r.row_spacing = v, + (RowsSpace | RowsTotal, ArrayKind::Polar(k)) => k.row_spacing = v, + _ => p.level_spacing = v, + } + Ok(()) + })?; + if matches!(stage, RowsSpace | RowsTotal) { RowsElev } else { Main } + } + None => stage, + } + } + (RowsElev, Input::Enter) => Main, + (RowsElev, Input::Text(t)) => match number(t) { + Some(z) if z.is_finite() => { + self.change(s, |p| { + match &mut p.kind { + ArrayKind::Rect(r) => r.row_elevation = z, + ArrayKind::Polar(k) => k.row_elevation = z, + } + Ok(()) + })?; + Main + } + _ => return Err(other("Requires a number.")), + }, + (Between | Fill, Input::Enter) => Main, + (Between | Fill, _) => match angle(&i, 0.0, &s.angle_settings())? { + Some(a) => { + self.change(s, |p| { + if let ArrayKind::Polar(k) = &mut p.kind { + k.fill = if stage == Fill { fill_angle(a)? } else { fill_from_between(a, k.items)? }; + } + Ok(()) + })?; + Main + } + None => stage, + }, + (Rotate, _) => { + let cur = self.params(s).is_none_or(|p| !matches!(p.kind, ArrayKind::Polar(ref k) if !k.rotate)); + match yes_no(&i, cur) { + Some(r) => { + self.change(s, |p| { + if let ArrayKind::Polar(k) = &mut p.kind { + k.rotate = r; + } + Ok(()) + })?; + Main + } + None => return Err(other("Requires Yes or No.")), + } + } + (RepBase, Input::Point(_) | Input::Enter) => { + let base = match &i { + Input::Point(p) => *p, + _ => centroid(s.doc()?, &self.rep_objs), + }; + self.rep_block = Some(block_from(s, h, &self.rep_objs.clone(), base)?); + RepItems + } + (RepItems, Input::Point(at)) => { + let tol = s.pixel_size() * s.settings.pickbox; + let item = item_at(s.doc()?, h, *at, tol).ok_or_else(|| other("No array item there; select an item of the array."))?; + let name = self.rep_block.clone().ok_or_else(|| other("No replacement objects."))?; + self.change(s, |p| { + p.replaced.push(ReplacedItem { i: item.0, j: item.1, level: item.2, block: name }); + Ok(()) + })?; + RepItems + } + (RepItems, Input::Keyword(_)) => { + let name = self.rep_block.clone().ok_or_else(|| other("No replacement objects."))?; + self.change(s, |p| { + p.source = name; + Ok(()) + })?; + Main + } + (RepItems, Input::Enter) => Main, + (SourceItem, Input::Point(at)) => { + let tol = s.pixel_size() * s.settings.pickbox; + let item = item_at(s.doc()?, h, *at, tol).ok_or_else(|| other("No array item there; select an item of the array."))?; + open_source(s, h, item)?; + return Ok(Step::Done); + } + (SourceItem, Input::Enter) => Main, + _ => stage, + }; + self.stage = next; + Ok(Step::Continue) + } +} + +/// ARRAYCLOSE: save or discard the source objects being edited. +struct CloseM; + +impl Interactive for CloseM { + fn name(&self) -> &'static str { + "ARRAYCLOSE" + } + fn begin(&mut self, s: &mut Session) -> Result { + if open_edit(s.doc()?).is_none() { + return Err(other("No array is being edited.")); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + Prompt::new("Save changes to array?", KEYWORDS).kw(&["Yes", "No"]).default("Yes") + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match yes_no(&i, true) { + Some(save) => { + close_source(s, save)?; + Ok(Step::Done) + } + None => Err(other("Requires Yes or No.")), + } + } +} diff --git a/crates/engine/tests/assoc_array.rs b/crates/engine/tests/assoc_array.rs index 5c0a75f..131210f 100644 --- a/crates/engine/tests/assoc_array.rs +++ b/crates/engine/tests/assoc_array.rs @@ -201,6 +201,170 @@ fn pasted_arrays_get_their_own_blocks() { assert!(params.iter().all(|p| d.block(&p.source).is_some())); } +/// Circle centres drawn by the array `h`, sorted. +fn centres(s: &Session, h: &Value) -> Vec<(i64, i64)> { + let d = s.doc().unwrap(); + let e = d.entity(Handle::parse_hex(h.as_str().unwrap()).unwrap()).unwrap().as_ref().clone(); + let mut v: Vec<(i64, i64)> = circles(d, &e).iter().map(|(p, _)| ((p.x * 1000.0).round() as i64, (p.y * 1000.0).round() as i64)).collect(); + v.sort(); + v +} + +fn lines_in(s: &Session, h: &Value) -> usize { + fn walk(d: &Drawing, e: &Entity, depth: usize) -> usize { + match &e.kind { + EntityKind::Line(_) => 1, + EntityKind::Insert(i) if depth < 8 => d.block(&i.block).unwrap().entities.iter().map(|b| walk(d, b, depth + 1)).sum(), + _ => 0, + } + } + let d = s.doc().unwrap(); + walk(d, d.entity(Handle::parse_hex(h.as_str().unwrap()).unwrap()).unwrap(), 0) +} + +fn rect_array(s: &mut Session) -> Value { + let h = circle(s, 0.0, 0.0); + s.execute("arrayrect", &json!({"handles": [h], "rows": 1, "cols": 3, "colSpacing": 2})).unwrap()["handle"].clone() +} + +#[test] +fn arrayedit_changes_parameters_of_one_array() { + let mut s = Session::new(); + let a = rect_array(&mut s); + let b = s.execute("copy", &json!({"handles": [a], "delta": [0, 100]})).unwrap(); + let b = s.doc().unwrap().model.iter().map(|e| json!(e.handle.hex())).find(|x| *x != a).unwrap_or(b); + s.execute("arrayedit", &json!({"handle": a, "cols": 2, "rows": 2, "rowSpacing": 5, "colSpacing": 3})).unwrap(); + assert_eq!(centres(&s, &a), vec![(0, 0), (0, 5000), (3000, 0), (3000, 5000)]); + // The copy that shared the array's block is unchanged. + assert_eq!(centres(&s, &b), vec![(0, 100_000), (2000, 100_000), (4000, 100_000)]); + // Blocks nothing uses are gone: two arrays, each with an array block and a shared source. + let anon = s.doc().unwrap().blocks.keys().filter(|k| k.starts_with('*')).count(); + assert_eq!(anon, 3); + + // Polar: items, fill, rotation, centre. + let mut s = Session::new(); + let h = circle(&mut s, 10.0, 0.0); + let p = s.execute("arraypolar", &json!({"handles": [h], "center": [0, 0], "count": 4})).unwrap()["handle"].clone(); + s.execute("arrayedit", &json!({"handle": p, "items": 3, "angle": 180})).unwrap(); + assert_eq!(centres(&s, &p), vec![(-10000, 0), (0, 10000), (10000, 0)]); + s.execute("arrayedit", &json!({"handle": p, "angleBetween": 45, "center": [0, 10]})).unwrap(); + assert_eq!(centres(&s, &p).len(), 3); + assert!(centres(&s, &p).contains(&(10000, 0))); + + // Bad values are refused and change nothing. + let before = centres(&s, &p); + assert!(s.execute("arrayedit", &json!({"handle": p, "items": 0})).is_err()); + assert!(s.execute("arrayedit", &json!({"handle": p, "items": 10_000_000})).unwrap_err().to_string().contains("MAXARRAY")); + assert!(s.execute("arrayedit", &json!({"handle": p, "angle": 400})).is_err()); + assert_eq!(centres(&s, &p), before); + let c = circle(&mut s, 50.0, 50.0); + assert!(s.execute("arrayedit", &json!({"handle": c, "items": 3})).is_err()); +} + +#[test] +fn arrayedit_base_replace_and_reset() { + let mut s = Session::new(); + let a = rect_array(&mut s); + // A new base point moves the reference, not the items. + s.execute("arrayedit", &json!({"handle": a, "base": [7, 7]})).unwrap(); + assert_eq!(centres(&s, &a), vec![(0, 0), (2000, 0), (4000, 0)]); + let d = s.doc().unwrap(); + let e = d.entity(Handle::parse_hex(a.as_str().unwrap()).unwrap()).unwrap(); + assert!(matches!(&e.kind, EntityKind::Insert(i) if i.insert.xy().near(Vec2::new(7.0, 7.0), 1e-9))); + // Spacing still counts from the items. + s.execute("arrayedit", &json!({"handle": a, "colSpacing": 4})).unwrap(); + assert_eq!(centres(&s, &a), vec![(0, 0), (4000, 0), (8000, 0)]); + + // REPlace item 1 with a line; the line stays in the drawing. + let l = s.execute("line", &json!({"points": [[0, 20], [1, 20]]})).unwrap()["handles"][0].clone(); + s.execute("arrayedit", &json!({"handle": a, "replace": {"handles": [l], "base": [0, 20], "items": [[1, 0, 0]]}})).unwrap(); + assert_eq!(centres(&s, &a), vec![(0, 0), (8000, 0)]); + assert_eq!(lines_in(&s, &a), 1); + assert_eq!(s.doc().unwrap().model.len(), 2); + // Replacing with the array itself is refused. + assert!(s.execute("arrayedit", &json!({"handle": a, "replace": {"handles": [a], "items": [[0, 0, 0]]}})).is_err()); + // RESet restores the item. + s.execute("arrayedit", &json!({"handle": a, "reset": true})).unwrap(); + assert_eq!(centres(&s, &a).len(), 3); + assert_eq!(lines_in(&s, &a), 0); + // REPlace Source objects: every item becomes the line. + s.execute("arrayedit", &json!({"handle": a, "replace": {"handles": [l], "items": "source"}})).unwrap(); + assert_eq!(centres(&s, &a).len(), 0); + assert_eq!(lines_in(&s, &a), 3); + // EXPLODE gives three lines (plus the original one). + s.execute("explode", &json!({"handles": [a]})).unwrap(); + assert_eq!(s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count(), 4); +} + +#[test] +fn arrayedit_source_then_arrayclose() { + let mut s = Session::new(); + let a = rect_array(&mut s); + // Source places the circle of item 2 (at x = 4) in the drawing. + let r = s.execute("arrayedit", &json!({"handle": a, "source": {"item": [2, 0, 0]}})).unwrap(); + let placed = r["source"].as_array().unwrap().clone(); + assert_eq!(placed.len(), 1); + // Only one array at a time; other edits wait for ARRAYCLOSE. + assert!(s.execute("arrayedit", &json!({"handle": a, "cols": 5})).is_err()); + // Move the circle up by 1 and draw a line next to it: both become the source. + s.execute("move", &json!({"handles": placed, "delta": [0, 1]})).unwrap(); + s.execute("line", &json!({"points": [[4, 3], [5, 3]]})).unwrap(); + s.execute("arrayclose", &json!({})).unwrap(); + assert_eq!(centres(&s, &a), vec![(0, 1000), (2000, 1000), (4000, 1000)]); + assert_eq!(lines_in(&s, &a), 3); + assert_eq!(s.doc().unwrap().model.len(), 1); + + // Discard: the placed objects go, the array stays as it was. + s.execute("arrayedit", &json!({"handle": a, "source": {}})).unwrap(); + assert_eq!(s.doc().unwrap().model.len(), 3); + s.execute("arrayclose", &json!({"save": false})).unwrap(); + assert_eq!(s.doc().unwrap().model.len(), 1); + assert_eq!(centres(&s, &a).len(), 3); + assert!(s.execute("arrayclose", &json!({})).is_err()); + + // Saving an empty source is refused; the edit stays open. + let r = s.execute("arrayedit", &json!({"handle": a, "source": {}})).unwrap(); + s.execute("erase", &json!({"handles": r["source"]})).unwrap(); + assert!(s.execute("arrayclose", &json!({})).is_err()); + s.execute("arrayclose", &json!({"save": false})).unwrap(); + assert_eq!(centres(&s, &a).len(), 3); +} + +#[test] +fn typed_arrayedit() { + let mut s = Session::new(); + let a = rect_array(&mut s); + typed(&mut s, &["arrayedit", "l"]); + let p = s.prompt_text(); + assert!(p.contains("Enter an option or [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] "), "{p}"); + typed(&mut s, &["c", "4", "t", "9", "r", "2", "5", ""]); + assert!(s.prompt_text().contains("Enter an option"), "{}", s.prompt_text()); + assert_eq!(centres(&s, &a).len(), 8); + assert!(centres(&s, &a).contains(&(9000, 5000)), "{:?}", centres(&s, &a)); + typed(&mut s, &["x"]); + assert!(s.current_prompt().is_none(), "{}", s.prompt_text()); + // One undo step takes the whole edit back. + s.execute("undo", &json!({})).unwrap(); + assert_eq!(centres(&s, &a).len(), 3); + + // REPlace with a typed pick of item 2, then Source on item 0 and ARRAYCLOSE. + s.execute("line", &json!({"points": [[0, 20], [1, 20]]})).unwrap(); + s.set_selection(Vec::new()); + typed(&mut s, &["arrayedit", "1,0"]); + typed(&mut s, &["rep", "l", "", ""]); + assert!(s.prompt_text().contains("Select an item in array to replace or [Source objects]"), "{}", s.prompt_text()); + typed(&mut s, &["4,0", "", "x"]); + assert_eq!(centres(&s, &a).len(), 2); + assert_eq!(lines_in(&s, &a), 1); + typed(&mut s, &["arrayedit", "1,0", "s", "0,0"]); + assert!(s.current_prompt().is_none(), "{}", s.prompt_text()); + typed(&mut s, &["arrayclose"]); + assert!(s.prompt_text().contains("Save changes to array? [Yes/No] "), "{}", s.prompt_text()); + typed(&mut s, &[""]); + assert!(s.current_prompt().is_none(), "{}", s.prompt_text()); + assert_eq!(centres(&s, &a).len(), 2); +} + #[test] fn json_arrays_follow_arrayassociativity() { // With ARRAYASSOCIATIVITY 0 the JSON form makes copies unless `associative` asks otherwise. diff --git a/crates/io/tests/assoc_array_roundtrip.rs b/crates/io/tests/assoc_array_roundtrip.rs index 4bdf585..2b869d9 100644 --- a/crates/io/tests/assoc_array_roundtrip.rs +++ b/crates/io/tests/assoc_array_roundtrip.rs @@ -23,6 +23,7 @@ fn params(source: &str) -> ArrayParams { levels: 2, level_spacing: 4.0, replaced: vec![ReplacedItem { i: 1, j: 0, level: 0, block: "Größe".into() }], + editing: None, } } diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e77e624..a7d3068 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1041,3 +1041,5 @@ Convert to Arc Перетворити на дугу Convert to Line Перетворити на відрізок Stretch Vertex Розтягнути вершину + Edit Array Редагувати масив + Close Array Editing Завершити редагування масиву